Для сопровождения программы код приходится читать, и тем это делать проще, чем больше он похож на естественный язык, — тогда быстрее вникаешь и сосредотачиваешься на главном.
В прошлых двух статьях я показал, что тщательно выбранные слова помогают лучше понимать суть написанного, но думать только о них недостаточно, ведь всякое слово существует в двух формах: как само по себе и как часть предложения. Повтор CurrentThread
ещё не повтор, пока мы не читаем его в контексте Thread.CurrentThread
.
Таким образом, ориентируясь в нотах и простых мелодиях, мы посмотрим теперь, что такое музыка.
Оглавление цикла
- Объекты
- Действия и свойства
- Код как текст
Код как текст
Большинство fluent-интерфейсов разрабатываются с упором на внешнее, а не внутреннее, поэтому их так легко читать. Разумеется, не бесплатно: содержание в некотором смысле ослабевает. Так, скажем, в пакете FluentAssertions
можно написать: (2 + 2).Should().Be(4, because: "2 + 2 is 4!")
, и, относительно чтения, because
смотрится элегантно, но внутри метода Be()
ожидается, скорее, параметр error
или errorMessage
.
На мой взгляд, такие послабления несущественны. Когда мы соглашаемся, что код — это текст, его составляющие перестают принадлежать сами себе: они теперь часть некоего всеобщего "Эфира".
Покажу на примерах, как такие соображения становятся опытом.
Interlocked
Напомню случай с Interlocked
, который мы из Interlocked.CompareExchange(ref x, newX, oldX)
превратили в Atomically.Change(ref x, from: oldX, to: newX)
, используя понятные имена методов и параметров.
ExceptWith
У типа ISet<>
есть метод, который называется ExceptWith
. Если посмотреть на вызов вроде items.ExceptWith(other)
, не сразу сообразишь, что происходит. Но стоит только написать: items.Exclude(other)
, как всё становится на свои места.
GetValueOrDefault
При работе с Nullable<T>
обращение к x.Value
бросит исключение, если в x
находится null
. Если получить Value
всё-таки нужно, используется x.GetValueOrDefault
: это или Value
, или значение по умолчанию. Громоздко.
Выражению "или x, или значение по умолчанию" сооветствует короткое и изящное x.OrDefault
.
int? x = null;
var a = x.GetValueOrDefault(); // Сложное, большущее выражение. Не годится.
var b = x.OrDefault(); // Простое — как пишется, так и читается.
var c = x.Or(10); // А можно ещё вот как.
С OrDefault
и Or
есть одно но, которое стоит помнить: при работе с оператором .?
нельзя написать нечто вроде x?.IsEnabled.Or(false)
, только (x?.IsEnabled).Or(false)
(проще говоря, оператор .?
отменяет всю правую часть, если в левой null
).
Шаблон можно применить при работе с IEnumerable<T>
:
IEnumerable<int> numbers = null;
// Многословно.
var x = numbers ?? Enumerable.Empty<int>();
// Коротко и изящно.
var x = numbers.OrEmpty();
Math.Min
и Math.Max
Идею с Or
можно развить на числовые типы. Положим, требуется взять максимальное число из a
и b
. Тогда мы пишем: Math.Max(a, b)
или a > b ? a : b
. Оба варианта выглядят достаточно привычно, но, тем не менее, не похожи на естественный язык.
Заменить можно на: a.Or(b).IfLess()
— взять a
или b
, если a
меньше. Подходит для таких ситуаций:
Creature creature = ...;
int damage = ...;
// Обычно пишется так.
creature.Health = Math.Max(creature.Health - damage, 0);
// Fluent.
creature.Health = (creature.Health - damage).Or(0).IfGreater();
// Но ещё сильнее:
creature.Health = (creature.Health - damage).ButNotLess(than: 0);
string.Join
Иногда нужно последовательность собрать в строку, разделяя элементы пробелом или запятой. Для этого используется string.Join
, например, так: string.Join(", ", new [] { 1, 2, 3 }); // Получим "1, 2, 3".
.
Простое "Раздели числа запятой" может стать вдруг "Присоедини запятую к каждому числу из списка" — это уж точно не код как текст.
var numbers = new [] { 1, 2, 3 };
// "Присоединяем" запятую к числам — не звучит.
var x = string.Join(", ", numbers);
// Разделяем числа запятой — интуитивно!
var x = numbers.Separated(with: ", ");
Regex
Впрочем, string.Join
вполне безобиден по сравнению с тем, как подчас неверно и не по назначению используется Regex
. Там, где можно обойтись простым читаемым текстом, почему-то предпочитается переусложнённая запись.
Начнём с простого — определения, что строка представляет набор цифр:
string id = ...;
// Коротко, но избыточно.
var x = Regex.IsMatch(id, "^[0-9]*$");
// Сильнее.
var x = id.All(x => x.IsDigit());
// Идеально!
var x = id.IsNumer();
Другой случай — узнаём, есть ли в строке хоть один символ из последовательности:
string text = ...;
// Сумбурно и путано.
var x = Regex.IsMatch(text, @"["<>[]'");
// Коротко и ясно. (И быстрее.)
var x = text.ContainsAnyOf('"', '<', '>', '[', ']', '\'');
// Или так.
var x = text.ContainsAny(charOf: @"["<>[]'");
Чем сложнее задача, тем сложнее "узор" решения: чтобы разбить запись вида "HelloWorld"
на несколько слов "Hello World"
, кому-то вместо простого алгоритма захотелось монстра:
string text = ...;
// Даже с онлайн-калькулятором не совсем понятно.
var x = Regex.Replace(text, "([a-z](?=[A-Z])|[A-Z](?=[A-Z][a-z]))", "$1 ");
// Теперь понятно.
var x = text.PascalCaseWords().Separated(with: " ");
// Так тоже хорошо.
var x = text.AsWords(eachStartsWith: x => x.IsUpper()).Separated(with: " ");
Бесспорно, регулярные выражения эффективны и универсальны, но хочется понимать происходящее с первого взгляда.
Substring
и Remove
Бывает, нужно удалить из строки какую-нибудь часть с начала или конца, например, из path
— расширение .txt
, если оно есть.
string path = ...;
// Классический подход в лоб.
var x = path.EndsWith(".txt") ? path.Remove(path.Length - "txt".Length) : path;
// Понятный метод расширения.
var x = path.Without(".exe").AtEnd;
Снова действие и алгоритм ушли, и осталась простая строка без расширения .exe в конце.
Поскольку метод Without
должен возвращать некий WithoutExpression
, напрашиваются ещё: path.Without("_").AtStart
и path.Without("Something").Anywhere
. Интересно ещё, что с таким же словом можно построить другое выражение: name.Without(charAt: 1)
— удаляет символ по индексу 1 и возвращает новую строку (полезно при вычислении перестановок). И тоже читаемо!
Type.GetMethods
Чтобы получить методы определённого типа с помощью рефлексии, используют:
Type type = ...;
// Тут и `Get` лишний, и оператор `|`. Ни к чему такие сложности.
var x = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
// Хорошая, понятная замена. `Or` ничего не делает, декорация.
var x = type.Methods(_ => _.Instance.Public.Or.NonPublic);
(То же самое подходит и для GetFields
и GetProperties
.)
Directory.Copy
Всякие операции по работе с папками и файлами частенько обобщаются до DirectoryUtils
, FileSystemHelper
. Там реализуют обход файловой системы, очистку, копирование и т.д. Но и тут можно придумать кое-что получше!
Отображаем текст "скопировать все файлы из 'D:\Source' в 'D:\Target'" на код "D:\\Source".AsDirectory().Copy().Files.To("D:\\Target")
. AsDirectory()
— возвращает DirectoryInfo
из string
, а Copy()
— создаёт экземпляр CopyExpression
, описывающий однозначный API для построения выражений (нельзя вызвать Copy().Files.Files
, например). Тогда открываются возможности копировать не все файлы, а некоторые: Copy().Files.Where(x => x.IsNotEmpty)
.
GetOrderById
Во второй статье я писал, что IUsersRepository.GetUser(int id)
— избыточно, и лучше — IUsersRepository.User(int id)
. Соответственно, в аналогичном IOrdersRepository
мы имеем не GetOrderById(int id)
, а Order(int id)
. Тем не менее, в другом примере предлагалось переменную такого репозитория называть не _ordersRepository
, а просто _orders
.
Оба изменения хороши сами по себе, но вместе, в контексте чтения, не складываются: вызов _orders.Order(id)
смотрится многословно. Можно было бы _orders.Get(id)
, но у заказов ничего не получается, мы только хотим указать тот, который имеет такой идентификатор. "Тот, который" — это One
, поэтому:
IOrdersRepository orders = ...;
int id = ...;
// Классика с излишествами.
var x = orders.GetOrderById(id);
// Вторая статья цикла говорит писать так:
var x = orders.Order(id);
// Но мы и так понимаем, что работаем с заказами.
var x = orders.One(id);
// Или с именованым параметром:
var x = orders.One(with: id);
GetOrders
В таких объектах, как IOrdersRepository
, часто встречаются и другие методы: AddOrder
, RemoveOrder
, GetOrders
. Из первых двух повторения уходят, и получаются Add
и Remove
(с соответствующими записями _orders.Add(order)
и _orders.Remove(order)
). С GetOrders
сложнее — переименовать на Orders
мало. Давайте посмотрим:
IOrdersRepository orders = ...;
// Совсем не подходит.
var x = orders.GetOrders();
// Без `Get`, но глупость.
var x = orders.Orders();
// Эврика!
var x = orders.All();
Нужно заметить, что при старом _ordersRepository
повторения в вызовах GetOrders
или GetOrderById
не так заметны, ведь работаем-то с репозиторием!
Имена вроде One
, All
подходят для многих интерфейсов, представляющих множества. Скажем, в известной реализации GitHub API — octokit
— получение всех репозиториев пользователя выглядит как gitHub.Repository.GetAllForUser("John")
, хотя логичнее — gitHub.Users.One("John").Repositories.All
. При этом получение одного репозитория будет, соответственно, gitHub.Repository.Get("John", "Repo")
вместо очевидного gitHub.Users.One("John").Repositories.One("Repo")
. Второй случай выглядит длиннее, но он внутренне согласован и отражает платформу. К тому же, с помощью методов расширения его можно сократить до gitHub.User("John").Repository("Repo")
.
Dictionary.TryGetValue
Получение значений из словаря делится на несколько сценариев, которые отличаются только тем, что нужно делать, если ключ не найден:
- бросить ошибку (
dictionary[key]
); - вернуть значение по умолчанию (не реализовано, но часто пишут
GetValueOrDefault
илиTryGetValue
); - вернуть что-то другое (не реализовано, но я бы ожидал
GetValueOrOther
); - записать указанное значение в словарь и вернуть его (не реализовано, но встречается
GetOrAdd
).
Выражения сходятся в точке "берём какой-то X, или Y, если X нет". Кроме этого, как и в случае с _ordersRepository
, переменную словаря мы назовём не itemsDictionary
, а items
.
Тогда для части "берём какой-то X" идеально подходит вызов вида items.One(withKey: X)
, возвращающий структуру с четырьмя концовками:
Dictionary<int, Item> items = ...;
int id = ...;
// Как правило, значения получаются так:
var x = items.GetValueOrDefault(id);
var x = items[id];
var x = items.GetOrAdd(id, () => new Item());
// Но проще и согласованней:
var x = items.One(with: id).OrDefault();
var x = items.One(with: id).Or(Item.Empty);
var x = items.One(with: id).OrThrow(withMessage: $"Couldn't find item with '{id}' id.");
var x = items.One(with: id).OrNew(() => new Item());
Assembly.GetTypes
Посмотрим на создание всех существующих в сборке экземпляров типа T
:
// Классика.
var x = Assembly
.GetAssembly(typeof(T))
.GetTypes()
.Where(...)
.Select(Activator.CreateInstance);
// "Плохая" декомпозиция.
var x = TypesHelper.GetAllInstancesOf<T>();
// Выразительнее.
var x = Instances.Of<T>();
Таким образом, иногда, имя статического класса — начало выражения.
Нечто похожее можно встретить в NUnit: Assert.That(2 + 2, Is.EqualTo(4))
— Is
и не задумывался как самодостаточный тип.
Argument.ThrowIfNull
Теперь взглянем на проверку предусловий:
// Классические варианты.
Argument.ThrowIfNull(x);
Guard.CheckAgainstNull(x);
// Описательно.
x.Should().BeNotNull();
// Интересно, но невозможно... Или возможно?
Ensure(that: x).NotNull();
Ensure.NotNull(argument)
— симпатично, но не совсем по-английски. Другое дело написанное выше Ensure(that: x).NotNull()
. Если бы только там можно было...
Кстати, можно! Пишем Contract.Ensure(that: argument).IsNotNull()
и импортируем тип Contract
с помощью using static
. Так получаются всякие Ensure(that: type).Implements<T>()
, Ensure(that: number).InRange(from: 5, to: 10)
и т.д.
Идея статического импорта открывает множество дверей. Красивого примера ради: вместо items.Remove(x)
писать Remove(x, from: items)
. Но любопытнее сокращение перечислений (enum
) и свойств, возвращающих функции.
IItems items = ...;
// Неплохо.
var x = items.All(where: x => x.IsWeapon);
// Пока хуже.
// `ItemsThatAre.Weapons` возвращает `Predicate<bool>`.
var x = items.All(ItemsThatAre.Weapons);
// `using static` всё выровнял! Читается прекрасно.
var x = items.All(Weapons);
Экзотический Find
В С# 7.1 и выше можно писать не Find(1, @in: items)
, а Find(1, in items)
, где Find
определяется как Find<T>(T item, in IEnumerable<T> items)
. Этот пример непрактичен, но показывает, что все средства хороши в борьбе за читаемость.
Итого
В этой части я рассмотрел несколько способов работать с читаемостью кода. Все их можно обобщить до:
- Именованный параметр как часть выражения —
Should().Be(4, because: "")
,Atomically.Change(ref x, from: oldX, to: newX)
. - Простое имя вместо технических деталей —
Separated(with: ", ")
,Exclude
. - Метод как часть переменной —
x.OrDefault()
,x.Or(b).IfLess()
,orders.One(with: id)
,orders.All
. - Метод как часть выражения —
path.Without(".exe").AtEnd
. - Тип как часть выражения —
Instances.Of
,Is.EqualTo
. - Метод как часть выражения (
using static
) —Ensure(that: x)
,items.All(Weapons)
.
Так выводится на первый план внешнее и созерцаемое. Сперва мыслится оно, а уж затем мыслятся его конкретные воплощения, уже не столь значительные, покуда код читается как текст. Из этого следует, что судьёй будет не столько вкус, сколько язык — он определяет разницу между item.GetValueOrDefault
и item.OrDefault
.
Эпилог
Что лучше, понятный, но нерабочий метод, или рабочий, но непонятный? Белоснежный замок без мебели и комнат или сарай с диванами в стиле Людовика XIV? Роскошная яхта без двигателя или кряхтящая баржа с квантовым компьютером, которым никто не умеет пользоваться?
Полярные ответы не подходят, но и "где-то посередине" — тоже.
На мой взгляд, оба понятия неразрывны: тщательно выбирая обложку для книги, мы с сомнением поглядываем на ошибки в тексте, и наоборот. Я бы не хотел, чтобы Beatles играли некачественную музыку, но и чтобы назывались они MusicHelper — тоже.
Другое дело, что работа над словом как часть процесса разработки — дело недооценённое, непривычное, и поэтому какая-то крайность в суждениях всё-таки нужна. Этот цикл — крайность формы и картинки.
Всем спасибо за внимание!
Ссылки
Кому интересно посмотреть ещё примеры, их можно найти у меня на GitHub, например, в библиотеке Pocket.Common
. (не для всемирного и повсеместного использования)