Привет, Хабр! Представляю вашему вниманию статью "Four Better Rules for Software Design" автора David Bryant Copeland. David Bryant Copeland — архитектор ПО и технический директор Stitch Fix. Он ведет свой блог и является автором нескольких книг.
Мартин Фаулер недавно создал твит с ссылкой на его пост в блоге о четырех правилах простого дизайна от Кента Бека, которые, как я думаю, могут быть еще улучшены (и которые иногда могут отправить программиста по ложному пути):
Правила Кента из книги Extreme Programming Explained:
- Кент говорит: "Запускайте все тесты".
- Не дублируйте логику. Старайтесь избегать скрытых дубликатов, таких как параллельные иерархии классов.
- Все намерения, важные для программиста, должны быть явно видны.
- Код должен иметь наименьшее возможное количество классов и методов.
Согласно моему опыту, эти правила не совсем соответствуют нуждам проектирования ПО. Мои четыре правила хорошо спроектированной системы могли бы быть такими:
- она хорошо покрыта тестами и успешно проходит их.
- она не имеет абстракций, которые напрямую не нужны программе.
- она имеет однозначное поведение.
- она требует наименьшее количество концепций.
Для меня эти правила вытекают из того, что мы делаем с нашим ПО.
Так что же мы делаем с нашим ПО?
Мы не можем говорить о проектировании ПО, не поговорив прежде о том, что мы намереваемся делать с ним.
ПО пишется для решения проблемы. Программа выполняется и имеет поведение. Это поведение изучается, чтобы обеспечить правильность работы или обнаружить ошибки. ПО также часто изменяется для придания ему нового или измененного поведения.
Поэтому любой подход к проектированию ПО должен быть сфокусирован на предсказании, изучении и понимании его поведения, чтобы сделать изменение этого поведения как можно проще.
Мы проверяем правильность поведения путем тестирования, и поэтому я согласен с Кентом, что первое и самое главное — хорошо спроектированное ПО должно проходить тесты. Я даже пойду дальше и настою на том, что ПО должно иметь тесты (т. е. быть хорошо покрыто тестами).
После того, как поведение было проверено, следующие три пункта обоих списков касаются понимания нашего ПО (и, следовательно, его поведения). Его список начинается с дублирования кода, которое действительно находится на своем месте. Однако по моему личному опыту, слишком сильный фокус на уменьшение дублирования кода имеет высокую цену. Для его устранения необходимо создать скрывающие его абстракции, и именно эти абстракции делают ПО сложным для понимания и изменения.
Устранение дублирования кода требует абстракций, а абстракции приводят к сложности
"Don't Repeat Yourself" или DRY используется для оправдания спорных решений дизайна. Вы когда-нибудь видели подобный код?
ZERO = BigDecimal.new(0)
Кроме того вы, наверное, видели что-то подобное:
public void call(Map payload, boolean async, int errorStrategy) {
// ...
}
Если вы видите методы или функции с флагами, boolean и т. д., то это, как правило, значит, что кто-то использовал принцип DRY при рефакторинге, но код не был точно таким же в обоих местах, поэтому полученный код должен был быть достаточно гибким, чтобы вмещать оба поведения.
Такие обобщенные абстракции сложны для тестирования и понимания, так как они должны обрабатывать намного больше случаев, чем оригинальный (возможно, дублирующийся) код. Иными словами, абстракции поддерживают намного больше поведений, чем нужно для нормального функционирования системы. Таким образом, устранение дублирования кода может породить новое поведение, которое не требуется системе.
Поэтому действительно важно объединять некоторые типы поведения, однако бывает сложно понять, какое поведение действительно дублируется. Часто куски кода выглядят похоже, но это происходит только по случайности.
Подумайте, насколько проще устранить дублирование кода, чем повторно вернуть его (например, после создания плохо продуманной абстракции). Поэтому нам нужно задуматься об оставлении дублирующегося кода, если только мы не абсолютно уверены, что у нас есть лучший способ избавиться от него.
Создание абстракций должно заставить нас задуматься. Если в процессе устранения дублирующегося кода вы создаете очень гибкую обобщенную абстракцию, то, возможно, вы пошли по неверному пути.
Это приводит нас к следующему пункту — намерение против поведения.
Намерение программиста бессмысленно — поведение значит все
Мы часто хвалим языки программирования, конструкции или куски кода за то, что они "раскрывают намерения программиста". Но какой смысл знать намерения, если вы не можете предсказать поведение? И если вы знаете поведение, насколько много значит намерение? Получается, вам надо знать, как ПО должно себя вести, но это не то же самое, что и "намерения программиста".
Давайте рассмотрим этот пример, который очень хорошо отражает намерения программиста, но не ведет себя так, как задумывалось:
function LastModified(props) {
return (
<div>
Last modified on
{ props.date.toLocaleDateString() }
</div>
);
}
Очевидно, программист планировал, что этот компонент React будет отображать дату с сообщением "Last modified on". Работает ли это как было задумано? Не совсем. Что, если this.prop.date не имеет значения? Все просто ломается. Мы не знаем, было ли так задумано, или кто-то просто забыл об этом, и это даже не имеет значения. Имеет значение то, какое это имеет поведение.
И именно это мы должны знать, если захотим менять эту часть кода. Представьте, что нам нужно изменить строку на "Last modification". Хотя мы и можем сделать это, неясно, что должно произойти, если date отсутствует. Будет лучше, если вместо этого мы напишем компонент так, чтобы сделать его поведение более понятным.
function LastModified(props) {
if (!props.date) {
throw "LastModified requires a date to be passed";
}
return (
<div>
Last modified on
{ props.date.toLocaleDateString() }
</div>
);
}
Или даже так:
function LastModified(props) {
if (props.date) {
return (
<div>
Last modified on
{ props.date.toLocaleDateString() }
</div>
);
}
else {
return <div>Never modified</div>;
}
}
В обоих случаях поведение более понятное, а намерения программиста не имеют значения. Допустим, мы выбрали вторую альтернативу (которая обрабатывает отсутствующее значение date). Когда нас попросят изменить сообщение, мы можем видеть поведение и проверить, корректно ли сообщение "Never modified" или оно тоже нуждается в изменении.
Таким образом, чем более однозначно поведение, тем больше у нас шансов успешно изменить его. А это значит, что нам может понадобиться написать больше кода или делать его более точным, или даже иногда писать дублирующийся код.
Также это значит, что нам понадобится больше классов, функций, методов и т. д. Конечно, хотелось бы сохранить их число минимальным, но мы не должны использовать это число как нашу метрику. Создание большого количества классов или методов создает концептуальные расходы (conceptual overhead), и в ПО появляется больше концепций, чем единиц модульности. Поэтому нам нужно уменьшать число концепций, что, в свою очередь, может привести к уменьшению числа классов.
Концептуальные расходы способствуют запутанности и усложнению
Чтобы понять, что в действительности будет делать код, вам надо знать не только предметную область, но и все использованные в этом коде концепции (например, при поиске среднеквадратического отклонения вы должны знать операции присваивания, сложения, умножения, циклы for и длину массива). Это объясняет, почему при увеличении числа концепций в дизайне увеличивается его сложность для понимания.
Раньше я уже писал о концептуальных расходах, и хороший побочный эффект уменьшения числа концепций в системе заключается в том, что больше людей смогут понять эту систему. В свою очередь это увеличивает число людей, которые могут вносить изменения в эту систему. Определенно, дизайн ПО, который безопасно может быть изменен множеством людей, лучше, чем тот, который может быть изменен только небольшой кучкой. (Поэтому я считаю, что хардкорное функциональное программирование никогда не станет популярным, так как оно требует глубокого понимания множества очень абстрактных концепций.)
Уменьшение концептуальных расходов приведет к естественному снижению числа абстракций и сделает поведение проще для понимания. Я не говорю "никогда не вводите новую концепцию", я говорю, что у этого есть своя цена, и если эта цена перевешивает пользу, введение новой концепции должно быть тщательно обдумано.
Когда мы пишем код или проектируем ПО, мы должны перестать думать об элегантности, красоте или другой субъективной мере нашего кода. Вместо этого мы всегда должны помнить, что мы собираемся делать с ПО.
Вы не вешаете код на стену — вы меняете его
Код — это не произведение искусства, которое вы можете распечатать и повесить в музее. Код выполняется. Он изучается и отлаживается. И, самое главное, он изменяется. Причем часто. Любой дизайн, с которым сложно работать, должен быть поставлен под вопрос и пересмотрен. Любой дизайн, который уменьшает число людей, которые могут работать с ним, также должен быть поставлен под вопрос.
Код должен работать, поэтому он должен быть протестирован. Код имеет баги и потребует добавления новых фич, поэтому мы должны понимать его поведение. Код живет дольше, чем возможность конкретного программиста поддерживать его, поэтому мы должны стремиться к коду, понятному широкому кругу лиц.
Когда вы пишете свой код или проектируете вашу систему, упрощаете ли вы объяснение поведения системы? Становится ли проще понять, как она себя поведет? Сфокусированы ли вы на решении проблемы прямо перед вами или на более абстрактной?
Всегда старайтесь оставлять поведение простым для демонстрации, предсказания и понимания, и сводите число концепций к абсолютному минимуму.