#20. Проектування. Патерни. SOLID.

Урок 21. Проектування. Патерни. SOLID.

План

  1. OOA --> OOD --> OOP
  2. Підхід до проектування
    1 . YAGNI
    2. KISS
    3. DRY
  3. SOLID
  4. Паттерни проектування
    1. Ітератор
    2. Декоратор
    3. Одинак
    4. Фасад
    5. Фабричний метод
  5. Література

OOA --> OOD --> OOP

OOA (об’єктно-орієнтований аналіз) - методологія, за якої вимоги до системи сприймаються з погляду класів і об’єктів, виявлених у предметній області.

ООД (об’єктно-орієнтований дизайн) - це методологія проектування,
що поєднує в собі процес об’єктної декомпозиції та прийоми представлення логічної та фізичної, а також статичної та динамічної моделей проектованої системи.


Як це працює.

ООА - це оцінка того, які проблема має вирішувати система, і які сутності у нас взагалі існують (припустимо, при проектуванні інтернет-магазину потрібно розуміти, що у нас будуть сутності користувача, товару, замовлення, і т. д.)

ООД - це проектування необхідних класів і того, як вони будуть взаємодіяти. (Розуміння, що замовлення буде здійснювати користувач, і при цьому замовлення може бути роздрібним і оптовим, і для того що б їх прорахувати нам необхідна різна логіка, і які патерни ми можемо застосувати (про це трохи пізніше))

ООП - У цій схемі це конкретна реалізація того, що було продумано на етапі ООД, ґрунтуючись на принципах ООП ( тобто сідаємо і пишемо код)

Приклад:
OOA – Об’єктно-орієнтований аналіз.
Менеджер привів замовника. Замовник хоче сферичного коня у вакуумі. Керівник бере тімліда, провідного програміста і всі троє з усіх сил намагаються зрозуміти, чого він хоче. Далі вже без замовника програміст сидить і доооовго думає, що це буде за звір, як же цей кінь виглядатиме, які частини (не плутати з класами) будуть у проекті.
Після виділення частин коня вирішено, що він складатиметься з трьох частин: сервер-служба, гуі-клієнт-налаштувач, веб-морда.

OOD – Об’єктно-орієнтований дизайн.
Тимліду доручено створити сервер і він починає думати у бік того, як буде влаштований сервер, малює одному йому зрозумілі стрілочки і продумує ієрархію елементів цього сервера та їхню взаємодію.

OOP – Об’єктно-орієнтоване програмування.
Сидить програміст. Вирішує, що клас ClientProcessor буде спадкоємцем того-то і того-то… Малює UML, показує тімлід. Киває – робить.

Підхід до проектування або OOD Principles

Передмова

Що таке принципи об’єктно-орієнтованого проектування (OOD Principles)?
Це рецепти, якими варто користуватися, якщо ви хочете писати більш-менш красиву архітектуру.
Навіщо використовувати OOD Principles?
Тому, що якщо не писати застосунок правильно, врешті-решт ви виявите, що втрачаєте час на підтримку старого коду, замість написання нового.
Побічні ефекти:

  • вас зненавидить начальник за те, що прибуток від застосунку стає
    дедалі меншим і кожна нова зміна - це головний біль
  • вас зненавидять інші розробники за те, що їм доводиться читати ваш
    незрозумілий код
  • ви зненавидите самого себе за те, що робота стала нудною й одноманітною.

Коли ви ознайомитеся з OOD Principles, ви вважатимете, що все зрозуміли і тепер ви можете писати ідеальний код. Однак практика показує, що насправді ви мало чого зрозумієте, просто прочитавши теорію, ви продовжуватимете писати жахливий код (можливо, навіть ще жахливіший, ніж він був до цього) ще півроку, а може, й рік, однак цей перехідний період важливий, тому що в цей час ви усвідомлюєте на своїх помилках справжній сенс цих принципів, а також розумієте, що потрібно мати міру, застосовуючи їх.
Зрештою ви досягнете повного їхнього розуміння і будете особливо не замислюючись писати красивий і високоякісний код.
Навіщо тоді вникати, якщо все одно не подіє? Тому що теорія в рази прискорює пізнання справи, без теорії ви будете ще довго бити собі лоб об бетонну стіну, і ще невідомо, що буде міцнішим. Так що вперед!

Незалежно від того, що ви розробляєте, завжди можна застосовувати наступні принципи:

YAGNI (You aren’t gonna need it - Вам це не знадобиться)
Процес і принцип проектування, при якому основною метою та цінністю є відмова від додавання функціональності, в якій немає безпосередньої потреби.

Бажання писати код, який не потрібний прямо зараз, але може знадобитися в майбутньому, призводить до таких небажаних наслідків:

  • Витрачається час, який було б витрачено на додавання, тестування і поліпшення необхідної функціональності.
  • Нова функціональність обмежує те, що може бути зроблено в майбутньому, тому непотрібна функціональність може згодом перешкодити додаванню нової потрібної функціональності.
  • Поки функціональність справді не потрібна, важко повністю передбачити, що вона має робити, і протестувати її. Якщо нова функціональність ретельно не протестована, вона може неправильно працювати, коли вона згодом знадобиться.
  • Це призводить до того, що програмне забезпечення стає складнішим.
  • Якщо вся функціональність не документована, вона може так і залишитися невідомою користувачам.
  • Додавання нової функціональності може призвести до бажання ще новішої, приводячи до ефекту снігової кулі.

KISS (Keep it simple, stupid, або Keep it short and simple - Роби коротше і простіше)
Процес і принцип проектування при якому простота системи декларується як основна мета та/або цінність. Базується на твердженні, що більшість систем працюють краще, якщо вони прості в користуванні. Виходячи з цього, простота повинна бути головною метою в області дизайну і потрібно намагатися уникати непотрібних складнощів під час проектування.
Якщо все можна описати двома класами, у яких 3 методи, то не треба описувати 10 класів із 30-ма методами.
Принцип, швидше за все, знаходить свої витоки в таких концепціях, як бритва Оккама (не треба робити більше припущень, ніж мінімально потрібно), «Простота — це найвища витонченість» Леонардо да Вінчі, «Менше означає більше» Людвіг Міс ван дер Рое, або «Схоже, що досконалість досягається не тоді, коли немає, що додати, а тоді коли немає що відняти» Антуан де Сент-Екзюпері.

DRY (Don’t repeat yourself - Не повторюйся)
Принцип розробки програмного забезпечення, що направлений на уникнення дублювання інформації будь-якого вигляду (наприклад, програмний код чи текст інтерфейсу користувача).
«Будь-яка інформація повинна мати єдине, однозначне, авторитетне представлення в системі».
Правильне використання DRY дозволяє розробникам робити атомарні зміни в системі, коли модифікація одного елементу системи не вимагає модифікації сторонніх елементів. Ті ж елементи, що мають логічне відношення до модифікованого, змінюються прогнозовано та одноманітно.
Тобто, якщо ти використовуєш один і той самий код у різних місцях зроби з нього функцію або метод)
Порушення принципу DRY жартома називають WET — «write everything twice» («пиши все по два рази») та «ми любимо друкувати» (we enjoy typing). Це гра англійських слів «dry» (укр. «сухий») і «wet» (укр. «вологий, мокрий»). Загалом, дублювання коду може здійснюватися не тільки двічі, але й набагато більше разів, тому порушення DRY перетворюється на «write everything ten thousand times» («пиши все десять тисяч разів»).

SOLID


SOLID - це абревіатура (акронім), складена Майклом Фезерсом з перших літер п’яти базових принципів об’єктно-орієнтованого програмування та дизайну, яка була запропонована Робертом Мартіном у статті 2000 року (Design Principles and Design Patterns)
Ці принципи - частина загальної стратегії гнучкої та адаптивної розробки, їхнє дотримання полегшує розширення та підтримку проєкту.

SOLID принципи радять, як проектувати модулі, тобто цеглинки, з яких будується проект. Мета принципів - проектувати модулі, які:

  • легко доповнювати, розширювати, підтримувати
  • легко читатати і розуміти
  • можуть бути повторно використані

Принципи

S - Single Responsibility Principle, SRP (Принцип єдиної відповідальності)

Клас має бути створений для виконання лише однієї задачі, яку він повинен повністю інкапсулювати. Усі його поведінки мають бути спрямовані виключно на забезпечення цієї відповідальності.

Роберт Мартін, засновник терміну, висловлює принцип так: «Клас повинен мати лише одну причину для змін»

Наприклад, уявіть собі клас, який складає та друкує звіт. Такий клас може змінитися з двох причин:

  • може змінитися сам вміст звіту
  • може змінитися формат звіту.

Логічно, що обидва аспекти цих причин насправді є двома різними відповідальностями. SRP каже, що у такому разі потрібно розділити клас на два нових класи, для яких буде характерна лише одна відповідальність. Причина, чому потрібно зберігати спрямованість класів єдину мету у цьому, що робить класи здоровішими. Що стосується класу, наведеного вище, якщо відбулася зміна в процесі складання звіту - є велика ймовірність, що в непридатність прийде код, який відповідає за друк.

При розробці різних поведінок одного класу часто з’являється God Object (Божественний об’єкт), який в ООП вважається антипаттерном. Дотримання принципу єдиної відповідальності дозволяє уникати цього антипаттерну.

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

Приклад з порушенням:

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

    def save(self):
        # Збереження користувача у базі даних

    def send_email(self, subject, message):
        # Відправка електронного листа користувачу

У цьому прикладі клас User має дві відповідальності: збереження користувача у базі даних і відправка електронного листа. Це порушення принципу єдиної відповідальності, оскільки клас повинен мати лише одну причину для зміни.
Рефакторинг:

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

class UserRepository:
    def save(self, user):
        # Збереження користувача у базі даних

class EmailService:
    def send_email(self, user, subject, message):
        # Відправка електронного листа користувачу

У рефакторингу ми розділили відповідальності на два окремих класи. Клас User тепер відповідає тільки за модель користувача, а UserRepository відповідає за збереження користувачів у базі даних. Клас EmailService відповідає за відправку електронних листів. Це поліпшення забезпечує більшу чіткість і розділення відповідальностей.

O - Open Closed Principle, OCP (Принцип відкритості/закритості)

Програмні сутності, такі як класи, модулі, функції, методи та ін. повинні бути відкритими для розширення, але закритими для змін. Це означає, що коли вам потрібно внести зміни в функціональність програми, ви повинні розширювати існуючий код, а не змінювати його. Це дозволяє зберігати старий код незмінним, зменшує ризик появи помилок та спрощує розширення функціональності.
Старе формулювання: потрібно вміти розширювати поведінку класів, не модифікуючи її.

Приклад з порушенням:

class Shape:
    def __init__(self, name, type):
        self.name = name
        self.type = type

    def calculate_area(self):
        if self.type == "Rectangle":
            # Обчислення площі прямокутника
        elif self.type == "Circle":
            # Обчислення площі кола

У цьому прикладі клас Shape має метод calculate_area(), який використовує умовний оператор для визначення типу фігури і обчислення площі. Це порушує принцип відкритості/закритості, оскільки при додаванні нового типу фігури потрібно модифікувати існуючий код.

Рефакторинг:

class Shape:
    def __init__(self, name):
        self.name = name

    def calculate_area(self):
        pass

class Rectangle(Shape):
    def __init__(self, name, width, height):
        super().__init__(name)
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

class Circle(Shape):
    def __init__(self, name, radius):
        super().__init__(name)
        self.radius = radius

    def calculate_area(self):
        return 3.14 * self.radius**2

У рефакторингу ми використали успадкування, де кожен тип фігури (прямокутник, коло) є окремим класом, який успадковує базовий клас Shape. Кожен клас реалізує свій власний метод calculate_area(), специфічний для типу фігури.

Тепер, коли нам потрібно додати новий тип фігури, наприклад, трикутник, ми просто створюємо новий клас Triangle, який успадковує Shape і реалізує метод calculate_area() для обчислення площі трикутника. Ми не змінюємо код існуючих класів, що дозволяє нам дотримуватися принципу відкритості/закритості.

L - Liskov Substitution Principle, LSP (Принцип підстановки Лісков)

Об’єкти в програмі можуть бути заміненими їхніми нащадками без зміни коду програми.

Принцип підстановки Барбари Лісков стверджує, що об’єкти певного класу повинні бути замінюваними об’єктами своїх підкласів без впливу на коректність програми. Це означає, що якщо клас B є підтипом класу A, то об’єкти класу A можуть бути замінені об’єктами класу B без жодних небажаних наслідків.

Приклад з порушенням:

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

    def get_area(self):
        return self.width * self.height

class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)

    def set_width(self, width):
        self.width = width
        self.height = width

    def set_height(self, height):
        self.width = height
        self.height = height

У цьому прикладі клас Square успадковує клас Rectangle, оскільки квадрат є підтипом прямокутника. Однак, методи set_width() та set_height() в класі Square змінюють поведінку класу, оскільки при виклику цих методів для квадрата змінюється не тільки одна сторона, але і друга. Це порушує принцип підстановки Лісков, оскільки нам очікувалося, що квадрат поводитиметься так само, як і прямокутник, але це не так.

Рефакторинг:

class Shape:
    def get_area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

    def get_area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def set_side(self, side):
        self.side = side

    def get_area(self):
        return self.side ** 2

У цьому рефакторингу ми створюємо базовий клас Shape, який має метод get_area(). Класи Rectangle і Square успадковують від Shape і реалізують свої власні методи get_area().

Тепер обидва класи поводяться коректно. Методи set_width() та set_height() в класі Rectangle змінюють відповідно ширину і висоту, тоді як метод set_side() в класі Square змінює лише сторону. Кожен клас поводиться згідно своїх очікувань і не порушує принцип підстановки Лісков.

Такий рефакторинг дозволяє коректно використовувати класи Rectangle і Square в коді без несподіваних наслідків або неочікуваної зміни поведінки.

I - Interface Segregation Principle, ISP (Принцип розділення інтерфейсу)

Багато спеціалізованих інтерфейсів краще за один універсальний. Інтерфейс може бути поділений на спеціалізовані ще на стадії проєктування, заради майбутньої гнучкості програмних компонентів.

Прикладни́й програ́мний інтерфе́йс (Application Programming Interface, API)— це набір чітко визначених методів для взаємодії різних компонентів.

Старе визначеня:
Клієнти не повинні залежати від методів, які вони не використовують.

Кожен клієнт повинен мати доступ лише до тих частин інтерфейсу, які йому потрібні, а не до надлишкових методів або властивостей.
Даний принцип означає, що занадто «товсті» інтерфейси необхідно розділяти на менші та специфічні, щоб їх клієнти знали лише про ті методи, що необхідні для них у роботі. Як результат, при зміні певного функціоналу, незмінними мають лишитися ті класи, що не використовують його. Тобто виконання цього принципу допомагає системі залишатися гнучкою при внесенні до неї змін та лишатися простою для рефакторингу.

Серед плюсів варто відзначити наступні:

  • при необхідності створення нової реалізації інтерфейсу немає потреби реалізовувати непотрібні методи;
  • клієнтський код отримує лише те, що потрібне для його роботи.

Мінус використання полягає в зростанні кількості інтерфейсів, що приводить до зростання складності системи.

Приклад з порушенням:

class Machine:
    def print(self, document):
        pass

    def scan(self, document):
        pass

    def fax(self, document):
        pass

У цьому прикладі клас Machine має методи print(), fax() та scan(). Проте, не всі клієнти, які використовують цей клас, потребують всіх цих методів. Наприклад, якщо клієнту потрібно тільки друкувати документи, він все одно має доступ до непотрібних методів fax() та scan(). Це порушує принцип розділення інтерфейсу, оскільки клієнтам надаються методи, які вони не використовують.
Рефакторинг:

class Printer:
    def print(self, document):
        pass

class FaxMachine:
    def fax(self, document):
        pass

class Scanner:
    def scan(self, document):
        pass

У рефакторингу ми розділили оригінальний клас Machine на окремі класи Printer, FaxMachine та Scanner, кожен з яких має лише один метод, відповідний їх функціональності. Тепер клієнти можуть використовувати тільки той клас, який відповідає їх потребам. Наприклад, клієнт, якому потрібно лише друкувати документи, може використовувати клас Printer, а інші методи, такі як fax() та scan(), для нього недоступні.

D - Dependency Inversion Principle, DIP (Принцип інверсії залежностей)

  • Модулі вищого рівня не повинні залежати від модулів нижчого рівня. Обидва типи модулів повинні залежати від абстракцій.
  • Абстракції не повинні залежати від деталей реалізації. Деталі реалізації повинні залежати від абстракцій.

Що таке модулі верхніх рівнів? Як визначити цей рівень? Що ближче модуль до вводу/виводу, то нижчий рівень модуля. Тобто модулі, що працюють із DB, інтерфейсом користувача, низького рівня. А модулі, що реалізують бізнес-логіку - високого рівня.

Що таке залежність модулів? Це посилання на модуль у вихідному коді, тобто import, require тощо.

Принцип інверсії залежностей стверджує, що класи повинні залежати від абстракцій, а не від конкретних реалізацій. Замість того, щоб програмувати залежності безпосередньо в класах, ми повинні використовувати абстракції (інтерфейси або абстрактні класи), які можна замінити різними реалізаціями.

Приклад з порушенням:

class WeatherService:
    def get_temperature(self):
        # Код для отримання температури з сервісу

class WeatherReporter:
    def __init__(self):
        self.weather_service = WeatherService()

    def report_weather(self):
        temperature = self.weather_service.get_temperature()
        # Код для звітування про погоду

У цьому прикладі клас WeatherReporter прямо залежить від конкретної реалізації WeatherService. Це порушує принцип інверсії залежностей, оскільки клас WeatherReporter має жорстку залежність від WeatherService, що робить його складним у використанні та тестуванні.

Рефакторинг:

class WeatherService:
    def get_temperature(self):
        # Код для отримання температури з сервісу

class WeatherReporter:
    def __init__(self, weather_service):
        self.weather_service = weather_service

    def report_weather(self):
        temperature = self.weather_service.get_temperature()
        # Код для звітування про погоду

У цьому рефакторингу ми впроваджуємо принцип інверсії залежностей, передаючи залежність WeatherService у конструктор WeatherReporter в якості аргументу. Тепер WeatherReporter залежить від абстракції WeatherService, а не від конкретної реалізації. Це робить клас WeatherReporter більш гнучким, легким у використанні та тестуванні.

  • Принцип єдиної відповідальності (Single responsibility)

“На кожен об’єкт має бути покладено один єдиний обов’язок” Для цього перевіряємо, скільки у нас є причин для зміни класу - якщо більше однієї, то слід розбити цей клас.

  • Принцип відкритості/закритості (Open/closed)

“Програмні сутності мають бути відкриті для розширення, але закриті для модифікації”

  • Принцип підстановки Лісков (Liskov substitution)

“Об’єкти в програмі можуть бути замінені їхніми спадкоємцями без зміни властивостей програми” Для цього перевіряємо, чи не чи посилили ми передумови і чи не послабили постумови. Якщо це сталося - то принцип не дотримується

  • Принцип розділення інтерфейсу (Interface segregation)

“Багато спеціалізованих інтерфейсів краще, ніж один універсальний” Перевіряємо, наскільки багато інтерфейс містить методів і наскільки різні функції накладаються на ці методи, і якщо необхідно - розбиваємо інтерфейси.

  • Принцип інверсії залежностей (Dependency Invertion)

“Залежності мають будуватися щодо абстракцій, а не деталей” Перевіряємо, чи залежать класи від якихось інших класів (безпосередньо інстанціюють об’єкти інших класів тощо) і якщо ця залежність має місце, замінюємо на залежність від абстракції.

Патерни проектування

Насправді, патерн це просто будь-яка шаблонна конструкція, яку можна використовувати кілька разів. І ви навіть знаєте кілька патернів, тільки не знаєте, що це патерни :)

Наприклад, декоратор, ітератор.

Патернів існує просто величезна кількість, настільки величезна, що існують сотні книжок з патернів проектування. Тут можна подивитися на багато з них, але далеко не на всі.

Що таке Паттерн?

Патерн проектування - це рішення певної проблеми під час проектування архітектури програм, яке часто зустрічається.

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

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

Якщо навести аналогії, то алгоритм - це кулінарний рецепт із чіткими кроками, а патерн - інженерне креслення, на якому намальовано рішення, але не конкретні кроки його реалізації.

З чого складається патерн?

Описи патернів зазвичай дуже формальні й найчастіше складаються з таких пунктів:

  • проблема, яку вирішує патерн;
  • мотивації до розв’язання проблеми у спосіб, який пропонує патерн;
  • структури класів, що складають рішення;
  • прикладу однією з мов програмування;
  • особливостей реалізації в різних контекстах;
  • зв’язків з іншими патернами.

Такий формалізм в описі дав змогу створити великий каталог патернів, перевіривши кожен із них на спроможність.

Навіщо знати патерни?

Ви можете цілком успішно працювати, не знаючи жодного патерну. Ба більше, ви могли вже не раз реалізувати якийсь із патернів, навіть не підозрюючи про це.

Але усвідомлене володіння інструментом якраз і відрізняє професіонала від любителя. Ви можете забити цвях молотком, а можете і мікроскопом, якщо сильно постараєтеся. Але професіонал знає, що головна фішка мікроскопа зовсім не в цьому. Отже, навіщо же знати патерни?

  • Перевірені рішення. Ви витрачаєте менше часу, використовуючи готові рішення, замість повторного винаходу велосипеда.
    До деяких рішень ви змогли б додуматися і самі, але багато хто може бути для вас відкриттям.

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

  • Загальний програмістський словник. Ви вимовляєте назву патерну, замість того, щоб годину пояснювати іншим програмістам, який крутий дизайн ви придумали і які класи для цього потрібні.

Класифікація патернів


Патерни відрізняються за рівнем складності, деталізації та охоплення проектованої системи. Проводячи аналогію з будівництвом, ви можете підвищити безпеку на перехресті, встановивши світлофор, а можете замінити перехрестя цілою автомобільною розв’язкою з підземними переходами.

Найбільш низькорівневі та прості патерни — ідіоми. Вони не дуже універсальні, так як мають сенс лише в рамках однієї мови програмування.

Найбільш універсальні — архітектурні патерни, які можна реалізувати практично будь-якою мовою. Вони потрібні для проектування всієї програми, а не окремих її елементів.

Крім цього, патерни відрізняються і за призначенням.

  • Породжуючі патерни або Твірні шаблони (Creational Patterns) піклуються про гнучке створення об’єктів без внесення в програму зайвих залежностей.

  • Структурні патерни (Structural Patterns) показують різні способи побудови зв’язків між об’єктами.

  • Поведінкові патерни або Шаблони поведінки (Behavioral Patterns) піклуються про ефективну комунікацію між об’єктами.

refactoring.guru - шикарний сайт з описом патернів та їх реалізацією різними мовами програмування.

Трішки історії

Патерни програмування збиралися впродовж багатьох років і різними розробниками. Але зібрали їх у книзі так звана Банда чотирьох (Gang of Four, GoF)
Це група авторів книги “Design Patterns: Elements of Reusable Object-Oriented Software” (Шаблони проєктування: Елементи повторно використовуваного об’єктно-орієнтованого програмного забезпечення), що була видана в 1994 році. Ця книга стала впливовим джерелом знань про патерни проектування і визнана класикою в області програмного проектування.

Банда чотирьох складалася з наступних авторів:

  1. Еріх Гамма (Erich Gamma): Він працював в IBM Research і займався розробкою методик та інструментів для об’єктно-орієнтованого програмування.

  2. Річард Гелм (Richard Helm): Він також працював в IBM Research та спеціалізувався на розробці засобів для програмування.

  3. Ральф Джонсон (Ralph Johnson): Він працював в Університеті штату Іллінойс в Урбана-Шампейн і мав досвід в області об’єктно-орієнтованого програмування та рефакторингу.

  4. Джон Вліссідес (John Vlissides): Він працював в IBM Research і мав досвід у проектуванні об’єктно-орієнтованих систем та інструментів.

У своїй книзі “Design Patterns”, Банда чотирьох описала 23 різних патерни проектування, які стали основою для розвитку програмного проектування та об’єктно-орієнтованого програмування. Ці патерни охоплюють різні аспекти проектування програмного забезпечення, включаючи створення об’єктів, взаємодію об’єктів, структуризацію системи та багато іншого.

Банда чотирьох має великий вплив на галузь програмного проектування, і їхні патерни є важливими засобами для розробників у створенні ефективного та гнучкого програмного коду.

Розглянемо деякі патерни

Ітератор (Iterator)

Ітератор — це поведінковий патерн проектування, що дає змогу послідовно обходити елементи колекції, не розкриваючи їхньої внутрішньої структури.

Приклад реалізації:

class Iterator:
    def __init__(self, collection):
        self.collection = collection
        self.index = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.index < len(self.collection):
            item = self.collection[self.index]
            self.index += 1
            return item
        raise StopIteration

# Приклад використання

numbers = [1, 2, 3, 4, 5]
iterator = Iterator(numbers)

for number in iterator:
    print(number)

У цьому прикладі ми створили клас Iterator, який приймає колекцію в конструкторі. Він реалізує методи __iter__ та __next__, що дозволяють об’єкту бути ітератором.

Метод __iter__ повертає сам ітератор, а метод __next__ повертає наступний елемент колекції на кожному виклику, доки не буде досягнутий кінець колекції, у якому випадку виникає виключення StopIteration.

У прикладі ми створюємо колекцію чисел [1, 2, 3, 4, 5] і створюємо об’єкт ітератора для цієї колекції. Потім ми можемо використовувати цей ітератор для ітерації через елементи колекції за допомогою циклу for, отримуючи кожен елемент на кожній ітерації.

Цей патерн дозволяє нам ізолювати логіку ітерації від самої колекції, що робить код більш модульним і зручним у використанні. Крім того, цей патерн дозволяє нам мати кілька активних ітераторів для однієї колекції, кожен з яких може знаходитись у власному стані ітерації.

Декоратор (Decorator)

Декоратор — це структурний патерн проектування, що дає змогу динамічно додавати об’єктам нову функціональність, загортаючи їх у корисні «обгортки».

Приклад реалізації:

class Component:
    def operation(self):
        pass

class ConcreteComponent(Component):
    def operation(self):
        print("Виконано основну операцію")

class Decorator(Component):
    def __init__(self, component):
        self.component = component

    def operation(self):
        self.component.operation()

class ConcreteDecoratorA(Decorator):
    def operation(self):
        super().operation()
        self.additional_operation()

    def additional_operation(self):
        print("Виконано додаткову операцію А")

class ConcreteDecoratorB(Decorator):
    def operation(self):
        super().operation()
        self.additional_operation()

    def additional_operation(self):
        print("Виконано додаткову операцію В")

# Приклад використання

component = ConcreteComponent()
decorated_component = ConcreteDecoratorA(ConcreteDecoratorB(component))
decorated_component.operation()

У цьому прикладі ми маємо основний клас Component, який визначає базовий інтерфейс для об’єктів, які можна декорувати. Клас ConcreteComponent представляє конкретний об’єкт, який ми хочемо декорувати.

Клас Decorator є базовим класом для всіх декораторів. Він приймає посилання на компонент, який декорується, і має той самий інтерфейс, що і компонент. У методі operation він спочатку виконує операцію компонента, а потім додає свою власну додаткову функціональність.

Класи ConcreteDecoratorA і ConcreteDecoratorB є конкретними реалізаціями декораторів. Вони успадковуються від Decorator і розширюють його функціональність, додаючи власну додаткову операцію.

У прикладі ми створюємо об’єкт ConcreteComponent, який представляє об’єкт, який буде декорований. Потім ми обгортаємо його в об’єкт ConcreteDecoratorB, а потім в об’єкт ConcreteDecoratorA. Кожен декоратор додає свою власну додаткову операцію до основної операції компонента.

При виклику методу operation на останньому об’єкті декоратора decorated_component, виконується послідовність операцій: спочатку виконується основна операція компонента (ConcreteComponent), а потім виконуються додаткові операції декораторів ConcreteDecoratorB і ConcreteDecoratorA. Це дозволяє нам динамічно додавати нові функціональність до об’єкту, не змінюючи його базової реалізації.

Патерн декоратор дозволяє гнучко розширювати функціональність об’єктів і додає багатошаровість до системи. Кожен декоратор може додавати свою унікальну функціональність без втручання у роботу інших декораторів або базового об’єкта. Це сприяє підтримці принципу відкритості для розширення і закритості для модифікації (Open-Closed Principle) в проекті.

Одинак (Singleton)

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

Приклад реалізації:

class Singleton:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            cls._instance = super().__new__(cls, *args, **kwargs)
        return cls._instance

# Приклад використання

obj1 = Singleton()
obj2 = Singleton()

print(obj1 is obj2)  # Виведе: True

У цьому прикладі клас Singleton має приватне статичне поле _instance, яке зберігає єдиний екземпляр класу.

Метод __new__ перевіряє, чи вже існує екземпляр класу. Якщо такого екземпляра немає, то він створює новий за допомогою super().__new__ і зберігає його в _instance. У наступних викликах конструктора __new__ повертає збережений екземпляр, забезпечуючи, що буде створено лише один екземпляр класу.

У прикладі ми створюємо два об’єкти obj1 і obj2 класу Singleton. Виклик obj1 is obj2 порівнює обидва об’єкти і повертає True, що свідчить про те, що вони посилаються на один і той самий екземпляр класу. Це підтверджує коректну реалізацію патерну Singleton.

Патерн Singleton корисний в ситуаціях, коли потрібен лише один екземпляр класу, наприклад, для кешування даних, керування доступом до ресурсів або глобальних налаштувань. Він забезпечує централізований доступ до цього екземпляра, уникнення зайвого створення об’єктів та забезпечення однозначності даних.

Фасад (Facade)

Фасад — це структурний патерн проектування, який надає простий (але урізаний) інтерфейс до складної системи класів, бібліотеки або фреймворку.

Приклад реалізації :

# Складна підсистема

class SubsystemA:
    def operationA(self):
        print("Виконано операцію A")

class SubsystemB:
    def operationB(self):
        print("Виконано операцію B")

class SubsystemC:
    def operationC(self):
        print("Виконано операцію C")

# Фасад

class Facade:
    def __init__(self):
        self.subsystemA = SubsystemA()
        self.subsystemB = SubsystemB()
        self.subsystemC = SubsystemC()

    def operation(self):
        self.subsystemA.operationA()
        self.subsystemB.operationB()
        self.subsystemC.operationC()

# Приклад використання

facade = Facade()
facade.operation()

У цьому прикладі ми маємо складну підсистему, яка складається з класів SubsystemA, SubsystemB і SubsystemC. Кожен з цих класів виконує свою власну операцію.

Клас Facade є фасадом і надає спрощений інтерфейс до цієї складної підсистеми. Він ініціалізує об’єкти підсистеми в своєму конструкторі і надає метод operation, який викликає потрібні операції підсистеми в правильній послідовності.

У прикладі ми створюємо об’єкт фасаду facade і викликаємо метод operation. Фасад приховує всю складність підсистеми і надає простий спосіб виконання необхідних операцій.

Патерн Facade дозволяє спростити взаємодію зі складною системою або підсистемою, надаючи єдиний точковий вхід і приховуючи деталі роботи. Він полегшує розробку, підтримку і розширення системи, зменшуючи залежність від підсистеми та забезпечуючи високий рівень модульності і перевикористання коду. Крім того, він полегшує тестування, оскільки тестування фасаду вимагає меншої кількості тестових сценаріїв, ніж тестування кожного компонента підсистеми окремо.

Патерн Facade може бути корисним, коли вам потрібно спростити складний API або підсистему, коли ви працюєте зі сторонньою бібліотекою або коли ви хочете створити єдину точку доступу до підсистеми для зовнішніх клієнтів.

Наприклад, уявіть, що у вас є складна підсистема для обробки зображень, яка включає декодер, ефекти, фільтри і компресори. Клієнти, які хочуть просто обробити зображення, можуть використовувати фасад, який надає єдиний метод для обробки зображень, приховуючи всю складність підсистеми.

Таким чином, патерн Facade допомагає зменшити складність системи, полегшує її використання та сприяє підтримці принципу розділення інтерфейсу від реалізації.

Фабричний метод (Factory Method)

Фабричний метод — це породжувальний патерн проектування, який визначає загальний інтерфейс для створення об’єктів у суперкласі, дозволяючи підкласам змінювати тип створюваних об’єктів.

Приклад реалізації :

# Абстрактний клас Creator

class Creator:
    def create_product(self):
        pass

    def some_operation(self):
        product = self.create_product()
        result = f"Creator: Використання {product.operation()}"
        return result


# Конкретні класи Creator

class ConcreteCreator1(Creator):
    def create_product(self):
        return ConcreteProduct1()


class ConcreteCreator2(Creator):
    def create_product(self):
        return ConcreteProduct2()


# Абстрактний клас Product

class Product:
    def operation(self):
        pass


# Конкретні класи Product

class ConcreteProduct1(Product):
    def operation(self):
        return "ConcreteProduct1"


class ConcreteProduct2(Product):
    def operation(self):
        return "ConcreteProduct2"


# Приклад використання

creator = ConcreteCreator1()
result = creator.some_operation()
print(result)  # Виведе: "Creator: Використання ConcreteProduct1"

creator = ConcreteCreator2()
result = creator.some_operation()
print(result)  # Виведе: "Creator: Використання ConcreteProduct2"

У цьому прикладі ми маємо абстрактний клас Creator, який містить абстрактний метод create_product() для створення об’єктів типу Product. Конкретні підкласи ConcreteCreator1 і ConcreteCreator2 реалізують цей метод і повертають відповідні конкретні об’єкти ConcreteProduct1 і ConcreteProduct2.

Абстрактний клас Product містить абстрактний метод operation(), який повертає результат операції. Конкретні підкласи ConcreteProduct1 і ConcreteProduct2 реалізують цей метод і повертають відповідні результати.

У прикладі ми створюємо об’єкт creator класу ConcreteCreator1 і викликаємо його метод some_operation(), який викликає метод create_product() для створення конкретного продукту. Результат виконання методу operation() конкретного продукту потім використовується для побудови результату в методі some_operation() класу Creator.

Потім ми виводимо результат виклику методу some_operation() для об’єкта creator типу ConcreteCreator1. Це призведе до створення об’єкта ConcreteProduct1 і виконання його методу operation(), який поверне рядок "ConcreteProduct1". Результат буде виведений як "Creator: Використання ConcreteProduct1".

Аналогічно, ми можемо створити об’єкт creator типу ConcreteCreator2 і отримати результат "Creator: Використання ConcreteProduct2", оскільки буде створено об’єкт ConcreteProduct2 і виконано його метод operation().

Патерн Factory Method дозволяє визначати загальний інтерфейс для створення об’єктів, але відкладає вибір конкретного типу об’єкта підкласам. Це дозволяє зручно розширювати систему, додаючи нові підкласи Creator і Product без змін в існуючому коді.

Домашнє завдання:

  1. Створити клас Writer, додати метод write, який приймає строку і додає її у файл.
  2. Створити клас Logger, додати метод write, який приймає обʼєкт класу Exception і записує у файл за допомогою Writer у такому форматі
    Номер \t дата час \t Клас помилки \t текст помилки

1 2023-11-25 18:10:01 NameError name ‘a’ is not defined
2 2023-11-25 18:15:01 AttributeError ‘str’ object has no attribute ‘c’

  1. Створити декоратор logger, який у разі помилки при виконанні оригінальної функції, записує помилку через Logger.
  2. Задекорувати методи validate_email та generate_candidates.

Література

  1. SOLID Principles: Improve Object-Oriented Design in Python
  2. Principle of Software Development Principles
  3. SOLID на Wiki
  4. Метод datetime.strftime() в Python