
SOLID, для тех из вас, кто не знаком с миром объектно-ориентированного программирования, является аббревиатурой пяти фундаментальных принципов, которые должны соблюдаться программистами, чтобы создать более понятный, удобный в обслуживании, расширяемый и, в конечном итоге, лучший программное обеспечение.
На прошлой неделе мой наставник поручил мне создать мини-игру, в которую можно будет играть из командной строки macOS. Предпосылка мини-игры - отточить мои основы SOLID в ООП, а также отточить свои способности в применении шаблонов проектирования в моем программном обеспечении.
Попутно я допустил несколько ошибок в отношении принципов SOLID. Ранее я также использовал неправильный шаблон дизайна в своем проекте. В этой статье я покажу вам эти ошибки и недоработки дизайна, которые я допустил на этом пути, и то, как мне удалось их исправить в итоге.
Если вам интересен мой проект в целом, вы можете получить к нему доступ здесь.
S - Принцип единой ответственности (SRP)
Единая ответственность означает, что каждый класс должен отвечать только за одну функциональность.
Вы говорите, что это объяснение на самом деле не объясняет, что означает принцип единой ответственности? Извините, но SRP, по сути, не требует пояснений. Отдельный класс не должен делать больше, чем должен. Хотя это довольно простой и понятный принцип, я считаю, что этот принцип легче всего упустить из виду. Этот принцип нарушается в основном из-за лени. Вместо того, чтобы создавать дополнительные классы и разделять обязанности, ленивые программисты просто втискивают всю функциональность в один класс бога.
Есть еще одна причина, по которой кто-то нарушает этот принцип, заключается в том, что он ошибочно думает, что функция - это работа этого класса, но на самом деле это работа того класса сделать это вместо этого. Это довольно сложная проблема для обнаружения, но есть простое решение этой проблемы. Когда вы определяете функцию внутри класса, задайте себе эти вопросы.
Будут ли в будущем другие программисты и я знать, что эта функция находится в этом классе?
Если позже спецификации изменятся, будет ли ясно, что другие программисты и я изменю эту функцию здесь и только здесь?
Чтобы дать вам больше понимания, я приведу вам пример из моей мини-игры, о которой я упоминал ранее. Чтобы дать вам некоторый контекст, я сделал приложение командной строки, которое запрашивает у пользователей ввод для создания своего личного персонажа Marvel. Я разработал игру так, чтобы каждый персонаж мог распечатать свое текущее здоровье, урон, имя и способности, используя функцию статуса .
Для нас было бы разумно поместить функцию статуса печати в каждый символ, потому что, в конце концов, каждый символ может напечатать свой уважаемый статус. Следует иметь в виду, что тот факт, что класс может выполнять определенную функциональность, не означает, что этот класс должен быть тем, который реализует указанную функциональность. У персонажа есть характеристики, но это не значит, что он должен распечатать его. После этого я решил отделить функцию печати от Character и вместо этого поместить ее в класс Printer.
Здесь ясно, что ответственность за печать лежит на принтере. Если есть изменение в том, как мы должны печатать статус каждого символа, теперь мы знаем, что нам нужно изменить реализацию внутри класса Printer вместо того, чтобы проходить каждый отдельный класс и изменять там функцию статуса.
O - Принцип открытости / закрытости
Каждый класс должен быть открыт для расширения, но закрыт для модификации.
Открытость для расширения означает, что класс должен быть расширяемым при появлении новой спецификации. Самый простой способ понять этот принцип - использовать наследование в качестве примера. Вот пример моего первоначального класса Action, который я создал в своей мини-игре.
Используя оператор if-else, я ограничиваю свое программное обеспечение для расширения, поскольку все больше и больше действий будут означать все больше и больше операторов if-else внутри моего класса Action. Чтобы исправить это, я вместо этого делаю Action протоколом с функцией execute, которой должны соответствовать все его подклассы.
Здесь мою программу легче расширить (имейте в виду, что каждый подкласс может быть помещен в разные файлы и модули). Все, что нам нужно сделать, это создать новый класс, соответствующий протоколу Action, не меняя ничего другого внутри основной логики самой программы. Реализация Action таким образом будет проще, так как она более читабельна, понятна и ее легче расширять в будущем. Представьте, что если у вас есть 20 команд действий, вы хотите иметь 200 строк с 20 операторами if-else в одном файле?
Этот метод абстракции также известен как шаблон стратегии, где мы группируем алгоритмы и делаем их взаимозаменяемыми во время выполнения. Мы определяем абстракцию в протоколе и скрываем детали реализации в каждом подклассе.
L - Принцип замены Лискова
Этот принцип гласит, что:
Пусть ϕ (x) будет доказуемым свойством для объектов x типа T.
Тогда ϕ (y) должно быть истинным для объектов y типа S, где S - это подтип T.
- Барбара Лисков и Жаннетт Винг
Если вы похожи на меня, то, возможно, вы не совсем понимаете, что все это значит. Как мы вообще обнаруживаем, когда наше программное обеспечение нарушает этот принцип? Это утверждение - именно то, почему мне труднее всего понять этот принцип, и я его активно избегаю в процессе программирования.
Однако есть более простой способ понять этот принцип. При программировании программного обеспечения вы должны соблюдать несколько ключевых моментов. Вот эти ключевые моменты:
- У подкласса не должно быть больше предварительных условий, чем у его родительского класса, а это означает, что если подкласс переопределяет свою родительскую функцию, эта функция не должна затем возвращать исключение, если ее исходная функция не вернет исключение.
- У подкласса не должно быть меньше постусловий, чем у его родительского класса, а это означает, что подкласс должен возвращать тот же ожидаемый результат, что и их родительский класс.
- У подкласса не должно быть неиспользуемой функции, унаследованной от родительского класса.
- Подкласс не должен использовать разные параметры для одной и той же функции.
Однако наиболее важно то, что каждый подкласс должен вести себя так же, как его родительский класс, и быть взаимозаменяемыми во время выполнения. Для клиента не должно быть заметно выполнение одной и той же команды, будь то для родительского или подкласса. Если он другой, возможно, вы нарушили принцип замены Лискова.
См. Этот пример моего протокола действий.
Изначально я поместил две функции выполнения в протокол действий. Это произошло из-за того, что было два разных типа действий: те, которые требуют только одного персонажа, и те, которые требуют двух. Если бы я реализовал это, то у всех подклассов была бы по крайней мере одна неиспользуемая функция. StatusAction принимает только один параметр, поэтому он не отменяет выполнение с двумя параметрами. Код будет работать, но нарушит принцип замены Лискова. Нам нужно разделить эти функции так, чтобы каждый подкласс только соответствовал их уважаемому контракту выполнения.
Здесь, после абстрагирования двух доступных действий, мы видим, что каждый подкласс SoloAction ведет себя точно так, как должен, и для выполнения их команды требуется только один параметр. То же самое относится к DuoAction, где для каждого подкласса требуется два параметра для выполнения.
I - Принцип разделения интерфейса
Если вы внимательно читали мои примеры, возможно, вы видели объект, называемый протоколом. Протокол практически такой же, как и интерфейс на других языках (с небольшими отличиями). Я лично использую протоколы, чтобы придерживаться этого принципа.
Этот принцип гласит, что каждую сущность не следует заставлять зависеть от функции, в которой она не нуждается. Используя интерфейс, мы можем абстрагироваться от каждой функциональности класса, а затем позволить каждому классу, которому нужна эта конкретная функциональность, реализовать ее самостоятельно.
Давайте посмотрим на этот пример из моей мини-игры
Сначала я подумал, что описываемый будет означать, что у класса будет имя и описание. Но что происходит, когда создается новый класс, и ему нужно только имя, но без описания? Нам нужно разделить эти две функции и создать два отдельных интерфейса, чтобы каждый класс мог решить, какой интерфейс они хотят реализовать.
Таким образом, если классу нужно реализовать только имя или только описание, они могут это сделать. Если классу нужны оба интерфейса, они могут реализовать оба интерфейса одновременно.
D - Принцип инверсии зависимостей
Чтобы придерживаться этого принципа, необходимо обратить внимание на два ключевых момента:
- Модули высокого уровня не должны зависеть от модулей низкого уровня. Оба должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Один простой способ понять это состоит в том, что модуль нижнего уровня не должен определять зависимость самого себя. Давайте посмотрим на этот пример прямо здесь:
У моего класса GameHandler есть несколько атрибутов. Поскольку я знаю, что ему нужны эти конкретные атрибуты, я мог просто определить их внутри самого класса GameHandler. Это, однако, усложняет ситуацию, когда новые спецификации появляются сами по себе, и оказывается, что мне нужно изменить ввод inputManager на SpecialInputManager или принтер на BeautyPrinter. Модуль более высокого уровня не сможет вносить изменения «на лету», скорее, нам нужно вручную изменить его внутри класса GameHandler.
С этим изменением мы больше не зависим от GameHandler в определении его собственных атрибутов, скорее, модуль более высокого уровня может определять его самостоятельно.
Заключение
Соблюдение этих 5 фундаментальных принципов означало бы создание лучшего программного обеспечения. Понятно изначально создать программное обеспечение, которое не соответствует основным принципам SOLID, но наша задача как программиста - вернуться и исправить эти ошибки, чтобы наше программное обеспечение было более удобным в обслуживании и расширяемым в будущем, не только нами, но и другими программистами.