Pull to refresh

Код живой и мёртвый. Часть третья. Код как текст

ProgrammingPerfect code.NETDesigning and refactoringООP

Для сопровождения программы код приходится читать, и тем это делать проще, чем больше он похож на естественный язык, — тогда быстрее вникаешь и сосредотачиваешься на главном.


В прошлых двух статьях я показал, что тщательно выбранные слова помогают лучше понимать суть написанного, но думать только о них недостаточно, ведь всякое слово существует в двух формах: как само по себе и как часть предложения. Повтор CurrentThread ещё не повтор, пока мы не читаем его в контексте Thread.CurrentThread.


Таким образом, ориентируясь в нотах и простых мелодиях, мы посмотрим теперь, что такое музыка.


Оглавление цикла


  1. Объекты
  2. Действия и свойства
  3. Код как текст

Код как текст


Большинство 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. (не для всемирного и повсеместного использования)

Tags:оопoodsoliddesign patternsarchitecture designrefactoringfluentfluent interfacec#.net
Hubs: Programming Perfect code .NET Designing and refactoring ООP
Total votes 12: ↑10 and ↓2 +8
Views5.2K

Popular right now

Senior .Net Engineer (C#)
to 230,000 ₽ItivitiСанкт-Петербург
.NET C# Software Engineer
from 3,500 to 4,000 $Hand2NoteRemote job
Senior .NET Developer (C#) | Remote
from 290,000 to 320,000 ₽C TeleportRemote job
Программист .NET/C#/ASP. NET MVC
from 100,000 ₽МВС ТелекомМосква
C# .net Developer
from 90,000 to 180,000 ₽АЛМАЗМоскваRemote job