Во время рефакторинга мы можем обнаружить в коде «скрытые закономерности» в разных частях приложения. Это могут быть схожие алгоритмы или принципы работы функций, которые можно обобщить.
Обобщения могут как упростить код, так и сделать его сложнее. В этой главе мы поговорим о том, как понять, когда стоит заменять похожий код обобщёнными алгоритмами и типами, а когда — нет. Мы также затронем тему иерархий и наследования и поговорим, почему композиция предпочтительнее наследования.
Обобщённый алгоритм — это продолжение идей абстракции и уменьшения дублирования кода.
Если несколько функций работают «по одинаковой схеме», мы можем оформить эту «схему» в виде набора операций. Такая схема будет описывать работу функций абстрактно, «в общих чертах», не ссылаясь на конкретные переменные в коде.
Например, вместо ручного перебора элементов массива:
const items = [1, 2, 3, 4, 5];
for (const i of items) {
console.log(i);
}
// 1 2 3 4 5
for (const i of items) {
console.log(i - 1);
}
// 0 1 2 3 4
...Мы можем выделить «схему работы», а именно — перебрать массив, для каждого элемента применить указанную функцию. В примере ниже эту схему инкапсулирует в себе метод forEach
:
const printNumber = (i) => console.log(i);
const printNumberMinusOne = (i) => console.log(i - 1);
// Перебрать массив `items`, для каждого элемента выполнить функцию `printNumber`:
items.forEach(printNumber);
// 1 2 3 4 5
// Перебрать массив `items`, для каждого элемента выполнить функцию `printNumberMinusOne`:
items.forEach(printNumberMinusOne);
// 0 1 2 3 4
Обобщённый алгоритм «схемы» не ссылается на конкретные переменные и функции. Вместо этого он ожидает их как параметры:
Перебрать указанный массив; для каждого элемента применить указанную функцию
Такой алгоритм опирается на признаки переданных параметров. Массив можно перебрать по-элементно, функцию можно вызвать, передав аргументом элемент массива. Сочетание этих признаков — это контракт на работу такого алгоритма. Выявление подобных схем и определение их контрактов — и есть основа обобщённого программирования.
Чтобы понять, чем обобщённые алгоритмы могут быть полезны и как их выявлять, посмотрим на приложение для управления финансами. В коде ниже есть две функции для подсчёта трат и пополнений в истории:
// Тип описывает историю трат и доходов в приложении:
type RecordHistory = List<Entry>;
// Запись в истории содержит вид, дату создания и сумму:
type EntryKind = "Spend" | "Income";
type Entry = {
type: EntryKind;
created: TimeStamp;
amount: MoneyAmount;
};
// Подсчитывает, сколько всего потрачено:
function calculateTotalSpent(history: RecordHistory) {
return history.reduce(
(total, { type, amount }) => total + (type === "Spend" ? amount : 0),
0
);
}
// Подсчитывает, сколько всего добавлено:
function calculateTotalAdded(history: RecordHistory) {
let total = 0;
for (const { type, amount } of history) {
if (type === "Income") total += amount;
}
return total;
}
Допустим, в приложение понадобилось также добавить подсчёт трат за некоторый период, например, за сегодня. При добавлении нам стоит проверить, нет ли в новой функции общих черт с уже имеющимися:
// В новой функции `calculateSpentToday` видна «схема работы»,
// похожая на алгоритм из `calculateTotalSpent` и `calculateTotalAdded`:
// - взять массив истории;
// - найти нужные записи;
// - просуммировать.
function calculateSpentToday(history: RecordHistory) {
return history.reduce(
(total, { type, created }) =>
total +
(type === "Spend" && created >= today && created < tomorrow ? amount : 0),
0
);
}
Заметим, что каждая из трёх функций сводится к двум задачам: отфильтровать историю и просуммировать поле amount
в отфильтрованных записях. Эти задачи мы можем вынести наружу и обобщить:
type HistorySegment = List<Entry>;
type EntryPredicate = (record: Entry) => boolean;
// Фильтрация теперь может использоваться отдельно.
// При этом функция `keepOnly` может фильтровать историю записей
// по _любому_ критерию, который реализует тип `EntryPredicate`:
const keepOnly = (
history: RecordHistory,
criterion: EntryPredicate
): HistorySegment => history.filter(criterion);
// Суммирование теперь тоже может использоваться отдельно.
// При этом в функцию `totalOf` можно передать _любой_ фрагмент истории,
// и она посчитает сумму трат или доходов в ней:
const totalOf = (history: HistorySegment): MoneyAmount =>
history.reduce((total, { amount }) => total + amount);
Тогда предыдущие функции мы сможем скомпоновать из этих двух задач — это и будет реализацией обобщённого алгоритма. Отличающиеся детали мы будем использовать как параметры этого обобщённого алгоритма:
// Отличаются лишь критерии фильтрации, всё остальное одинаково:
const isIncome: EntryPredicate = ({ type }) => type === "Income";
const isSpend: EntryPredicate = ({ type }) => type === "Spend";
const madeToday: EntryPredicate = ({ created }) =>
created >= today && created < tomorrow;
// Критерий фильтрации передаём как «параметр» для `keepOnly`:
const added = keepOnly(history, isIncome);
const spent = keepOnly(history, isSpend);
// Подсчёт суммы тогда можно вести по любому фрагменту истории:
totalOf(spent);
totalOf(added);
totalOf(keepOnly(spent, madeToday));
Обобщения удобны, когда мы полностью уверены в «схеме работы». Если у нескольких кусков кода «схема» одинаковая или отличается незначительно, обобщение может помочь уменьшить дублирование.
Но обобщения также могут сделать код сложнее. Если мы подозреваем, что «схема работы» может поменяться, то обобщать рано. Эвристика здесь примерно та же, что и при работе с дублированием. Пока мы не уверены в своих знаниях о коде, с обобщением лучше повременить.
К слову 💡 |
---|
Правила работы с дублированием, а также то, как отличать повторяющийся код от недостатка информации, мы подробнее обсуждали в одной из предыдущих глав. |
Иногда схожие детали могут быть не у алгоритмов, а у типов данных. Тип — это набор значений с некоторыми определёнными свойствами.12 Похожие типы можно объединить в обобщённый тип, но при этом стоит следовать правилу:
❗️ Обобщать только когда есть уверенность, что не будет исключений
Данные менять сложнее, чем код. Неаккуратное обобщение типов может ослабить типизацию и вынудить добавлять в код лишние проверки. Для примера вернёмся к типу Entry
из фрагмента выше:
type EntryKind = "Spend" | "Income";
type Entry = {
type: EntryKind;
created: TimeStamp;
amount: MoneyAmount;
};
Если мы знаем, что в истории могут лежать только записи с такими свойствами, то тип хорошо описывает предметную область. Но если есть вероятность, что в истории могут находиться и другие записи, то с этим типом могут возникнуть проблемы.
Допустим, в истории могут отображаться комментарии пользователя, которые не содержат суммы, но содержат текст:
type EntryKind = "Spend" | "Income" | "Comment";
type Entry = {
type: EntryKind;
created: TimeStamp;
// Обобщение ослабляет поле `amount`,
// а вместе с ним и весь тип `Entry`:
amount?: MoneyAmount;
// При этом появляется новое поле, тоже слабое —
// «может быть, а может не быть»:
content?: TextContent;
};
В типе Entry
есть противоречие. Его поля amount
и content
— необязательные, поэтому тип «разрешает» записи без суммы и текста. Но это неправильное отражение предметной области: комментарий должен содержать текст, а траты и пополнения должны содержать сумму. Без этого условия данные в истории записей будут невалидными.
Проблема в том, что тип Entry
с необязательными полями пытается смешать в себе разные сущности и состояния данных. Мы можем проверить это, если создадим React-компонент для вывода суммы записи из истории на экран:
type RecordProps = {
record: Entry;
};
// В компонент передаём запись типа `Entry`,
// чтобы вывести сумму траты или пополнения:
const Record = ({ record }: RecordProps) => {
// Но внутри нам приходится фильтровать эти данные.
// Если запись — это комментарий, рендер придётся пропустить,
// комментарии суммы не содержат:
if (record.type === "Comment") return null;
const sign = isIncome(record) ? "+" : "–";
// А тут придётся ещё и сказать компилятору,
// что `amount` «точно есть, мы проверили!»
return `${sign} ${record.amount!}`;
};
Рендер комментария в этом компоненте не особо приживается. Чтобы исправить эту проблему, нам сперва нужно понять, что мы знаем о предметной области:
- Здесь могут быть только комментарии, или могут появиться другие текстовые сообщения?
- Появится ли новый тип записей, в котором будет поле
amount
? - Могут ли быть новые типы записей, в которых будут совершенно другие поля?
На такие вопросы ответить можно не всегда. Когда нам не хватает знаний о предметной области или требованиях к программе, обобщать типы рано. В таких случаях предпочтительнее вместо обобщений типы компоновать:
// Мы разбили тип на несколько.
// Необязательные поля пропали, мы чётче разделяем состояния данных,
// а от статической типизации будет больше толку.
type Spend = { type: "Spend"; created: TimeStamp; amount: MoneyAmount };
type Income = { type: "Income"; created: TimeStamp; amount: MoneyAmount };
type Comment = { type: "Comment"; created: TimeStamp; content: TextContent };
// Да, мы написали «больше кода», но на ранних стадиях проекта
// нам важнее попасть в предметную область, чтобы правильно понять
// суть приложения и связи между сущностями.
// Такие атомарные типы, как выше, — гибче,
// потому что мы можем компоновать их по признакам,
// которые нам важны в конкретной ситуации.
// Например, ниже в типе `FinanceEntry`
// собираем только записи, _содержащие сумму_.
// По спискам таких записей можно проходиться сумматором:
type FinanceEntry = Spend | Income;
// В типе `MessageEntry` собираем записи, _содержащие текст_.
// Сейчас такой тип только один, поэтому `MessageEntry` — это алиас,
// но если записей с текстом станет больше, мы сможем расширить этот тип дальше.
type MessageEntry = Comment;
// Самый общий тип `Entry` представим как выбор из _всех_ возможных вариантов.
// С такими записями можно делать только то, что можно делать с _любой_ записью:
type Entry = FinanceEntry | MessageEntry;
// Например, `Entry` можно сортировать по дате появления,
// так как `created` гарантированно есть у всех записей:
const sortByDate = (a: Entry, b: Entry) => a.created - b.created;
После рефакторинга компоненту Record
больше не нужны лишние проверки:
// В пропсах указываем не общий `Entry`, а `FinanceEntry`.
// В нём по определению есть сумма, поэтому дополнительных
// проверок во время ренденра нам не потребуется.
type RecordProps = {
record: FinanceEntry;
};
const Record = ({ record }: RecordProps) => {
const sign = isIncome(record) ? "+" : "–";
return `${sign} ${record.amount}`;
};
К слову 👀 |
---|
Даже если мы сделаем скидку на «ненастоящую типизацию» в TypeScript и ошибки рантайма, то одно лишь проектирование с оглядкой на преждевременные обобщения избавят код от лишних проверок. |
Конечно, нет гарантий, что в продакшене в пропс компонента не попадёт неправильный тип данных. Но с грамотным алёрт-мониторингом и обработкой ошибок мы сможем это быстро найти и исправить. |
Основная идея компоновки типов в том, чтобы не обобщать раньше времени. Скомпонованные типы проще расширять по мере усложнения требований к приложению. К примеру, нам потребовалось добавить записи с овердрафтом и системные текстовые сообщения. Добавим Overdraft
и Warning
:
type Spend = { type: "Spend"; created: TimeStamp; amount: MoneyAmount };
type Income = { type: "Income"; created: TimeStamp; amount: MoneyAmount };
type Overdraft = { type: "Overdraft"; created: TimeStamp; amount: MoneyAmount }; // Добавлено.
type Comment = { type: "Comment"; created: TimeStamp; content: TextContent };
type Warning = { type: "Warning"; created: TimeStamp; content: TextContent }; // Добавлено.
// В юнионах нам достаточно добавить по ещё одному варианту
// в каждое место, которое требуется расширить:
type FinanceEntry = Spend | Income | Overdraft;
type MessageEntry = Comment | Warning;
type Entry = FinanceEntry | MessageEntry;
// При необходимости мы можем перекомпоновать типы с нуля,
// чтобы скомпоновать их по другим признакам.
А вот когда мы знаем о предметной области достаточно, можно при желании выделить закономерности и обобщить типы. (Мы знаем достаточно, когда код этих сущностей перестал меняться.) Но это совсем необязательный шаг:
type FinanceEntry = {
type: "Spend" | "Income" | "Overdraft";
created: TimeStamp;
amount: MoneyAmount;
};
type MessageEntry = {
type: "Comment" | "Warning";
created: TimeStamp;
content: TextContent;
};
В примере выше для описания типа Entry
возникает соблазн использовать какой-нибудь дженерик-тип3, чтобы определять вид записи «налету»:
type FinanceEntryKind = "Spend" | "Income" | "Overdraft";
type MessageEntryKind = "Comment" | "Warning";
type EntryKind = FinanceEntryKind | MessageEntryKind;
// Поля и поведение тогда будут определяться
// с помощью тип-аргумента `TKind`:
type Entry<TKind extends EntryKind> = {
type: TKind;
created: TimeStamp;
amount: TKind extends FinanceEntry ? MoneyAmount : never;
content: TKind extends MessageEntryKind ? TextContent : never;
};
type Income = Entry<"Income">;
type Comment = Entry<"Comment">;
Но с дженериками тоже лучше не торопиться. Дженерики — как чертежи для типов. Чтобы использовать их, нам надо быть уверенными, что структура типа не поменяется.
Осторожно 🚧 |
---|
Этот раздел — одно большое IMHO, могу быть абсолютно неправ, настройте скепсистрометры на максимум. |
Дженерики подойдут для случаев когда мы знаем структуру типа, но не знаем его деталей. Например, в коде выше у нас был тип EntryPredicate
:
type EntryPredicate = (record: Entry) => boolean;
Предикат — это по определению функция, которая возвращает булево значение, поэтому мы точно знаем структуру:
type SomethingPredicate = (x: Something) => boolean;
Если нам по какой-то причине потребуется создать много предикатов от разных аргументов, но заранее неизвестно каких, то дженерик отлично опишет такой «чертёж»:
// Предикат от «абстрактного параметра»:
type Predicate<T> = (x: T) => boolean;
// Предикаты от конкретных параметров:
type EntryPredicate = Predicate<Entry>;
type HistoryPredicate = Predicate<History>;
type WhateverPredicate = Predicate<Whatever>;
Здесь мы уверены, что структура типа известна и не изменится. Мы можем передать любой тип-аргумент, и это не отразится на структуре типа и его работе. В Entry<TKind>
такой гарантии дать нельзя, по крайней мере на ранних этапах проектирования.
Раз мы заговорили о компоновке и «чертежах» сущностей, затронем ООП и наследование. В проектах, написанных в ООП-стиле, лучше избегать глубоких иерархий наследования:
class Task {
public start(): void {}
}
class AdvancedTask extends Task {
public configure(settings: TaskSettings): void {}
}
class UserTask extends AdvancedTask {
public definedBy: User;
}
// Класс `UserTask` 3-й в цепочке наследования:
// Task -> AdvancedTask -> UserTask
Глубокие иерархии — хрупкие. Они претендуют на точное знание предметной области, но модель не может описать мир точно, поэтому рано или поздно иерархия поломается. Если мы не предусмотрели какую-то функциональность, то из-за сломанной иерархии придётся либо добавлять эту функциональность в базовый класс, либо переопределять наследников, ломая принцип подстановки (об этом чуть позже).
Вместо наследования предпочтительнее использовать композицию. В ООП-коде композиция обычно означает реализацию интерфейсов:
// Объявим несколько интерфейсов,
// каждый из которых описывает связный набор фич:
interface SimpleTask {
start(): void;
}
interface Configurable {
configure<TSettings>(settings: TSettings): void;
}
interface UserDefined {
definedBy: User;
}
// Класс может реализовать несколько интерфейсов,
// тем самым минуя ненужные шаги с наследованием:
class UserTask implements SimpleTask, Configurable, UserDefined {}
// Тогда при добавлении новой фичи, нам будет достаточно
// расширить список интерфейсов новым и реализовать его:
interface Cancellable {
stop(): void;
}
class UserTask
implements SimpleTask, Restartable, Configurable, UserDefined, Cancellable {}
// В случае с наследованием нам бы пришлось
// менять один из базовых классов
// или менять структуру наследования.
Однако 📝 |
---|
Мы здесь не говорим о реализации абстрактных классов, она наоборот бывает полезна. Абстрактный класс — это почти что «интерфейс с дефолтным поведением», поэтому «это другое». Но даже с абстрактными классами лучше избегать иерархий глубже 1–2 уровней. |
В JavaScript, однако, есть один юзкейс, когда глубокое наследование может быть полезным. Если в проекте для обработки ошибок используются паники, но хочется избежать многословных проверок с instanceof
на каждый тип ошибок, то можно использовать иерархии «слоёв ошибок»:
// Базовый класс всех ошибок приложения:
class AppError extends Error {}
// Классы ошибок, разделённые по «слоям»:
// Слой API и его подклассы на каждую ошибку API.
class ApiError extends AppError {}
class NotFound extends ApiError {}
class BadRequest extends ApiError {}
// Слой валидации и подклассы на каждую ошибку валидации.
class ValidationError extends AppError {}
class InvalidUserDto extends ValidationError {}
class MissingPostData extends ValidationError {}
// Тогда при обработке мы можем сократить количество проверок,
// заменив проверки на каждый возможный тип:
if (e instanceof NotFound) {
} else if (e instanceof BadRequest) {
} else if (e instanceof InvalidUserDto) {
} else if (e instanceof MissingPostData) {
}
// ...На проверки «по слоям» ошибок:
if (e instanceof ApiError) {
} else if (e instanceof ValidationError) {
}
Так мы можем уменьшить количество проверок с instanceof
, но это будет работать, только если для всех ошибок из одного слоя обработка будет одинаковой.
Чуть выше мы приводили пример с компонентом, в котором были лишние проверки:
type Entry = Spend | Income | Comment;
type RecordProps = { record: Entry };
const Record = ({ record }: RecordProps) => {
if (record.type === "Comment") return null; // Фильтр комментариев.
const sign = isIncome(record) ? "+" : "–";
return `${sign} ${record.amount}`;
};
Когда мы видим подобные условия, нам стоит проверить, не нарушен ли принцип подстановки Лисков.45 Этот принцип в прикладной формулировке Мартина звучит так:
Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом6
На практике это значит, что тип Entry
описывает любую запись: не важно, комментарий, трату или доход. Поэтому и передать его можно только в ту функцию, которая готова работать с любой записью.
То есть если функция собирается работать только с тратой или доходом, но не комментарием, то тип Entry
в такую функцию передавать нельзя. Нельзя потому, что функция может захотеть достать из записи количество денег, но в комментарии его нет, поэтому его надо отсеять перед использованием.
И наоборот, если функция готова работать с любой записью и будет использовать, например, только поле created
, то ей можно передать Entry
, так как created
есть у всех «подтипов».
Подробнее 🔬 |
---|
Для более точного понимания принципа подстановки стоит погрузиться в понятие вариантности,789 но это большая отдельная тема. Мы пропустим её, но я оставлю несколько ссылок на тему в списке литературы. |
Чтобы не нарушить принцип подстановки, в компонент Record
надо передать «наименьший общий тип»:
type FinanceEntry = Income | Spend;
type RecordProps = { record: FinanceEntry };
const Record = ({ record }: RecordProps) => {
// Дополнительные проверки не нужны,
// в типе _точно_ содержится всё,
// с чем функция может вздумать поработать.
const sign = isIncome(record) ? "+" : "–";
return `${sign} ${record.amount}`;
};
Принцип подстановки помогает определять, что и как можно компоновать. Его можно использовать как «интеграционный линтер», который подсвечивает преждевременные обобщения и неправильные абстракции.
Он также помогает находить места для правильных обобщений. Например, когда мы разделили задачу подсчёта денег на фильтрацию и суммирование, мы выделили функцию, которая может фильтровать не только по типу, но вообще как угодно:
type EntryPredicate = (record: Entry) => boolean;
type HistorySegment = List<Entry>;
type HistoryFilter = (
history: RecordHistory,
criterion: EntryPredicate
) => HistorySegment;
// Можно использовать не только эти функции:
const isIncome = ({ type }) => type === "Income";
const isSpend = ({ type }) => type === "Spend";
// Но и эти:
const beforeToday = ({ created }) => created < today;
const madeToday = ({ created }) => created >= today && created < tomorrow;
// А также эти:
const addedToday = (record) => isIncome(record) && madeToday(record);
const spentBeforeToday = (record) => isSpend(record) && beforeToday(record);
В HistoryFilter
мы можем передать любую реализацию типа EntryPredicate
. Такая гибкость в компоновке функциональности и есть главная польза принципа подстановки.
Упрощение 🚧 |
---|
Мы не будем вдаваться в подробности того, как принцип подстановки связан с пред- и постусловиями и как они должны себя вести. Но я оставлю несколько ссылок, которые рекомендую прочесть.1011 |
Footnotes
-
“Functional Design Patterns” by Scott Wlaschin, https://youtu.be/srQt1NAHYC0 ↩
-
Types and Typeclasses, Learn You Haskell, http://learnyouahaskell.com/types-and-typeclasses ↩
-
Generics in TypeScript, TypeScript Docs https://www.typescriptlang.org/docs/handbook/2/generics.html ↩
-
“A behavioral notion of subtyping” by Barbara H. Liskov, Jeannette M. Wing, https://dl.acm.org/doi/10.1145/197320.197383 ↩
-
The Principles of OOD, Robert C. Martin, http://www.butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod ↩
-
The Liskov Substitution Principle, https://web.archive.org/web/20151128004108/http://www.objectmentor.com/resources/articles/lsp.pdf ↩
-
Covariance and Contravariance, Wikipedia, https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science) ↩
-
“Why variance matters” by Ted Kaminski https://www.tedinski.com/2018/06/26/variance.html ↩
-
“The Ins and Outs of Generic Variance in Kotlin” by Dave Leeds, https://typealias.com/guides/ins-and-outs-of-generic-variance/ ↩
-
“Design By Contract”, c2.com, https://wiki.c2.com/?DesignByContract ↩
-
«Контрактное программирование» Тимур Шемсединов, https://youtu.be/K5_kSUvbGEQ ↩