Найти в Дзене
Я, Golang-инженер

#80. SOLID в Go - часть 1: обзор SOLID, девять примеров реализации Open/Closed Principle, паттерны от "Банды четырёх" как примеры OCP

Это статья об основах программирования на Go. На канале я рассказываю об опыте перехода в IT с нуля, структурирую информацию и делюсь мнением. Хой, джедаи и амазонки! Это первая часть планируемой серии о SOLID. Как всегда, публикация - это попытка разобраться с чем-то из IT-сферы, о чём слышал и не вполне понимал что это, как им пользоваться, а главное - для чего это нужно. Сперва хотел написать одну длинную статью о SOLID, раскрывающую все принципы с примерами и альтернативами. По ходу изучения понял, что это будет сложно сделать за раз, и нужно разделить тему на части. В этой публикации расскажу о SOLID в целом и разберу насколько возможно подробно один принцип: open/closed, поскольку он считается главным. Значительную помощь и старт в понимании SOLID и принципа открытости/закрытости в частности, мне оказали два источника: SOLID - это аббревиатура, которая означает концепцию из пяти принципов разработки хорошего ПО. Эти принципы - результат практики написания объектно-ориентированн
Оглавление

Это статья об основах программирования на Go. На канале я рассказываю об опыте перехода в IT с нуля, структурирую информацию и делюсь мнением.

Хой, джедаи и амазонки!

Это первая часть планируемой серии о SOLID. Как всегда, публикация - это попытка разобраться с чем-то из IT-сферы, о чём слышал и не вполне понимал что это, как им пользоваться, а главное - для чего это нужно.

Сперва хотел написать одну длинную статью о SOLID, раскрывающую все принципы с примерами и альтернативами. По ходу изучения понял, что это будет сложно сделать за раз, и нужно разделить тему на части.

В этой публикации расскажу о SOLID в целом и разберу насколько возможно подробно один принцип: open/closed, поскольку он считается главным.

1. О SOLID

Значительную помощь и старт в понимании SOLID и принципа открытости/закрытости в частности, мне оказали два источника:

  • Ультимативное видео по чистой архитектуре на YouTube без привязки к конкретному языку - https://youtu.be/WlCDcr8JYFU?si
  • Публикация разработчиков Домклик на Хабре: https://habr.com/ru/companies/domclick/articles/816885/

SOLID - это аббревиатура, которая означает концепцию из пяти принципов разработки хорошего ПО. Эти принципы - результат практики написания объектно-ориентированного кода и его поддержки.

Об ООП в Go можно почитать здесь: "клик"

Понятие "хороший код" для ПО - размыто. Будем считать хорошим ПО такое - которое легко поддерживать, в т.ч. выпускать новые версии с новым функционалом без повторного тестирования всего приложения и без срочных фиксов багов после релиза новой версии.

Разные люди формулировали и выявляли разные подходы к разработке ПО. Из всего разнообразия материалов, важные для себя принципы выделил и популяризировал один программист - Роберт Мартин, сделал он это в начале 2000-х в статье Design Principles and Design Patterns, статья на 34 страницы; позднее были другие публикации на эту же тему.

В 2004 г. Майкл Физерс выявил пять самых важных принципов из работ Мартина и предложил для упрощения запоминания назвать эти принципы аббревиатурой SOLID, где каждая буква - какой-то принцип, вот они:

  1. S - Single-responsibility principle (SRP) - принцип единственной ответственности;
  2. O - Open-closed principle (OCP) - принцип открытости/закрытости;
  3. L - Liskov Substitution principle (LSP) - принцип подстановки Барбары Лисков;
  4. I - Interface Segregation principle (ISP) - принцип разделения интерфейса;
  5. D - Dependency Inversion principle (DIP) - принцип инверсии зависимостей.

С тех пор практика SOLID стала чуть ли не олицетворять хорошего программиста, такое мнение исходя из требований к кандидатам во многих вакансиях програмистов/девопсов:

Фрагмент вакансии
Фрагмент вакансии

Хочу разобрать каждый принцип не в порядке их в аббревиатуре, а по мере упоминания их в исходной статье - в порядке, который посчитал важным старина Мартин, и уже с ориентированием применения в Go, а не абстрактных языках с ООП. В этой публикации сосредоточусь только на принципе OCP.

На самом деле, в статье Мартина 2000х больше пяти принципов, ниже они представлены по порядку встречи в его статье:

  1. The Open Closed Principle - O;
  2. The Liskov Substitution Principle - L;
  3. The Dependency Inversion Principle - D;
  4. The Interface Segregation Principle - I;
  5. The Release Reuse Equivalency Principle;
  6. The Common Closure Principle;
  7. The Common Reuse Principle;
  8. The Acyclic Dependencies Principle;
  9. The Stable Dependencies Principle;
  10. The Stable Abstractions Principle.

А также обзорно представлены некоторые паттерны проектирования. О паттернах немного расскажу далее.

Так вот, первые четыре принципа Мартин относит чисто к дизайну классов; последующие шесть - к архитектуре пакетов. Хотя в том же ультимативном видео по чистой архитектуре более полно раскрыт принцип OCP, без привязки к классам.

Интересно также то, что принципа, отвечающего за букву S в аббревиатуре SOLID, не найдём в публикации Мартина 2000-х, вероятно он был включён Мартином в список важных принципов несколько позднее, по крайней мере в 2002 г. в книге "Agile Software Development: Principles, Patterns, and Practices", в русском переводе в 2004 г. "Быстрая разработка программ. Принципы, примеры, практика".

Важно понимать, чьи труды мы изучаем и к чьему опыту хотим прислушаться.

Кто такой Роберт Сесил Мартин, автор SOLID? Это гражданин США, родился в 1952 г. Свою первую книгу, а она была о разработке на С++, он написал в 1995 г. в возрасте 36 лет. И после этого раз в несколько лет он писал новые книги и открыл компанию по консультированию разработки ПО и аудиту.

Нужно думать, что к этому времени он стал мега-крутым разработчиком, если не написавшим свой Телеграмм, то участвовавший в значимых IT проектах, или сыгравший в них ключевую роль.

И тут проблема: ни один источник в интернете не говорит, кем работал Мартин до 36 лет (в этом возрасте пишет первую книгу по С++), - по крайней мере я не нашёл информации. Сам он в соцсети пишет, что самоучка и начал программировать в 17 лет:

https://x.com/unclebobmartin/status/1072125758548688896?lang=en
https://x.com/unclebobmartin/status/1072125758548688896?lang=en

В одном из источников нашёл информацию, что Мартин:

...начинал свою карьеру с должности помощника программиста в одной из бесчисленных контор по производству офисного ПО для довольно слабых компьютеров...
https://fb.ru/article/455603/robert-martin-rasskaz-ob-idealnom-programmiste

В интернете пишут примерно следующее: что к 1970-м Мартин приобрёл авторитет в области программирования и прозвище "Дядя Боб". На секундочку, ему в это время было ~18 лет (!). Кто-то где-то преувеличил, и все публикации повторяют одно и то же.

Возможно он занимался в каких-то секретных/военизированных организациях типа DARPA (почитать о ней можно здесь: "клик"), потому и нет открытой информации, - хотя о других разработчиках из DARPA и их вкладе в IT-сферу вполне можно найти информацию. Возможно, Мартин работал в ноу-нейм конторах без особого вклада в IT-индустрию, возможно ещё что-то. В конце концов история знает немало "левшей" - мастеров своего дела, о которых известно только в узких кругах до поры до времени.

В любом случае, нужно понимать и относиться соответственно - историю с SOLID продвинул человек, для которого консультирование, курсы и обучающие книги - основное дело.

Ключевое здесь на мой взгляд то, что SOLID - не последняя инстанция, а набор инструментов, которые нужно разумно применять или не применять, понимая, где они принесут пользу, а где могут нанести вред.

2. O - принцип открытости/закрытости

Общие сведения

O - Open/closed principle

Мартин называет его главным из пяти принципов разработки ПО, разобранных в его статье.

В классической трактовке принцип означает, что код открыт для расширения и закрыт для модификации.

Под модификацией подразумевается изменение существующего кода, а это - потенциальные баги, необходимость повторных тестов ранее написанного и протестированного кода при каждой доработке, - а это могут быть миллионы строк кода и тысячи сценариев тестов. Под расширением подразумевается изменение логики: добавить что-то или убрать, не изменяя ранее написанного кода.

На первый взгляд это кажется абсурдным - как можно поменять логику, при этом не изменяя код?

Суть в том, что написанный код предоставляет некий интерфейс в широком смысле этого слова для взаимодействия с потенциальным новым кодом. И мы взаимодействуя с этим интерфейсом можем изменить логику не трогая написанный код. Или, точнее, не трогая основную часть написанного кода, которая на этапе разработки была принятой, как неизменяемая, или изменяемая в очень редких ситуациях.

Мне больше нравится трактовка, которую я сформулировал сам:

Принцип открытости/закрытости говорит о том, что в написанном коде можно легко изменить поведение, не трогая существующий код, а добавляя новый код.

На самом деле переписывать существующий код придётся в любом случае, но переписывать мы будем кусочек логики, не влияющий на существующий код.

Это похоже на прививание черенков плодовых деревьев к другим деревьям: ствол один, плоды разные (ps - у яблок, привитых к берёзе, появляется вкус берёзового сока):

https://ru.pinterest.com/pin/607141593489290099/
https://ru.pinterest.com/pin/607141593489290099/

Если продолжить аналогию, то корневая система дерева - это каркас программы, который планируется оставить без изменений. Ветви деревьев - варианты расширений функционала ПО без необходимости вмешиваться в работу корневой системы (основного кода). Ствол дерева - интерфейс, через который мы можем расширять исходный функционал корневого кода.

Для принципа открытости/закрытости важно предусматривание в коде трёх частей:

  • Корневая система - неизменяемая часть кода (или крайне редко изменяемая);
  • Ветви - дополнительный функционал, которым можно расширить/заменить другой подобный функционал кода;
  • Ствол - интерфейс взаимодействия корневой системы и ветвей.

Принцип открытости/закрытости был изложен в возрасте 38 лет французским специалистом в области информатики Бертраном Мейером в 1988 г. в книге "Object-Oriented Software Construction", отвечая на вопрос:

«Как можно разработать проект, устойчивый к изменениям, срок жизни которых превышает срок существования первой версии проекта?»

Идея была в том, что единожды разработанный класс (в Go отсутствует понятие класс в классическом понимании ООП) в дальнейшем требует только исправления ошибок, а новые или изменённые функции требуют создания нового класса.

Фото  Б. Мейера из русской Википедии
Фото Б. Мейера из русской Википедии

Интересные факт, с 2011 г. Мейер работал завкафедрой в университете Санкт-Петербурга, а с 2014 г. - в Иннополисе Татарстана заведующим Лаборатории программной инженерии. Как долго он работал в РФ, информации не нашёл.

В статье Мартина примером принципа открытости/закрытости является код подключения для разных моделей модемов.

В моей публикации я постарался собрать известные мне лайфхаки, когда мы можем изменять функционал, не затрагивая некую корневую исходную логику.

В Go я нашёл следующие способы реализовать принцип открытости/закрытости:

  1. Эмбендинг - библиотека и переиспользование методов;
  2. Передача простого "фильтра" в функцию;
  3. Передача набора простых "фильтров" в функцию;
  4. Передача функции по-значению в другую функцию;
  5. Вызов и выполнение скрипта из хранилища.
  6. Паттерн "Стратегия" - однотипное поведение для разных структур;
  7. Switch по значению карты;
  8. Пустой интерфейс, как параметр функции/метода без проверки его типа;
  9. Паттерн "Шаблонный метод".

Ещё раз суть принципа открытости/закрытости:

Вообще, главная мысль, которую я понял по всем принципам SOLID, и в частности в принципе открытости/закрытости, т.к. первым его начал разбирать - невозможно ко всему коду применить любой принцип. В программе можно создать фрагменты кода, для которых возможно применить тот или иной принцип, или несколько принципов. А уж применять, или нет - дело программиста.

Любой принцип из SOLID можно применить только к фрагменту кода. Не будет такого, что весь код написан по SOLID, т.к. каждый принцип ситуативен.

Это важно для понимания: принципы SOLID применяются локально, а не глобально ко всему коду.

2.1. Эмбендинг

Эмбендинг в Go - это встраивание структур в другие структуры или встраивание интерфейсов в другие интерфейсы.

Эмбендинг любезно предоставлен авторами Go из коробки, и даёт возможность нативно использовать один из вариантов реализации принципа открытости/закрытости.

Если эмбендинг структур - вещь широко применяемая, то применять эмбендинг интерфейсов полезно разве что хайлевелным разработчикам в очень специфичных ситуациях.

Суть эмбендинга понятна: мы можем добавить в структуру новую структуру, не затрагивая существующие поля и связанную с ними логику.

Можно сказать, что в Go наследование по-поведению и наследование по-данным разделено, и эмбендинг структур - это наследование по-данным. Почитать подробнее можно в вышеупомянутой публикации об ООП. Подробнее останавливаться здесь не буде.

Добавлю только, что у структур в Go номинальная типизация, а у интерфейсов - структурная типизация (парадокс).

Это означает, что если есть две структуры с разными названиями, но поля этих структур будут называться одинаково и иметь одинаковые типы данных, то мы не сможем передать в один метод обе эти структуры: или одна или другая.

А для структурной типизации интерфейсов важно, что лежит внутри, а не что за интерфейс сам.

2.1.1. Родительская структура - как библиотека для эмбендинга

Рассмотрим код:

Код
Код

Здесь я смоделировал ситуацию, когда есть некая библиотека в котором определена базовая структура, которую могут использовать другие пакеты: в неизменном виде (как в структуре monster), или добавляя в неё свои поля.

Это первый из трёх примеров, который я посчитал реализацией принципа открытости/закрытости для эмбендинга. Мы создаём базовую структуру, как-бы в виде библиотеки, а другие пакеты используют её для расширения собственного функционала.

2.1.2. Переопределяем метод дочерней структуры

Рассмотрим код:

Код
Код

В нём мы создали метод для структуры. А если нам понадобится изменить логику sendMsg из-за какого-то параметра настройки, что делать? При этом нужно сохранить текущую реализацию.

Используя принцип открытости/закрытости можно добавить новую структуру и новый метод чисто для него, но с таким же названием, как у метода родительской структуры:

Код
Код

Мы создали новую структуру на базе существующей и переопределили для неё собственный метод sendMsg. Для каждой структуры - alien и men - будут функционировать собственный методы sendMsg.

Вроде бы ничего особенного в этом нет: по сути это новый метод для новой структуры. Но вот в чём дело, в данном случае мы перезатёрли для alien метод от men, а можем не перезатирая оставить старый метод и новый для структуры alien, об этом далее.

2.1.3. Используем для структуры свой метод и метод родительской структуры

Рассмотрим код:

Код
Код

Здесь я изменил название метода для структуры alien - дал ему новое название, чтобы он не переопределял метод для структуры men. И теперь для alien я могу использовать оба метода: и от встроенной структуры men, и определённый для alien персонально. Мы расширили функционал, не трогая написанного кода.

Для эмбендинга мы рассмотрели три примера реализации принципа открытости-закрытости:

  1. Структура - как часть библиотеки, которую другие пакеты могут использовать как основу, встраивая её в новую структуру;
  2. Переопределяем существующий метод для новой структуры с эмбендингом;
  3. Создаём новый метод - оставляем возможность для новой структуры с эмбендингом использовать собственный метод и метод родительской структуры.

2.2. Передача "фильтра" в качестве аргумента

Рассмотрим код без оптимизации:

Код
Код

В функции filterCrew внутри кода используется строковый литерал "Алиса" для выстраивания логики функции. Литерал в данном случае можно назвать так называемым "магическим литералом", т.к. его назначение непонятно без контекста. Почему Алиса? Для чего Алиса?

Но не в этом проблема. Литерал можно вынести в глобальную константу или сделать ещё что-то для прояснения ситуации, например, закомментировать. Проблема в том, что если мы вдруг захотим изменить логику фильтрации, нам придётся идти в функцию filterCrew и что-то там менять. А мы хотим сделать универсальную функцию, которая не будет подвержена каким-либо изменениям. Хотим забыть о ней, чтобы она работала и её никто не трогал. Если продолжить аналогию с прививанием деревьев, мы хотим и видим возможность, чтобы функция filterCrew стала частью корневой системы.

Просто передаём аргумент фильтрации в функцию:

Код
Код

Теперь мы передаём в функцию filterCrew фильтр. Всё, эта функция теперь универсальная: при изменении фильтра, мы её не трогаем. Уровень абстракции вырос. Т.е. код стал более гибким, более открытым для расширения и закрытым для модификации.

Конечно, нам нужно будет менять другую функцию - которая вызывает filterCrew. Но для нас это не страшно, для нас главное было сделать какую-то функцию универсальной, подходящей для множества сценариев и которую не нужно перетестировать - мы один раз её как следует протестировали и спокойно используем в любых сценариях. Соответственно, функция, вызывающая filterCrew в данном случае будет являться "ветвью дерева"; функция filterCrew - корневой системой; а сигнатура функции filterCrew - стволом, т.е. интерфейсом в широком смысле, позволяющим взаимодействовать различным ветвям с одним корнем.

Такой простой сценарий использования принципа открытости/закрытости.

Идём дальше: что если нам нужно несколько фильтров, причём неизвестно сколько именно будет в разных сценариях - один или двадцать девять миллионов?

2.3. Передача набора фильтров в функцию

В этом случае всё аналогично, просто воспользуемся встроенным инструментарием Go, позволяющим функции принимать неопределённое количество однотипных аргументов. Передавать можем набор значений, либо сразу срез:

Код
Код

Так, передавая разные фильтры мы продолжаем использовать универсальную функцию filterCrew. Можем передавать один фильтр, можем - множество. Не важно, функция одинаково справляется.

2.4. Передача функции по-значению

2.4.1. Пример №1 с функциями

Что если мы хотим фильтровать не просто по конкретному значению, а по набору каких-то условий? Например, не хотим привязываться к проверке чисто по имени члена экипажа, а расширить возможности функции.

Рассмотрим код:

Код
Код

Теперь в функцию filterCrew мы передаём не конкретное значение фильтра, а функцию по-значению. Мы можем расширять функционал приложения, не трогая некую исходную логику. Причём это расширение более сложного уровня, чем передача простых параметров из предыдущего параграфа.

Для примера я реализовал функцию filterCity, которая фильтрует по городу, и для изменения логики кода мы не будем трогать filterCrew, а изменим переданный в него аргумент:

Код
Код

Точно также, можно изменить код, чтобы он принимал не одну функцию по-значению, а неопределённое количество таких функций.

2.4.2. Пример №2 с функциями - middlware

В этой публикации о middlware я рассказывал о применении middlware и что это. Middlware использует передачу функции по-значению и реализует принцип открытости/закрытости, когда базовый механизм остаётся неизменным, но мы можем добавить пре- и пост- обработку к этому базовому механизму через новые цепочки middlware.

Приведу только один скрин из этой публикации, чтобы показать о чём речь:

Код с middlware
Код с middlware

Можно создавать целые цепочки из middlware и таким образом расширять функционал, не изменяя функционал базовых "корневых" функций.

2.5. Скрипт из хранилища

В этом случае суть примерно та же, как передача фильтра в функцию или передача функции по-значению в функцию.

Только фильтром таким будет скрипт из хранилища, например из БД. Подробнее можно почитать в публикации Lua в Golang.

Пример из публикации о Lua:

Код
Код

Идея в том, что расширение функционала происходит в стороннем хранилище, и его обновить как правило проще, чем обновить ПО на серверах.

2.6. Паттерн "Стратегия"

2.6.1. Общие сведения

Паттерн - это давно известное и хорошо зарекомендовавшее себя решение распространённой задачи.

Паттерн "Стратегия" предполагает наличие множества взаимозаменяемых алгоритмов, количество которых вероятно будет увеличивать по мере развития приложения, а также интерфейса для запуска любого из алгоритмов однотипным образом.

Тот или иной алгоритм должен выбираться "не лету" по ходу выполнения приложения, без участия человека, а сами алгоритмы не связаны друг с другом и при определённых сценариях могут подменяться один алгоритм на другой, или например при неуспехе одного алгоритма, задействовать следующий. Чем-то похоже на передачу функции по-значению из предыдущего параграфа, но чуть глобальнее.

Ключевой смысл в том, что вместо жёсткой фиксации одного какого-то алгоритма или последовательности алгоритмов с многочисленными if-else в ходе выполнения кода, мы создаём некий объект, и этот объект выбирает, какой алгоритм использовать.

На самом деле для выбора алгоритма разработаны свои паттерны, здесь мы ограничимся простым switch.

Важно при разработке Стратегии выработать ожидаемый результат: что будет итогом работы алгоритмов, т.е. какие данные и сколько их будет в результате по итогу исполнения алгоритмов и какие данные алгоритм запрашивает для своей работы. И исходя из этого можно выстраивать Стратегию.

2.6.2. Базовый код

Рассмотрим часть кода:

Код с реализацией open/closed principle
Код с реализацией open/closed principle

Выделенный фрагмент - смысл Стратегии и пример реализации принципа открытости/закрытости: совершенно разные структуры (и методы для этих структур) мы можем использовать одним образом. Причём не в том месте, где эти структуры создаются (это важно) и без проверок типа структуры перед их использованием.

Сама Стратегия реализуется в комбинации интерфейс + семейство взаимозаменяемых алгоритмов, т.е. таких алгоритмов, которые принимают и возвращают значения конкретных типов данных.

Строки 38-41, просто считывание аргумента, передаваемого при запуске программы. Например, вместо

go run main.go

мы для запуска кода напишем, например

go run main.go Алиса

Попробуйте ответить, кстати, почему вначале длина os.Args проверяется на литерал два.

Итак, вначале мы создали интерфейс, а в конце вызываем метод интерфейса конкретной структуры, единообразного для различных структур. Вот недостающая часть кода с определением структур и методов для каждой структуры:

Код с реализацией open/closed principle
Код с реализацией open/closed principle

В этом коде показаны две различные структуры, методы для них и сам интерфейс - основа стратегии: мы определили, какой результат должен быть у любого алгоритма.

А вот как будет выглядеть результат работы программы с разными аргументами:

Код с реализацией open/closed principle
Код с реализацией open/closed principle

Из разных входящих параметров мы получаем различные алгоритмы. Т.е. выбор алгоритма произошёл "на лету", без участия человека. А реализация алгоритма отделена от непосредственного выбора алгоритма. В разработке ПО - это крайне полезная вещь, облегчающая расширение кода, его понимание, фиксы и тестирование.

2.6.3. Альтернатива без open/closed principle

Какая альтернатива такого подхода? Для меня поначалу было не очевидно, чем Стратегия лучше прямого использования методов от конкретных структур без участия интерфейсов. Поскольку мы можем обработать в свиче варианты алгоритмов без интерфейсов, и они будут прекрасно работать:

Код без open/closed principle
Код без open/closed principle

В чём здесь быть проблема? Чем хуже использование u.fly() и a.fly() вместо универсального f.fly() из предыдущего примера?

2.6.4. Зачем нужно отделять реализацию алгоритма от его определения?

Проблема в том, что в коде без open/closed principle выбор алгоритма напрямую, что-называется, жёстко связан с реализацией алгоритма. Т.е. мы не можем запустить алгоритм в какой-то другой части кода, отличной это этого свича, либо в том другом месте придётся делать такой же свитч с проверкой какой тип структуры к нам пришёл и индивидуально для него писать логику. Это потребует дополнительной большой логики, замедлит исполнение кода, повышает риск ошибок когда в одной части кода произвели изменения, в другой - забыли.

Почему важно отделить выбор алгоритма от его реализации, т.е. почему мы можем захотеть вызывать реализацию алгоритма в другом месте?

Так, на первом этапе Стратегии мы определили, какой результат может возвращать алгоритм. Но тут возникает вопрос в пре- и постобработке алгоритма.

Допустим, мы в дальнейшем захотим не печатать в терминал полученный в ходе выполнения алгоритма результат, а сохранять в файл или отправлять по сети. Что в этом случае, т.е. без разделения логики выбора алгоритма и его реализации, придётся делать? Примерно вот это:

Код без open/closed principle
Код без open/closed principle

Мы для каждого алгоритма вручную прописали изменения его пост-обработки, что мы делаем с результатом: теперь мы сохраняем данные в файл.

Результат работы ПО
Результат работы ПО

Такой подход противоречит принципу открытости/закрытости - нам нужно влезать в уже написанную часть кода и делать в ней правки. Хотя прекрасно можем обойтись без этого, если бы разделили логику реализации и логику выбора алгоритма.

Это один из примеров пост-обработки выполнения алгоритма.

Вот пример пре-обработки выполнения алгоритма. Допустим, каждый наш алгоритм обладает функционалом по двум направлениям, и мы получаем два исходных условия - какой алгоритм, и что он должен сделать. Я сразу покажу как это будет выглядеть со Стратегией, и вы сами поймёте как это усложнило бы код без Стратегии.

Расширим интерфейс и добавил новые методы для каждой структуры:

Код с реализацией open/closed principle
Код с реализацией open/closed principle
Код с реализацией open/closed principle
Код с реализацией open/closed principle

Здесь второй свитч выполняет пре-обработку исполнения алгоритма: определяет, какой использовать метод выбранного алгоритма (выбранной структуры).

Представьте, что всю эту логику и постоянное её возможное изменение по мере развития ПО, пришлось бы дорабатывать при жёсткой связке выбора алгоритма и его реализации: ненужная сложность, громоздкость, а поддерживать такой код будет становиться всё сложнее по мере развития проекта.

Итак, паттерн Стратегия позволяет использовать разные алгоритмы из семейства алгоритмов единообразным способом. За выбор алгоритма отвечает другой паттерн (об этом в главе 3).

Использование стратегии позволяет расширять код, внося новые алгоритмы и, скажем так, регистрируя их в объекте выбора алгоритмов (таким объектом здесь является switch). Тогда при необходимости внесения изменений в пре- и пост-обработку результатов выполнения алгоритма, нам не нужно переписывать логику для каждого алгоритма: мы в одном месте доработаем код, и всё.

2.6.5. Когда применять Стратегию

Когда стоит применять паттерн Стратегия? Есть вполне определённый сценарий на мой взгляд, когда такую реализацию принципа открытости/закрытости стоит рассматривать в коде:

  1. Есть исходные данные, от которых далее можем отталкиваться для выбора алгоритма реализации - в данном случае эти исходные данные - это ввод аргумента при запуске программы, в другом - это будет какой-то сценарий с чтением и обработкой информации из БД, в третьем - сетевая информация, в четвёртом - комбинации этих режимов и т.д.
  2. Результат работы всех алгоритмов можно унифицировать: каждый алгоритм возвращает вполне конкретный результат и принимает также одинаковые параметры.
  3. Алгоритмов ожидается много. Не два или пять, а десятки, например, или даже тысячи. Или потенциально вместо пары алгоритмов сейчас со временем планируется, что количество алгоритмов будет исчисляться десятками.
  4. Сложный индивидуальный сценарий реализации каждого алгоритма. Не просто метод в пять строк, а пакеты с сотнями/тысячами строк кода.
  5. Наличие пре- и пост-обработки результатов алгоритма. Это уж наверняка будет: куда передаётся результат исполнения алгоритма, как эта информация обрабатывается, какие настройки передаются одновременно с исполнением алгоритма.

На этом всё с разбором паттерна Стратегия.

2.7. Switch по значению карты

2.7.1. Пример без open/closed principle

Смоделируем ситуацию, когда происходит проверка по одним значениям switch в разных участках кода.

Рассмотрим код и результат его работы:

Код и вывод в терминал
Код и вывод в терминал

В коде определён набор условий и ряд случаев обработки этих условий в switch. Если потребуется обработать новые условия, или изменить, скажем code10, чтобы он относился не к логике №2, а к логике №1, потребуется исправлять функцию main. Да и в целом - код несколько запутанный в плане перечисления условий в case'ах.

2.7.2. Пример с open/closed principle

Можно доработать код следующим образом, используя карту:

Код и вывод в терминал
Код и вывод в терминал

Помимо бонусного эффекта - улучшение читаемости кода в switch, его наглядности, мы привнесли принцип открытости/закрытости: при обновлении кодов или если потребуется их отвязать от одной логики и привязать к другой - не нужно трогать функцию - достаточно доработать карту.

Такой подход полезно применять, когда количество условий существенно меньше количества возможных сценариев срабатывания условий.

2.8. Приём функцией пустого интерфейса в качестве параметра

Предположим, в зависимости от алгоритма мы рассчитываем получить данные, которые нужно парсить в разные структуры. Базовое решение - сделать для каждой структуры индивидуальный парсер. А можно пойти универсальным путём.

Рассмотрим код:

Код
Код

Мы создали функцию parseData, которая одним из параметров принимает пустой интерфейс.

Соответственно, эта функция будет парсить данные в любые структуры: один раз написали, и пользуемся сколько захотим. Передаём любые структуры в неё. Вот пример вывода:

Код и терминал
Код и терминал

В реальных приложениях такой подход может использоваться в намного более сложных функциях, например перед обработкой джейсона следует подготовка и отправка сетевого запроса, и чтение полученных данных.

2.9. Паттерн "Шаблонный метод"

2.9.1. Общие сведения

Паттерн Шаблонный метод формирует скелет алгоритма из методов. Получаем примерно следующее

func (s *someType) mainMethod (data any) string{
s.step1(data)
s.step2(data)
s.step3(data)
return data
}

И для разных ситуаций могут быть созданы свои структуры и свои реализации каждого этапа.

Можно сказать, что паттерн Шаблонный метод - это менее гибкая Стратегия, притом, может быть более сложная в реализации. И используется, чтобы сократить дублирование кода для семейства алгоритмов, действия которых можно привести, скажем так, к набору кнопок 1, 2, 3 ... n. И эти "кнопки" мы реализуем в шаблонном методе. А реализация этих "кнопок" может отличаться для разных структур.

При этом, также, как и в Стратегии, мы можем единым образом вызывать алгоритм для разных структур.

Шаблонный метод также может включать хуки - необязательные методы в определённых участках шаблонного метода, типа такого:

func (s *someType) mainMethod (data any) string{
s.step1(data)
customMethod1(data)
s.step2(data)
s.step3(data)
customMethod2(data)
return data
}

Хуки для большинства алгоритмов (структур) могут ничего не делать, а для особых случаев когда нужны - в них будет какая-то логика.

При этом могут быть определены базовые, т.е. универсальные методы для структур. Которые можно использовать, не переопределяя для разных структур собственные методы - тоже сокращение дублирование кода.

Мне пока трудно представить случай, где паттерн Шаблонный метод действительно нужен. Почему-то складывается впечатление, что он хорош для разработки сценариев игр, где, возможно, миллионы однотипных алгоритмов. Помню, делал что-то типа модов для Morrowind (которые только сам и использовал), и по-ощущениям разработчики как раз предоставляли общий скелет, и была возможность расширять поведение программы; хотя это всё же больше похоже на использование сценариев скриптов и эмбендинга:

Морровинд: https://elderscrolls.fandom.com/wiki/Skaal_Village_(Bloodmoon)
Морровинд: https://elderscrolls.fandom.com/wiki/Skaal_Village_(Bloodmoon)

Разберу один пример, где каждый алгоритм (каждая структура) на всех этапах реализует собственные методы, без возможности использования универсальных.

2.9.2. Пример паттерна "Шаблонный метод"

Рассмотрим код:

Код
Код

В коде создан интерфейс с двумя необходимыми базовыми этапами и структура, включающая этот интерфейсный тип данных в качестве поля. А также две конкретные структуры для разных сценариев, требующих обработки.

Идём далее:

Код
Код

Функция main с созданием разных структур в зависимости от исходных данных и запуск алгоритма шаблона методов. А также функция содержащая этапы обработки, через которые должны проходить все поступающие данные любого вида (любой исходной структуры).

Идём далее:

Код
Код

В этом фрагменте представлены персональные методы для каждой структуры.

Посмотрим на результат работы:

Код и терминал
Код и терминал

Как видим, для разных данных был выбран и реализован разный алгоритм.

Пока это все примеры, которые я смог придумать сам и найти из источников информации для реализации принципа открытости/закрытости.

Далее расскажу о книге, полезной для решения серьёзных задач и для подготовки к вопросу интервью "какие паттерны проектирования вы знаете".

3. Паттерны от "Банды четырёх"

Паттерны - по сути, примеры реализации принципов SOLID, на мой взгляд. В этом параграфе наискосок коснусь этой темы.

3.1. Общее описание

Есть книга где собраны 23 паттерна разработки кода, оригинальное название Design Patterns: Elements of Reusable Object-Oriented Software:

https://book24.ru/product/patterny-obektno-orientirovannogo-proektirovaniya-5775545/
https://book24.ru/product/patterny-obektno-orientirovannogo-proektirovaniya-5775545/

Об авторах говорят часто "Банда четырёх", т.е. Gang of Four или просто GoF.

Четверо разработчиков собрали проверенные решения типовых задач и написали книгу, издана в 1994 г.

Я эту книгу не читал, а узнал о ней, когда стал искать типичные способы выбора алгоритма для паттерна Стратегия. Сама Стратегия - также один из паттернов в этой книге.

Реализация switch для выбора алгоритма из параграфа Стратегия - это реализация паттерна "фабричный метод" из этой книги. Чтобы понять, какие ещё есть приёмы, в т.ч. по реализации принципа открытости/закрытости - можно будет изучить эту книгу. Ну или хотя бы знать - что есть по крайней мере 23 способа решения типовых задач.

Можно сказать, что в этой публикации разобраны три паттерна из книги "Банды четырёх": Стратегия, Шаблонный метод и Фабричный метод.

Фабричный метод также может быть реализован не через switch, а через карту. Разберём его для ознакомления.

3.2. Паттерн "Фабричный метод" через карту

Рассмотрим код:

Код
Код

Здесь остаётся по итогу Стратегия, но выбор алгоритма теперь не через switch, а через карту.

В карте значением является функция - один из примеров реализации принципа открытости/закрытости, который мы рассмотрели выше.

Рассмотрим остальную часть кода:

Код
Код

Здесь мы реализуем функции, которые являются значениями карты. А также итоговые методы для разных структур.

Примеры вывода:

Вывод в терминал
Вывод в терминал

Итак, мы разобрали ещё один пример помимо switch выбора и работы с базовым алгоритмом - через карту.

В каких случаях выгоднее использовать карту, в каких switch для определения алгоритма?

На мой взгляд здесь зависит чисто от количества алгоритмов и наглядности. Субъективно, вариант со свичем нагляднее. При этом его алгоритмическая сложность по-идее О(n), а алгоритмическая сложность работы с картой - аммортизированное константное время. Т.е. карта будет работать быстрее.

Вероятно, если количество алгоритмов выше полусотни (а то и десятка), а приложение предполагается высоконагруженным, то вариант с картой может работать быстрее.

4. Выводы

В этой публикации познакомились с SOLID: узнали, кто её автор(ы), поняли, что у каждого принципа есть свой автор, и принципов намного больше чем пять из SOLID. Поняли, что весь код не может быть по SOLID, т.к. каждый принцип имеет ситуативное применение и распространяется на локальный участок кода. Познакомились с примерами реализации главного принципа SOLID - принципа открытости/закрытости, напомню примеры его реализации, разобранные в публикации:

  1. Эмбендинг - библиотека и переиспользование методов;
  2. Передача простого "фильтра" в функцию;
  3. Передача набора простых "фильтров" в функцию;
  4. Передача функции по-значению в другую функцию;
  5. Вызов и выполнение скрипта из хранилища.
  6. Паттерн "Стратегия" - однотипное поведение для разных структур;
  7. Switch по значению карты;
  8. Пустой интерфейс, как параметр функции/метода без проверки его типа;
  9. Паттерн "Шаблонный метод".

Узнали, что есть паттерны проектирования - практики решения типовых задач, которые хорошо зарекомендовали себя в течение длительного времени. У меня гипотеза, что ещё ряд паттернов банды четырёх реализует принцип OCP. Но изучать это детальнее сейчас не буду, в планах - начать изучать Кафку, а далее вернуться к разбору других принципов SOLID.

Да, поделитесь, какими вы пользовались приёмами реализации принципа OCP.

На этом у меня всё, благодарю за то, что дочитали до конца. Успехов, и будем на связи.

https://ru.freepik.com/
https://ru.freepik.com/

Бро, ты уже здесь? 👉 Подпишись на канал для начинающих IT-специалистов «Войти в IT» в Telegram, будем изучать IT вместе 👨‍💻👩‍💻👨‍💻