Паттерн Композит

Паттерн Композит относится к структурным паттернам проектирования, в отечественных источниках он известен как “Компоновщик”.
Допустим мы разрабатываем приложение – фотоальбом. Пользователь может создавать папки, добавлять туда фото, производить прочие манипуляции. Обязательно нужна возможность показывать количество файлов в папках, общее количество всех файлов и папок.
Очевидно что нужно использовать дерево, но как реализовать архитектуру древа, с простым и удобным интерфейсом? На помощь приходит паттерн Композит.

Sheila in Moonducks

Далее в Directory реализуем метод dataCount() – путем прохода по всем элементам лежащим в массиве компонентов, сложив все их dataCount’s.
Все готово!
Ниже пример на Go:

package main

import "fmt"

type component interface {

dataCount() int

}

type file struct {

}

type directory struct {

c []component

}

func (f file) dataCount() int {

return 1

}

func (d directory) dataCount() int {

var outputDataCount int = 0

for _, v := range d.c {
outputDataCount += v.dataCount()
}

return outputDataCount

}

func (d *directory) addComponent(c component) {

d.c = append(d.c, c)

}

func main() {

var f file
var rd directory
rd.addComponent(f)
rd.addComponent(f)
rd.addComponent(f)
rd.addComponent(f)

fmt.Println(rd.dataCount())

var sd directory
sd.addComponent(f)

rd.addComponent(sd)
rd.addComponent(sd)
rd.addComponent(sd)

fmt.Println(sd.dataCount())
fmt.Println(rd.dataCount())

}

Источники

https://refactoring.guru/ru/design-patterns/composite

Паттерн Адаптер

Benjamín Núñez González

Паттерн Адаптер относится к структурным паттернам проектирования.

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

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

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

Есть лишь небольшая проблема – видеопоток закодирован в формате mpeg2, а наша система поддерживает только OGG Theora. Исходного кода системы у нас нет, единственное что мы можем делать – менять датасет и обучать нейросеть. Что же делать? Написать класс-адаптер, который будет переводит поток из mpeg2 -> OGG Theora и отдавать в нейросеть.

По классической схеме в паттерне участвуют client, target, adaptee и adapter. Клиент в данном случае это нейросеть, получающая видеопоток в OGG Theora, таргет – интерфейс с которым она взаимодействует, adaptee – интерфейс отдающий видеопоток в mpeg2, адаптер – конвертирует mpeg2 в OGG Theora и отдает по интерфейсу target.

Вроде все просто?

Источники

https://ru.wikipedia.org/wiki/Адаптер_(шаблон_проектирования)
https://refactoring.guru/ru/design-patterns/adapter

Паттерн Делегат

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



// псевдокод

class BarbershopScreen {
   let calendar: Calendar

   func showBarbersList(date: Date) {
      showSelectionSheet(barbers(forDate: date))
   }
}

class Calendar {
    let screen: BarbershopScreen

    func handleTap(on date: Date) {
        screen.showBarbersList(date: date)
    }
}

Через несколько дней требования меняются, перед выводом списка нужно показывать предложения с выбором услуг (стрижка бороды и т.п.) но не всегда, во все дни кроме субботы.
Добавляем в календарь проверку суббота сегодня или нет, в зависимости от нее вызываем метод списка барберов или списка услуг, для наглядности продемонстрирую:



// псевдокод

class BarbershopScreen {
   let calendar: Calendar

   func showBarbersList(date: Date) {
      showSelectionSheet(barbers(forDate: date))
   }

   func showOffersList() {
      showSelectionSheet(offers)
   }
}

class Calendar {
    let screen: BarbershopScreen

    func handleTap(on date: Date)  {
        if date.day != .saturday {
             screen.showOffersList()
        }
        else {
             screen.showBarbersList(date: date)
        }
    }
}

Через неделю нас просят добавить календарь на экран обратной связи, и в этот момент случается первое архитектурное ой!
Что же делать? Календарь ведь связан намертво с экраном записи на стрижку.
ух! уф! ой-ой
Если продолжить работать с такой бредовой архитектурой приложения, то следует сделать копию всего класса календаря и связать эту копию с экраном обратной связи.
Ок вроде хорошо, далее мы добавили еще несколько экранов и несколько копий календаря, и тут наступил момент икс. Нас попросили изменить дизайн календаря, тоесть теперь нужно найти все копии календаря и добавить одинаковые изменения во все. Такой “подход” очень сказывается на скорости разработки, увеличивает шанс допустить ошибку. В итоге такие проекты оказываются в состоянии разбитого корыта, когда уже даже автор оригинальной архитектуры не понимает как работают копии его классов, прочие хаки добавленные по пути разваливаются на лету.
Что же нужно было делать, а лучше что еще не поздно начать делать? Использовать паттерн делегирования!
Делегирование это способ передавать события класса через общий интерфейс. Далее пример делегата для календаря:


protocol CalendarDelegate {
   func calendar(_ calendar: Calendar, didSelect date: Date)
}

Теперь добавим код работу с делегатом в код примера:



// псевдокод

class BarbershopScreen: CalendarDelegate {
   let calendar: Calendar

   init() {
       calendar.delegate = self
   }

   func calendar(_ calendar: Calendar, didSelect date: Date) {
        if date.day != .saturday {
            showOffersList()
        }
        else {
             showBarbersList(date: date)
        }
   }

   func showBarbersList(date: Date) {
      showSelectionSheet(barbers(forDate: date))
   }

   func showOffersList() {
      showSelectionSheet(offers)
   }
}

class Calendar {
    weak var delegate: CalendarDelegate

    func handleTap(on date: Date)  {
        delegate?.calendar(self, didSelect: date)
    }
}

В итоге мы отвязали календарь от экрана совсем, при выборе даты из календаря он передает событие выбора даты – *делегирует* обработку события подписчику; подписчиком выступает экран.
Какие преимущества мы получаем в таком подходе? Теперь мы можем менять календарь и логику экранов независимо друг от друга, не дублируя классы, упрощая дальнейшую поддержку; таким образом реализуется “принцип единственной ответственности” реализации компонентов системы, соблюдается принцип DRY.
При использовании делегирования можно добавлять, менять логику вывода окошек, очередности чего угодно на экране и это совершенно не будет затрагивать календарь и прочие классы, которые объективно и не должны участвовать в несвязанных напрямую с ними процессами.
Альтернативно, не особо утруждающие себя программисты, используют отправку сообщений через общую шину, без написания отдельного протокола/интерфейса делегата, там где лучше было бы использовать делегирование. О недостатках такого подхода я написал в прошлой заметке – “Паттерн Наблюдатель”.

Источники

https://refactoring.guru/ru/replace-inheritance-with-delegation

Паттерн Наблюдатель

Паттерн Наблюдатель (Observer) относится к поведенческим паттернам проектирования.
Паттерн позволяет отправлять изменение состояния объекта подписчикам, с использованием общего интерфейса.
Допустим мы разрабатываем мессенджер для программистов, у нас в приложении есть экран чата. При получении сообщения с текстом “проблема” и “ошибка” или “что-то не так”, нужно красить экран списка ошибок и экран настроек в красный цвет.
Далее я опишу 2 варианта решения задачи, первый простой но крайне сложный в поддержке, и второй гораздо стабильнее в поддержке, но требует включать голову при начальной реализации.

Общая шина

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

  1. Объект отправляет абстрактное сообщение в общую шину
  2. Другой объект подписанный на общую шину, ловит сообщение и решает обработать его или нет.

Один из вариантов реализации доступный у Apple (подсистема NSNotificationCenter), добавлен матчинг заголовка сообщения на имя метода, который вызывается у получателя при доставке.
Самый большой минус такого подхода – при дальнейшем изменении сообщения нужно будет сначала вспомнить, а затем вручную отредактировать все места где оно обрабатывается, отправляется. Налицо случай быстрой первичной реализации, дальнейшей долгой, сложной поддержки, требующей базы знаний для корректной работы.

Мультикаст делегат

В данной реализации сделаем финальный класс мультикаст-делегата, на него также как и в случае с общей шиной могут подписываться объекты для получения “сообщений” или “событий”, однако на плечи объектов не возлагается работа по разбору и фильтрации сообщений. Вместо этого классы подписчиков должны имплементировать методы мультикаст делегата, с помощью которых он их нотифицирует.
Реализуется это с помощью использования интерфейсов/протоколов делегата, при изменении общего интерфейса приложение перестанет собираться, в этот момент нужно будет переделать все места обработки данного сообщения, без необходимости держать отдельную базу знаний для запоминания этих мест. Компилятор твой друг.
В данном подходе повышается производительность команды, так как отсутствует необходимость писать, хранить документацию, нет необходимости новому разработчику пытаться понять как происходит обработка сообщения, его аргументов, вместо этого происходит работа с удобным и понятным интерфейсом, так реализуется парадигма документирования через код.
Сам мультикаст-делегат основывается на паттерне делегат, о нем я напишу в следующей заметке.

Источники

https://refactoring.gu/ru/design-patterns/observer

Паттерн Прокси

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


class InternetRouter {

    private let internet: Internet

    init(internet: Internet) {
        self.internet = internet
    }

    func handle(request: Request, from client: Client) -> Data {
        return self.internet.handle(request)
    }

}

В приведенном выше коде, мы создаем класс интернет-роутера, с указателем на объект предоставляющий доступ в интернет. При обращении клиента с запросом сайта, мы возвращает ответ из интернета.

Используя паттерн Прокси и антипаттерн синглтон, добавим функционал логирования имени клиента и URL:


class InternetRouterProxy {

    private let internetRouter: InternetRouter

    init(internet: Internet) {
        self.internetRouter = InternetRouter(internet: internet)
    }

    func handle(request: Request, from client: Client) -> Data {

        Logger.shared.log(“Client name: \(client.name), requested URL: \(request.URL)”)

        return self.internetRouter.handle(request: request, from: client)
    }

}

Из-за сохранения оригинального интерфейса InternetRouter в классе прокси InternetRouterProxy, достаточно заменить класс инициализации с InternerRouter на его прокси, больше изменений в кодовой базе не потребуется.

Источники

https://refactoring.guru/ru/design-patterns/proxy

Паттерн Прототип

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


fun didPressOnCopyProfileButton() {
    let profileCopy = new Profile()
    profileCopy.name = generateRandomName()
    profileCopy.age = profile.age
    profileCopy.photos = profile.photos.randomize()
    storage.save(profileCopy)
}

Теперь представим что другие члены команды сделали copy-paste кода копирования или придумали его с нуля, и после этого добавилось новое поле – likes. В данном поле хранится количество лайков профиля, теперь нужно обновить *все* места где происходит копирование вручную, добавив новое поле. Это очень долго и сложно в поддержке кода, как и в тестировании.
Для решения этой проблемы придуман паттерн проектирования Прототип. Создадим общий протокол Copying, с методом copy() который возвращает копию объекта с необходимыми полями. После изменения полей сущности нужно будет обновить только один метод copy(), вместо того чтобы искать и обновлять вручную все места содержащие код копирования.

Источники

https://refactoring.guru/ru/design-patterns/prototype

Стейт машина и паттерн Состояние

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

MEAACT PHOTO / STUART PRICE.

Повелитель флажков

Допустим мы разрабатываем экран видеоплеер для медиасистемы гражданского самолета, плеер должен уметь загружать видео поток, проигрывать его, позволять пользователю останавливать процесс загрузки, перематывать и производить прочие операции обыкновенные для плеера.
Допустим плеер закэшировал очередной чанк видеопотока, проверил что чанков достаточно для воспроизведения, начал воспроизводить пользователю фрагмент и одновременно продолжает загрузку следующего.
В этот момент пользователь перематывает в середину ролика, тоесть теперь нужно остановить проигрывание текущего фрагмента, и начать загрузку с новой позиции. Однако существуют ситуации в которых такое делать нельзя – пользователь не может управлять проигрыванием видеопотока, пока ему показывают видеоролик о безопасности в воздухе. Заведем флажок isSafetyVideoPlaying для проверки данной ситуации.
Также система должна уметь ставить на паузу текущее видео и транслировать оповещение от капитана судна и экипажа через плеер. Заведем еще один флажок isAnnouncementPlaying. Плюс ко всему появилось требование не приостанавливать воспроизведение во время вывода справки по работе с плеером, еще один флажок isHelpPresenting.

Псевдокод примера медиаплеера:


class MediaPlayer {

    public var isHelpPresenting = false
    public var isCaching = false
    public var isMediaPlaying: Bool = false
    public var isAnnouncementPlaying = false
    public var isSafetyVideoPlaying = false

    public var currentMedia: Media = null

    fun play(media: Media) {

        if isMediaPlaying == false, isAnnouncementPlaying == false, isSafetyVideoPlaying == false {

            if isCaching == false {
                if isHelpPresenting == false {
                    media.playAfterHelpClosed()
                }
                else {
                    media.playAfterCaching()
                }
            }
    }

    fun pause() {
        if isAnnouncementPlaying == false, isSafetyVideoPlaying == false {
            currentMedia.pause()
        }
    }
}

Приведенный выше пример плохо читаем, такой код сложно поддерживать из-за большой вариативности (энтропии) Данный пример основывается на моем опыте работы с кодовой базой *многих* проектов, где не использовалась стейт-машина.
Каждый флажок должен по особенному “управлять” элементами интерфейса, бизнес-логики приложения, разработчик, добавляя очередной флажок, должен уметь жонглировать ими, проверяя и перепроверяя все по нескольку раз со всеми возможными вариантами.
Подставляем в формулу “2 ^ количество флажков” можно получить 2 ^ 6 = 64 варианта поведения приложения для всего 6 флажков, все эти сочетания флажков нужно будет проверять и поддерживать вручную.
Со стороны разработчика добавление нового функционала при такой системе выглядит так:
– Нужно добавить возможность показывать страницу браузера авиакомпании, при этом она должна сворачиваться как с фильмами, если члены экипажа что-то объявляют.
– Ок, сделаю. (Ох чёрт придется добавить еще один флаг, и перепроверить все места где пересекаются флажки, это же сколько всего надо поменять!)

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

Enter The Machine

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

Псевдокод:


enum MediaPlayerState {
	mediaPlaying,
	mediaCaching,
	crewSpeaking,
	safetyVideoPlaying,
	presentingHelp
}

class MediaPlayer {
	fun play(media: Media) {
		media.play()
	}

	func pause() {
		media.pause()
	}
}

class MediaPlayerStateMachine {
	public state: MediaPlayerState
	public mediaPlayer: MediaPlayer
	public currentMedia: Media

	//.. init (mediaPlayer) etc

	public fun set(state: MediaPlayerState) {
		switch state {
			case mediaPlaying:
				mediaPlayer.play(currentMedia)
			case mediaCaching, crewSpeaking,
			safetyVideoPlaying, presentingHelp:
				mediaPlayer.pause()
		}
	}
}

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

Паттерн Состояние

Далее я покажу разницу между наивной имплементацией стейт машины и паттерном состояние. Представим что понадобилось добавить 10 стейтов, в итоге класс стейт-машины разрастется до размеров godobject, его будет сложно и затратно поддерживать. Конечно данная реализация лучше флажковой, (при флажковой системе застрелится сначала разработчик, а если и нет, то увидев 2 ^ 10 = 1024 вариаций повесится QA, однако если они оба *не заметят* сложности задачи, то ее заметит пользователь, у которого приложение просто откажется работать при определенном сочетании флажков)
При большом количестве состояний необходимо использовать паттерн Состояния.
Вынесем свод правил в протокол Состояния:


protocol State {
    func playMedia(media: Media, context: MediaPlayerContext)
    func shouldCacheMedia(context: MediaPlayerContext)
    func crewSpeaking(context: MediaPlayerContext)
    func safetyVideoPlaying(context:MediaPlayerContext)
    func presentHelp(context: MediaPlayerContext)
}

Вынесем реализацию свода правил в отдельные состояния, для примера код одного состояния:


class CrewSpeakingState: State {
	func playMedia(context: MediaPlayerContext) {
		showWarning(“Can’ t play media - listen to announce!”)
	}

	func mediaCaching(context: MediaPlayerContext) {
		showActivityIndicator()
	}

	func crewSpeaking(context: MediaPlayerContext) {
		set(volume: 100)
	}

	func safetyVideoPlaying(context: MediaPlayerContext) {
		set(volume: 100)
	}

	func presentHelp(context: MediaPlayerContext) {
		showWarning(“Can’ t present help - listen to announce!”)
	}
}

Далее создадим контекст с которым будет работать каждый стейт, интегрируем стейт-машину:


final class MediaPlayerContext {
	private
	var state: State

	public fun set(state: State) {
		self.state = state
	}

	public fun play(media: Media) {
		state.play(media: media, context: this)
	}

	…
	остальные возможные события
}

Компоненты приложения работают с контекстом через публичные методы, объекты стейты сами решают из какого стейта в какой осуществлять переход с помощью стейт-машины внутри контекста.
Таким образом мы реализовали декомпозицию God Object, поддерживать изменяющееся состояние будет гораздо проще, благодаря отслеживанию изменений в протоколе компилятором, уменьшения сложности понимания состояний из-за уменьшения количества строк кода и фокусе на решении конкретной задачи состояния. Также теперь можно разделять работу в команде, отдавая реализацию конкретного состояния членам команды, не беспокоясь о необходимости “разруливать” конфликты, что происходит при работе с одним большим классом стейт-машины.

Источники

https://refactoring.guru/ru/design-patterns/state

Шаблонный метод

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

Cuban Cars

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


class MyPhoneSuperDuperSecretMyPhoneAuthorizationStorage {
    fun loginAndPassword() : Pair {
        return Pair("admin", "qwerty65435")
    }
}

class ServerApiClient {
    fun authorize(authorizationData: AuthorizationData) : Unit {
        println(authorizationData.login)
        println(authorizationData.password)
        println("Authorized")
    }
}

class AuthorizationData {
    var login: String? = null
    var password: String? = null
}

interface AuthorizationModule {
    abstract fun fetchAuthorizationData() : AuthorizationData
    abstract fun authorize(authorizationData: AuthorizationData)
}

class MyPhoneAuthorizationModule: AuthorizationModule {
    
    override fun fetchAuthorizationData() : AuthorizationData {
        val loginAndPassword = MyPhoneSuperDuperSecretMyPhoneAuthorizationStorage().loginAndPassword()
        val authorizationData = AuthorizationData()
        authorizationData.login = loginAndPassword.first
        authorizationData.password = loginAndPassword.second
        
        return authorizationData
    }
    
    override fun authorize(authorizationData: AuthorizationData) {
        ServerApiClient().authorize(authorizationData)
    }
    
}

fun main() {
    val authorizationModule = MyPhoneAuthorizationModule()
    val authorizationData = authorizationModule.fetchAuthorizationData()
    authorizationModule.authorize(authorizationData)
}

Теперь для каждого телефона/платформы нам придется дублировать код отправки авторизации на сервер, налицо нарушение принципа DRY. Приведенный выше пример очень прост, в более комплексных классах дублирования будет еще больше. Для устранения дублирования кода следует использовать паттерн Шаблонный метод.
Вынесем общие части модуля в неизменяемые методы, функционал передачи зашифрованных данных переложим на конкретные классы платформ:


class MyPhoneSuperDuperSecretMyPhoneAuthorizationStorage {
    fun loginAndPassword() : Pair {
        return Pair("admin", "qwerty65435")
    }
}

class ServerApiClient {
    fun authorize(authorizationData: AuthorizationData) : Unit {
        println(authorizationData.login)
        println(authorizationData.password)
        println("Authorized")
    }
}

class AuthorizationData {
    var login: String? = null
    var password: String? = null
}

interface AuthorizationModule {
    abstract fun fetchAuthorizationData() : AuthorizationData
    
    fun authorize(authorizationData: AuthorizationData) {
        ServerApiClient().authorize(authorizationData)
    }
}

class MyPhoneAuthorizationModule: AuthorizationModule {
    
    override fun fetchAuthorizationData() : AuthorizationData {
        val loginAndPassword = MyPhoneSuperDuperSecretMyPhoneAuthorizationStorage().loginAndPassword()
        val authorizationData = AuthorizationData()
        authorizationData.login = loginAndPassword.first
        authorizationData.password = loginAndPassword.second
        
        return authorizationData
    }
    
}

fun main() {
    val authorizationModule = MyPhoneAuthorizationModule()
    val authorizationData = authorizationModule.fetchAuthorizationData()
    authorizationModule.authorize(authorizationData)
}

Источники

https://refactoring.guru/ru/design-patterns/template-method

Исходный код

https://gitlab.com/demensdeum/patterns/