Гибкость программного кода — устойчивость к его изменениям. Это означает, что добавление новой или изменение старой функциональность в гибкой системе требует меньше усилий, чем в негибкой.
- Признаки гибкого кода
- Советы по написанию гибкого кода
- Совет 1. Везде, где можно, используйте базовый класс, а не его конкретных наследников
- Совет 2. В C/C++ применяйте typedef
- Совет 3. Не используйте «магические числа»
- Совет 4. Не пренебрегайте инкапсуляцией
- Совет 5. Тщательно планируйте сигнатуры функций
- Совет 6. Избегайте осознанного дублирования
- Совет 7. Предпочитайте делегирование наследованию
- Совет 8. Используйте паттерны проектирования
- Совет 9. Подумайте о применении плагинов
- Совет 10. Хорошего понемногу
Признаки гибкого кода
В широком смысле гибкость достигается за счет соблюдения двух условий: низкая связность и высокое зацепление модулей системы. Под модулем можно понимать как отдельный класс, так и целую библиотеку классов.
Низкая связность означает слабую зависимостей модулей друг от друга. В идеале должна быть возможность использовать каждый модуль самостоятельно, а не тащить в нагрузку всю систему. На практике зависимости всегда есть и будут, но сокращение их числа одновременно уменьшает сложность системы и способствует повторному использованию кодовой базы.
Высокое зацепление указывает на однородность абстракции, использованной при проектировании модуля. Такой модуль создан для решения единственной конкретной задачи, о чем говорит его интерфейс. Подробности смотрите в статье, посвященной принципу единой ответственности.
Советы по написанию гибкого кода
А теперь рассмотрим несколько конкретных советов, которые помогут вам создавать гибкий ООП-код. На самом деле, они все следуют одной простой мысли: «не размазывайте по коду то, что можно держать в одном месте». Действительно, чем меньше кода нужно трогать для внесения изменения, тем выше его гибкость, а также ниже вероятность сделать ошибку. Ведь если для внесения одного изменения нужно отредактировать сразу пять файлов, то вы вполне можете забыть об одном из них (считайте, что вам повезло, если компилятор заметит это, но так происходит не всегда). Рекомендую дополнительно обратить внимание на статью, посвященную принципу DRY (Dont’ Repeat Yourself).
Итак, поехали!
Совет 1. Везде, где можно, используйте базовый класс, а не его конкретных наследников
Если возникнет необходимость перейти на другую реализацию (сохранив старую), то используя базовый класс достаточно будет поменять точку инициализации. Нарушение совета означает, что вам придется переработать каждое упоминание класса (не забывайте про входные и возвращаемые параметры функций, которые могут существенно добавить работы).
В качестве примера обращайтесь к статье, где мы обсуждали использование полиморфизма в C++.
Совет 2. В C/C++ применяйте [crayon-672c031ff4024561513274-i/]
Не используйте примитивные типы напрямую, если реально найти какое-нибудь значимое имя. Ваш выбор в конечном итоге может измениться, когда кода с явным указанием типа уже очень много. Например, вы можете решить, что в качестве типа идентификатора пользователя вполне подойдет int. Но в какой-то момент вам становится понятно, что лучше было использовать long или unsigned int. И с этого момента начинаются трудности. Скорее всего, автозамена не поможет, ведь тип int встречается повсеместно. Единственным выходом становится полу-ручная обработка всего кода приложения. Особо осложнить ситуацию может то, что чаще всего такие проблемы выявляются уже на поздних этапах разработки (или даже эксплуатации!) системы.
Но вы всегда можете определить псевдоним: typedef int UserID. Теперь используемый тип можно поменять в единственной строке для всего приложении. Просто и быстро.
Совет 3. Не используйте «магические числа»
Если числа определяют какие-либо статические свойства приложения, то заведите соответствующую константу и применяйте ее. Даже если считаете, что она никогда не изменится (например, число пи, которое, правда, есть в большинстве стандартных библиотек). Во-первых это упростит вашу задачу, если константа все же изменится. Неожиданно, но такое часто случается! Например, для того же числа пи может измениться необходимая точность. А во-вторых код станет понятнее. Сравните сами, какое выражение проще: 3.14 * radius * radius или PI * radius * radius? С другой стороны, в формуле длины окружности 2 * Pi * radius создавать константу для двойки необходимости нет. Она имеет смысл лишь в этом контексте.
Существует еще один класс констант. Они не определяют какие-то фундаментальные постоянные, как число пи, а имеют смысл лишь в качестве средства параметризации приложения. Примером выступает размер шрифта или цвет фона главного окна. Большей гибкости можно достичь, если вместо константы в коде использовать переменную, инициализированную по умолчанию значением этой константы. В результате поведение приложения перестает жестко зависеть от такого параметра. Появляется возможность использовать разные значения в рамках одной и той же программы, что оказывается недостижимым в случае константы. Такой подход позволяет пойти еще дальше и считывать эти параметры из конфигурационных файлов, переходя на новый уровень гибкости. Но будьте осторожны. Любой сможет изменить содержимое файла настроек, поэтому потребуется добавить множество проверок для сохранения работоспособности при заведомо неверных значениях.
Совет 4. Не пренебрегайте инкапсуляцией
Ключевое слово private придумано не просто так (хотя, например, в Python его нет). Скрывайте внутренние особенности реализации модулей. Пренебрежение этим правилом существенно снижает гибкость программы. Если клиенты модуля начинают пользоваться его внутренними особенностями, то возникает зависимость (повышается связность). Любая зависимость — препятствие для внесения изменений. Вы не можете просто так поменять код, от которого напрямую зависят его пользователи. Поэтому чем меньше клиент модуля знает о нем, тем лучше.
На самом деле, инкапсуляция работает повсеместно. Правило простое: давайте переменным минимально допустимую область видимости. Если можно, то пусть переменная будет видна только в пределах функции, иначе определите ее в виде private-поля класса. Даже уровень доступа protected создает опасную зависимость (особенно в Java) при том, что его ВСЕГДА можно избежать. Переменные с public-доступом и глобальные переменные противоречат принципам ООП (конечно, существуют структуры, но об этом чуть позже).
Следствие из этого совета: избегайте допущений при использовании любого модуля, даже (особенно) если вы сами являетесь его разработчиком. Сегодня функция возвращает упорядоченный набор значений, а завтра нет (когда это не является явным постусловием). Если ваш код зависит от этого побочного эффекта, то все сломается.
Совет 5. Тщательно планируйте сигнатуры функций
Сигнатуры функций образуют интерфейс модуля. Интерфейс — контракт, который заключают модуль и его клиенты. Он выступает в качестве точки соприкосновения. Нельзя просто так взять и поменять интерфейс модуля, не затронув уже имеющихся пользователей (хотя расширение, как правило, допустимо). Поэтому подходить к планированию такого важного элемента нужно с особой ответственностью.
Поменяться в сигнатуре функции может не так уж много: имя, входные параметры и возвращаемое значение (обычно тип возвращаемого значения не включают в сигнатуру, но это не так важно). С именем функции придумывать особо нечего. Если уж оно оказалось неудачным, то единственный выход — создать еще одну функцию, вызывающую старую. Обратите внимание, что во многих популярных библиотеках использован этот прием (старое имя может быть помечено, как устаревшее и не рекомендуемое к использованию deprecated).
Что касается входных параметров, то все зависит от ситуации. Если вам повезет, то вы сможете безболезненно добавить новый параметр в самый конец, указав для него значение по умолчанию. В других случаях может помочь перегрузка. Учитывайте, что большое количество входных параметров функции (больше трех) — плохой признак. Если вы заметили это еще на этапе проектирования, то рассмотрите возможность преобразования части этих параметров в поля класса, которому принадлежит эта функция. Также можно попробовать создать новый класс, включающий связанные параметры. А если параметры слабо связаны друг с другом, то это может указывать на то, что функция имеет больше одного назначения, что противоречит принципу единой ответственности.
При работе с возвращаемым значением помогут советы 1 и 2.
Совет 6. Избегайте осознанного дублирования
Дублирование кода — зло. Но когда оно происходит осознанно, то это явный признак проблем проектирования. Чаще всего оно случается не в рамках одного класса, внутри которого можно легко создать соответствующую функцию. Осознанное дублирование обычно встречается, когда один и тот же код (или очень похожий) нужен в разных модулях. Как правило, этот модуль не вписывается ни в один из уже существующих интерфейсов, поэтому отнести его куда-то не разрушив однородность абстракции нельзя. Вывод: нужен новый модуль.
Многие разработчики не сильно напрягаются при проектировании такого вспомогательного модуля. Отсюда всякие Utils, Helpers и Tools. Они представляют собой свалку функционала, который как бы общий и ничей одновременно. Такие модули могут состоять из одних статических функций, скатываясь в процедурный мир. Конечно, это лучше, чем явное дублирование, но отказ от ООП тоже не самое правильное решение. Решение заключается в аккуратном обдумывании действительного назначения вспомогательной функции. Вокруг нее всегда можно построить специализированный класс. Пусть сначала он окажется очень простым, но вполне возможно, что в будущем вам потребуется его расширить.
Совет 7. Предпочитайте делегирование наследованию
Наследование — статическая связь между классами на этапе компиляции. Любая статика снижает гибкость системы. Проблема заключается в том, что если подкласс реализует определенное поведение, то во время исполнения кода это уже не изменить (либо требуется применение нетривиальных синтаксических структур используемого языка программирования). Альтернативой становится создание классов, поведение которых формируется путем комбинирования более простых компонентов.
Типичный пример заключается в том, что у вас уже есть класс, из которого вы хотите позаимствовать лишь часть свойств при реализации своего класса. Часто это заведомо плохая идея. Например, нередко можно встретить класс на подобии PersonList, наследующий List. Существует очень много причин почему это плохо. Даже не будем вдаваться в подробности, но запомните, что в таких ситуациях лучше либо явно включить существующий класс в виде поля вашего класса, либо использовать private-наследования в C++.
В качестве другого примера вспомним тестовое приложение, которое мы создали при обсуждении потоков в Qt. Для нас было важно обеспечить возможность выбора алгоритма построения фрактала. Мы могли бы реализовать базовый класс приложения, в котором предусмотрели бы виртуальную функцию для построения фрактала. Тогда для различных алгоритмов нам было бы достаточно унаследовать этот базовый класс, предоставив соответствующие реализации виртуальной функции. Все бы работало, но мы бы не смогли легко перейти от одного алгоритма к другому в процессе работы программы.
Что было сделано на самом деле: алгоритм построения фрактала мы превратили в самостоятельный абстрактный класс. Основная часть приложения использовала экземпляр этого класса в качестве своего поля (не забывайте про совет 1), делегируя ему задачу построения фрактала. Это позволило нам легко совершать подмену алгоритма «на лету». Статическое наследование такой возможности вам не даст.
Да, мы не смогли совсем избежать наследования. Ведь нам нужен полиморфизм. Но мы добавили дополнительный уровень косвенности. Любая косвенность увеличивает гибкость, поскольку создает дополнительную точку сцепления, которую можно подменить в любой момент (как в конструкторе Лего). Чем больше точек сцепления — тем выше гибкость. Но обратите внимание, что это привело к повышению связности модуля. Теперь если мы захотим повторно использовать какой-либо его уровень в другом проекте, то вместе с ним придется тащить и расположенные ниже слои (либо использовать собственные заглушки, что не упрощает задачу).
Замечу, что рассмотренный пример про алгоритм построения фрактала основан на паттерне Стратегия. Но об этом ниже.
Совет 8. Используйте паттерны проектирования
Если кто-то уже решил вашу задачу, то зачем решать ее еще раз? Паттерны — решения типовых задач проектирования. Они представляют собой очень мощные и удобные для применения инструменты создания гибкой архитектуры. Некоторые паттерны мы уже затрагивали, поэтому обращайтесь к соответствующим статьям:
Совет 9. Подумайте о применении плагинов
Плагины — динамически подключаемые в процессе работы приложения модули. Главное преимущество от их использования — расширяемость. В хорошо спроектированной системе с применением плагинов любой может добавить свой собственный функционал (даже без наличия исходных кодов этой системы).
Приложение с поддержкой плагинов использует крайне гибкую модель работы: имеется ядро, которое предоставляет минимальный функционал и служит скелетом системы, а плагины выступают в роли вспомогательных модулей, с которыми ядро взаимодействует. При этом любой из плагинов можно легко отключить или подключить без изменения исходных кодов ядра.
Примерами удачно спроектированных программ с поддержкой плагинов являются: eclipse, QtCreator, vim, VisualStudio, Chromium, Firefox и многие другие.
Совет 10. Хорошего понемногу
А теперь немного сбавьте свой энтузиазм. Гибкость — это замечательно, но, как уже отмечалось, это также дополнительная косвенность. Косвенность всегда увеличивает сложность. Повсеместно соблюдать перечисленные советы невозможно в принципе (не существует бесконечных абстракций). Главная цель этих советов заключается в том, чтобы отодвинуть фактическое решение подальше от центральной части (ядра) приложения и тем самым упростить внесение изменений.
С одной стороны, ничего плохого в этом нет, однако проблема кроется в том, что высокая гибкость — много абстракций, то есть общностей. Общее всегда сложнее частного. К тому же, этих общностей еще и много, а в программировании есть очень простое правило: чем больше кода, тем больше в нем мест для ошибок. Стремитесь к использованию минимально возможного числа модулей, но не меньше.
Создать негибкую программу намного проще и быстрее, чем гибкую, но расширить ее практически невозможно. Нужен компромисс. Вот только чувство баланса вырабатывается не сразу. Требуется большое количество практики. Работайте и анализируйте свои промахи. Тогда со временем ваши системы будут все более надежными и гибкими.