[Перевод на русский язык — в конце материала.]
Проектування програмних систем досі залишається ближчим до кустарного ремесла або мистецтва, ніж до науки, тому, звичайно, вивести формулу якісного дизайну неможливо. Але можна виділити характерні ознаки (своєрідні чек-листи), що окреслюють межу між добрим і поганим. І якщо у процесі розробки намагатися принаймні не робити нічого поганого, вірогідність отримати позитивний результат підвищиться.
Отже, три ознаки, на які потрібно звернути особливу увагу, розробляючи та обираючи компоненти — це відкритість, адитивність та відстежуваність.
Відкритість
У математичній логіці існує таке поняття як «відкритий світ». Це такий світ, де відомі факти заздалегідь не можуть пояснити всі можливі стани і в подальшому можуть з’явитися нові факти, які неможливо вивести із існуючих. Протилежене поняття — «закритий світ», де всі властивості, які неможливо вивести з нашої моделі, вважаються хибними.
Бібліотечний код відрізняється від прикладного тим, що перший функціонує у відкритому світі, де невідомо, як саме і в якому контексті його буде використано, а другий — у закритому, де всі можливі шляхи використання програми передбачено її автором.
Відкритість компоненти визначається кількістю обмежень її використання у рамках вибраного стеку технологій. Очевидно, що повністю обійтися без обмежень неможливо: це свого роду плата за функціональність. Як приклад, у фронт-енді з одного боку знаходиться JQuery, що практично не накладає обмежень на використання, з іншого — ExtJS, розрахована на роботу у конкретному виді додатків.
Підтримка відсутності структурних обмежень може виявитися зовсім не простою справою. Для прикладу припустимо, що ми розробляємо підсистему завантаження бібліотек (плагінів) для деякої програми. Якщо це прикладний код, а не бібліотечний, то можемо також припустити, що плагіни знаходяться у певній директорії, вказаній у конфігурації коду, а код, який імплементує процес підключення функціональності, знаходиться у виділеному просторі імен, при чому завантажувані модулі не мають зовнішніх та внутрішніх залежностей.
Тепер уявімо, що нам знадобилося виділити код завантаження плагінів до бібліотеки, яка може повторно використовуватися. Для цього необхідно підтримувати шляхи до наших плагінів, знайти спосіб конфігурування імен функцій та реалізувати обробку завантаження залежних компонент. Таким чином, отримуємо підвищення складності на рівному місці.
До того ж, користуватися такою бібліотекою у прикладній програмі буде складніше, ніж тим простим варіантом, що був спочатку, адже перед використанням її потрібно буде спеціально конфігурувати. Також у нас виникне свій DLL hell — конфлікти між залежностями різних компонент.
Крім того, звичайно, потрібно ще винести конфігурацію окремою компонентою, щоб іі сумісно використовували і програма, і наша бібліотека. Для спрощення використання у типових ситуаціях зробити автоматичну конфігурацію за допомогою очевидних конвенцій — наприклад, нехай ім’я завантажуваного об’єкта генерується з імені файла.
Чудово. Ми зменшили кількість коду, але тепер програмістам перед його використанням потрібно обов’язково вивчати ці конвенції — і це вже фреймворк. А хотілося лише винести завантаження у загальний модуль, не займаючись системою конфігурації.
У комп’ютерних науках найбільш очевидний спосіб вирішення проблеми часто несе в собі ризики набагато більші, ніж сама проблема. І якщо у першому прикладі час витрачається на вирішення існуючих проблем, то, проектуючи окремий абстрактний фреймворк, ми ризикуємо витрачати левову частку часу на вирішення проблем неіснуючих. Тому слід пам’ятати, що передчасна абстракція, як правило, некоректна і заважає враховувати важливі деталі.
Якщо мова розробки дозволяє рефакторинг, то свідома дублікація коду з подальшим його скороченням сприяє побудові ємної та якісної моделі. Правильним підходом мені здається наступний: якщо вже нам потрібен фреймворк, то давайте вирощувати його паралельно з основною програмою.
До речі, більшість розповсюджених стеків розробки вже повинні мати більш-менш узагальнені системи керування конфігурацією та конвенції за замовчуванням. Так ми переходимо до наступного пункту:
Адитивність
Підтримка та рефакторинг після розробки не повинні дорого коштувати. У добре спроектованій системі, змінюючи системні компоненти, можна не переписувати реалізовану функціональність повністю, а «плавно перенести» її на новий фундамент. Саме адитивність дозволяє створювати великі програмні комплекси та формувати зручні інтерфейси відкритих бібліотек, які існують десятиріччями.
Засоби досягнення адитивності — відсутність переліку зв’язків між компонентами різних підсистем та слабка зв’язність. Нагадаємо: компоненти А і В називаються слабко пов’язаними, якщо для взаємодії їм не потрібно знати деталі реалізації одне одного. Існують дві основні техніки досягнення слабкої зв’язності. Перша — це взаємодія через загальний інтерфейс, друга — використання проксі об’єктів представників (тобто з боку А є один об’єкт, що інкапсулює взаємодію з В, аналогічно з боку В є представник А). Тоді для того, шоб змінити компоненту, досить переписати лише проксі представників, залишаючи все інше незмінним.
Чи є техніки розробки, що сприяють адитивності? Так. Перш за все, це розробка невеликими кроками. Тобто, якщо нам потрібно в одній зміні виконати задачі X, Y та Z, то давайте зробимо це послідовно (можливо, навіть розбивши їх на дрібніші частини) і для кожного випадку налаштуємо тести і окремий комміт. Дві маленькі проблеми вирішити простіше, ніж одну велику, до того ж, часта робота з кодом «шліфує» його, робить якіснішим із кожним переглядом. Тому мене дещо дивує бажання колег писати «ідеальний код»: краще прагнути писати код, який легко можна зрозуміти і модифікувати — тоді у нього будуть шанси за якийсь час стати ідеальним.
Відстежуваність (traceability)
Ця властивість полягає у легкості розуміння поведінки програми. Якщо розглядати її дії як певне перетворення вхідних сигналів у вихідні, то відстежуваність показує, наскільки точно ми можемо відслідкувати цей процес.
Існують дві основні стратегії налагодження. Перша — це хаотичне генерування та перевірка конкретних гіпотез, друга (правильна) — процес локалізації, що має гарантовано зійтися, схожий на пошук лева у пустелі за допомогою ділення навпіл. Відстежуваність створюється шляхом систематичної підтримки другого способу.
Засоби досягення дуже прості. Один із них — захисне програмування, але також корисно при розробці програми тримати в голові не тільки шлях основного виконання, а і його зміни після виникнення помилок. Уявіть собі того, хто шукає помилку, як одного із ваших кінцевих користувачів. Чого робити ні в якому разі не можна — так це губити інформацію та «маскувати» помилки без визначення їхніх причин.
Якщо API бібліотеки можна представити як користувацький інтерфейс програміста, то відстежуваність визначає «безпеку» цього інтерфейсу: після натискання кнопки нічого не вибухне, а якщо цією кнопкою користуватися наразі не можна, то отримане повідомлення про помилку пояснить, чому саме.
Звичайно, концентрація на цих трьох властивостях не гарантує успішного виконання проекту, але неуважність до них значною мірою ускладнить задачу.
Перевод на русский язык
Что такое хороший дизайн. Открытость, аддитивность, трассируемость
Проектирование программных систем все еще ближе к кустарному ремеслу или искусству, нежели к науке, поэтому вывести формулу хорошего дизайна невозможно. Однако можно выделить характерные признаки — своего рода чек-листы, очерчивающие ту границу, которая отделяет хороший дизайн от плохого. И если для начала хотя бы не делать плохо, то шансы получить позитивный результат будут выше.
Итак, три свойства, на которые стоит обратить внимание при разработке или оценке компонент — это открытость, аддитивность и трассируемость.
Открытость
В математической логике есть такое понятие как «открытый мир». Это мир, в котором существующие факты заведомо не могут описать все возможные состояния, и в дальнейшем могут появиться новые факты. Противоположное понятие — «закрытый мир», в котором свойство, не описанное в модели, считается ложным.
Библиотечный код отличается от прикладного тем, что первый функционирует в открытом мире, где неизвестно, как именно и в каком контексте будет использоваться программа, а второй — в закрытом, где все функции заранее предусмотрены автором.
Открытость компоненты определяется количеством ограничений на ее использование в рамках выбранного стека технологий. Очевидно, что полностью отказаться от ограничений нельзя — это своего рода плата за функциональность. К примеру, в фронт-енд разработке с одной стороны спектра находится JQuery, практически не налагающая никаких ограничений на дизайн, а со второй — ExtJS, рассчитанная на работу с определенным типом приложений.
Поддержка отсутствия структурных ограничений может оказаться непростой задачей. К примеру, пусть мы разрабатываем подсистемы загружаемых библиотек (плагинов) к какой-то программе. Если мы пишем прикладной код, а не библиотечный, то можем предположить, что плагины находятся в конкретной конфигурируемой директории, а код, реализующий процесс подключения функциональности, — в выделенном пространстве имен, притом сами плагины не имеют внешних зависимостей.
Теперь давайте представим, что нам захотелось выделить код загрузки плагинов в библиотеку для повторного использования. Вероятно, мы должны поддерживать пути и имена плагинов, найти способ конфигурирования имен функций и реализовать обработку загрузки зависимых компонент. В результате получаем чуть ли не двукратное увеличение сложности на ровном месте.
При этом в прикладной программе пользоваться такой абстрагированной библиотекой будет сложнее, чем первоначальным простым вариантом, т. к. ее надо отдельно конфигурировать перед использованием. Кроме того, теперь у нас есть свой DLL hell — конфликты зависимостей разных компонент.
Естественно, надо еще сделать общую компоненту конфигурации для программы и нашей загрузочной библиотеки, и для упрощения работы сделать автоматическую конфигурацию по соглашению для типичных случаев (к примеру, пусть полное имя загружаемого объекта будет по умолчанию генерироваться из имени файла).
Прекрасно. Мы уменьшили количество кода, но теперь программистам перед его использованием надо обязательно изучать эти конвенции, и это уже фреймворк. А хотелось только вынести загрузку в общий модуль, а не заниматься системой конфигурации.
В компьютерных науках очевидный способ решения проблемы часто несет в себе риски куда более высокие, чем сама проблема. И если в первом примере время уходит на решение существующих проблем, то, проектируя отдельный абстрактный фреймворк, мы рискуем потратить львиную долю времени на решение проблем несуществующих. Стоит помнить, что преждевременная абстракция, как правило, некорректна и мешает проработке важных деталей.
Если язык разработки позволяет рефакторинг, то сознательная дубликация кода с последующим его сокращением способствует построению более качественной и емкой модели. Правильным подходом мне кажется следующий: если уж нам нужен фреймворк, то давайте растить его параллельно вместе с основной программой.
Кстати, в большинстве распространенных стеков разработки наверняка уже есть более или менее обобщенные системы управления конфигурацией и конвенции по умолчанию. Так мы переходим к следующему пункту:
Аддитивность
Поддержка и рефакторинг после разработки не должны стоить дорого. В хорошо спроектированной системе при измененении системных компонент можно не переписывать реализованную функциональность полностью, а «плавно перенести» ее на новый фундамент. Именно аддитивность позволяет создавать большие комплексы и формировать удобный интерфейс открытых библиотек, существующих десятилетиями.
Средства достижения аддитивности — отсутствие точек перечислений связей между компонентами разных подсистем и слабая связность. Напомним, что две компоненты, A и B, называются слабо связанными, если для взаимодействия им не надо знать детали реализации друг друга. Существуют две основные техники достижения слабой связности. Первая — это взаимодействие через общий интерфейс, вторая — использование прокси объектов представителей (т. е. на стороне A есть один объект, инкапсулирующий взаимодействие с B, аналогично на стороне B есть представитель A). В таком случае, чтобы изменить компоненту, достаточно переписать только прокси представителей, оставив всё остальное неизменным.
Есть ли техники, повышающие аддитивность? Да. Это, прежде всего, разработка небольшими шагами. То есть, если нам нужно в одном изменении выполнить задачи X, Y и Z, давайте сделаем это последовательно (может, даже разбив их на более мелкие части) и для каждого случая отладим тесты и отдельный коммит. Две маленькие проблемы решить проще, чем одну большую, к тому же, частая работа с кодом делает его лучше с каждым просмотром. Поэтому меня слегка настораживает стремление коллег «писать идеальный код». Вместо этого лучше стремиться писать код, который легко понимать и модифицировать — тогда у него будут шансы через какое-то время стать идеальным.
Трассируемость (или отслеживаемость)
Это свойство заключается в легкости понимания поведения программы. Если рассматривать его как некое преобразование входящих сигналов в исходящие, то трассируемость показывает, насколько точно мы можем отследить этот процесс.
Существует две основных стратегии отладки. Первая — это хаотичное выдвижение и проверка конкретных гипотез, вторая (правильная) — некий гарантированно сходящийся процесс локализации, похожий на поиски льва в пустыне методом деления надвое. Трассируемость создается путем систематической поддержки второго способа.
Средства достижения очень простые. Одно из них — защитное программирование, еще очень полезно во время разработки держать в голове не только путь основного выполнения, но и возможные его изменения вследствие возникновения ошибок. Представьте себе отладчика как одного из конечных пользователей. Чего делать нельзя ни в коем случае — так это терять информацию и «маскировать» возникающие ошибки без определения их причин.
Если API библиотеки можно представить как пользовательский интерфейс программиста, то трассируемость определяет «безопасность» этого интерфейса: при нажатии на кнопку ничего не взорвется, а если использовать ее в данной ситуации нельзя, то сообщение об ошибке расскажет, почему именно.
Естественно, концентрация на этих трех свойствах не гарантирует успешного выполнения проекта, но и отсутствие к ним должного внимания сильно усложнит задачу.