GoF Factories: чи можна Абстрактну фабрику замінити набором Фабричних методів
Привіт! Мене звати Олександр, я Senior C++ Engineer в компанії GlobalLogic. Мій загальний комерційний досвід роботи на позиції інженера-програміста складає близько 9 років. Крім основної діяльності розробника я понад чотири роки викладаю курси «Алгоритми та структури даних» та «GoF Design Patterns» в межах програми GlobalLogic Education.
У цій статті я хочу розказати про GoF-фабрики, а саме — про патерни Factory Method (Фабричний метод) та Abstract Factory (Абстрактна фабрика), а також подивитися на них із дещо незвичного ракурсу, відповівши на питання: «Чи можна Абстрактну фабрику замінити набором Фабричних методів?».
Коротко про GOF
У 1994 році Еріх Ґамма (Erich Gamma), Річард Гелм (Richard Helm), Ральф Джонсон (Ralph Johnson) та Джон Вліссідес (John Vlissides) опублікували книгу «Design Patterns: Elements of Reusable Object-Oriented Software» («Патерни проєктування: елементи повторно використовуваного об’єктноорієнтованого програмного забезпечення»). Ці патерни надають перевірені рішення для розповсюджених задач проєктування програмного забезпечення та допомагають зробити систему більш модульною, гнучкою та підтримуваною. У книзі описано 23 патерни, які було розділено на три групи: породжувальні, структурні та поведінкові.
Знаходимо Фабрики
Фабричний метод та Абстрактна фабрика належать до групи породжувальних патернів.
Загалом, породжувальні патерни — це група патернів проєктування, які розв’язують проблеми створення об’єктів. Їхніми ключовими особливостями є:
- інкапсуляція створення об’єктів;
- забезпечення гнучкості та розширюваності;
- зменшення зв’язності між клієнтським кодом та конкретними класами;
- повторне використання коду.
Фабричний метод та Абстрактна фабрика на прикладі Civilization-like стратегії
Далі пропоную розглянути реалізації Фабричного методу та Абстрактної фабрики на цікавому прикладі. Уявімо, що ми розробляємо Civilization-like стратегію*. Гравець керує розвитком своєї віртуальної цивілізації від доісторичних часів до сучасності та майбутнього, взаємодіючи з іншими суперниками, використовуючи різноманітні стратегії та тактики для перемоги (військова, культурна, наукова тощо). Від епохи до епохи розвитку зазнають (звичайно, із внутрішньо ігровими умовностями) бойові юніти, культурні та наукові будівлі тощо.
Забігаючи наперед, код, представлений у статті, написано мовою С++. Однак я опускатиму деякі формальні та специфічні моменти для того, щоб зробити його простішим і зрозумілим для розробників на інших об’єктно-орієнтованих мовах програмування. Пам’ятайте, приклад будь-якого патерну — це не готове рішення для вашої конкретної задачі. Це відображення принципів та ідей, які потрібно кастомізувати під свою потребу.
Будемо поступово розширювати гру, розв’язуючи задачу саме створення об’єктів в системі. Почнемо з мінімалістичного функціоналу. Для початку, є лише 2 ери: Давні часи (Ancient) та Середньовіччя (Medieval). Також представлено 2 типи бойових юнітів відповідно: Лучник (Archer) та Арбалетник (Crossbowman).
Тобто середньовічні арбалетники приходять на заміну «застарілим» прадавнім лучникам. (Нагадую про ігрові умовності та деяке нехтування історичною правдивістю у контексті комп’ютерної гри).
Мінімалістична діаграма класів виглядатиме наступним чином:
У контексті цієї діаграми нас цікавить те, що кожен юніт має поліморфний метод attack(), що визначається у конкретному класі-нащадку. Для того, щоб надалі не плутатися з ерами та відповідністю юнітів, пропоную перейменувати клас Archer в AncientUnit та Crossbowman в MedievalUnit:
Мінімалістична імплементація цих класів виглядатиме наступним чином:
class Unit { public: virtual ~Unit() {}; virtual void attack() = 0; }; class AncientUnit : public Unit { public: virtual void attack() override { std::cout << "Ancient unit (Archer) attacks" << std::endl; } }; class MedievalUnit : public Unit { public: virtual void attack() override { std::cout << "Medieval unit (Crossbowman) attacks" << std::endl; } };
Без використання будь-якого патерна процес створення юнітів виглядатиме так:
З різних місць клієнтського коду відбувається створення об’єктів бойових юнітів. Відповідно логіка створення цих об’єктів, що охоплює прийняття рішення про потрібний тип юніта, «розмазана» по клієнтському коду та, з великою ймовірністю, дублюється. Знаходження баги в одному такому місці або оновлення вимог до бізнес-логіки найімовірніше призведе до змін в аналогічних місцях у різних модулях програми. А десь внести зміни можна просто забути...
Фабричний метод
Дану проблему легко вирішує простий патерн Фабричний метод. Створимо клас UnitProvider, статичний метод createUnit якого на основі вхідних параметрів (в нашому випадку — значення епохи) повертатиме створений об’єкт відповідного бойового юніта.
enum class Epoch : int { ANCIENT, MEDIEVAL }; class UnitProvider { public: static Unit* createUnit(const Epoch _epoch) { Unit* result_ptr = nullptr; switch (_epoch) { case Epoch::ANCIENT: result_ptr = new AncientUnit(); break; case Epoch::MEDIEVAL: result_ptr = new MedievalUnit(); break; default: /* Some decision here */ break; } return result_ptr; } };
Клас Game виступає у ролі клієнтського коду:
class Game { public: // Симуляція бурхливого розвитку до Середньовіччя void simulateDevelopment() { m_CurrentEpoch = Epoch::MEDIEVAL; } void simulateWar() { // Делегуємо створення юніта для поточної епохи Фабричному методу Unit* warrior_ptr = UnitProvider::createUnit(m_CurrentEpoch); warrior_ptr->attack(); delete warrior_ptr; } private: // Стартуємо в Давні часи Epoch m_CurrentEpoch{Epoch::ANCIENT}; };
Ну і метод main, для повноти картини:
int main() { Game game; game.simulateWar(); game.simulateDevelopment(); game.simulateWar(); return 0; }
Вивід на консоль виглядає наступним чином:
Рішення про те, об’єкт якого саме класу-нащадка Unit створити, інкапсульоване в UnitProvider::createUnit.
Як проміжний висновок можна сказати, що Фабричний метод на основі вхідного параметра(-ів) приймає рішення про створення об’єкта певного класа-нащадка по вказівнику на базовий клас.
Абстрактна фабрика
Але ж не війною єдиною, треба і про культуру подумати. Додамо в нашу гру споруди, які приноситимуть очки культури (їх можна буде витратити на певні ігрові бонуси). Для Давніх часів це буде Монумент, що приноситиме 1 очко культури, а для Середньовіччя — Амфітеатр з двома очками. Відповідно, мінімалістично маємо базовий клас CulturalBuilding з інтерфейсом getCulturalPoints. Знову ж таки, щоб запобігти плутанині, дочірні класи назвемо AncientCulturalBuilding та MedievalCulturalBuilding.
class CulturalBuilding { public: virtual ~CulturalBuilding() {} virtual int getCulturalPoints() = 0; }; class AncientCulturalBuilding : public CulturalBuilding { public: virtual int getCulturalPoints() override { std::cout << "Ancient cultural building (Monument) gives 1 cultural point" << std::endl; return 1; } }; class MedievalCulturalBuilding : public CulturalBuilding { public: virtual int getCulturalPoints() override { std::cout << "Medieval cultural building (Amphitheater) gives 2 cultural points" << std::endl; return 2; } };
На даному етапі можна сказати, що в нас є два сімейства сутностей: Давньої епохи (Лучник, Монумент) і Середньовіччя (Арбалетчик, Амфітеатр).
Вводячи поняття «сімейство сутностей» я плавно підходжу до обґрунтування подальшого використання патерну Абстрактна фабрика: він надає інтерфейс для створення сімейства взаємопов’язаних або взаємозалежних сутностей (об’єктів). Основна задача патерну та сама: інкапсулювати логіку створення об’єктів. В цьому випадку інкапсуляція відбуватиметься вже не всередині методу, а в ієрархії класів-фабрик.
Пропоную спочатку розглянути діаграму класів.
IAbstractFactory надає інтерфейс для створення бойових юнітів та культурних будівель. Класи-нащадки AncientFactory та MedievalFactory є реальними фабриками, кожна з яких повинна реалізувати методи createUnit та createCulturalBuilding. І тут все просто. AncientFactory створюватиме та повертатиме сімейство об’єктів Давньої епохи, а MedievalFactory — Середньовіччя.
class IAbstractFactory { public: virtual ~IAbstractFactory() {} virtual Unit* createUnit() = 0; virtual CulturalBuilding* createCulturalBuilding() = 0; }; class AncientFactory : public IAbstractFactory { public: virtual Unit* createUnit() override { return new AncientUnit(); } virtual CulturalBuilding* createCulturalBuilding() override { return new AncientCulturalBuilding(); } }; class MedievalFactory : public IAbstractFactory { public: virtual Unit* createUnit() override { return new MedievalUnit(); } virtual CulturalBuilding* createCulturalBuilding() override { return new MedievalCulturalBuilding(); } };
Змін зазнає клас Game, що виступає як клієнтський код. Атрибут m_Factory_ptr триматиме вказівник на фабрику, за допомогою якої будуть створюватись об’єкти. Також зникає потреба в enum Epoch.
class Game { public: Game() { // Стартуємо в Давні часи m_Factory_ptr = new AncientFactory(); } // Симуляція бурхливої діяльності void simulateUserActions() { Unit* unit_ptr = m_Factory_ptr->createUnit(); unit_ptr->attack(); // Воюємо юнітом CulturalBuilding* culturalBuilding_ptr = m_Factory_ptr->createCulturalBuilding(); culturalBuilding_ptr->getCulturalPoints(); // Забираємо очки культури delete unit_ptr; delete culturalBuilding_ptr; } void simulateDevelopment() { delete m_Factory_ptr; // Розвиток до Середньовіччя – просто заміняємо фабрику m_Factory_ptr = new MedievalFactory(); } private: IAbstractFactory* m_Factory_ptr = nullptr; };
Ну і метод main:
int main() { Game game; game.simulateUserActions(); game.simulateDevelopment(); std::cout << std::endl; game.simulateUserActions(); return 0; }
Вивід на консоль виглядає наступним чином:
Все, що потрібно для переходу від однієї ери до іншої (так, в нашій версії гри відбувається повний перехід до іншої ери усіма внутрішньо ігровими сутностями) — це заміна об’єкта фабрики, на яку вказує m_Factory_ptr.
Даний приклад є доволі простим з погляду бізнес-логіки: заміна фабрики відбувається ультимативно на об’єкт класу MedievalFactory в методі simulateDevelopment. У складнішому випадку часто створюють Фабричний метод, який на основі вхідних параметрів повертає об’єкт конкретної фабрики, яка, своєю чергою, створюватиме об’єкти із певного сімейства.
Набір фабричних методів
Дивлячись на доволі непросту конструкцію патерна Абстрактна фабрика, логічно приходить думка: «А навіщо так складно? Можна ж було просто додати ще один Фабричний метод у попередній варіант реалізації, що набагато простіше».
І насправді таке рішення абсолютно має право на життя. Пропоную його розглянути.
Повертаємо назад приватний атрибут m_CurrentEpoch в клас Game; клас UnitProvider тепер варто перейменувати у щось на кшталт EpochObjectsProvider:
class EpochObjectsProvider { public: static Unit* createUnit(const Epoch _epochType) { Unit* result_ptr = nullptr; switch (_epochType) { case Epoch::ANCIENT: result_ptr = new AncientUnit(); break; case Epoch::MEDIEVAL: result_ptr = new MedievalUnit(); break; default: /* ... */ break; } return result_ptr; } static CulturalBuilding* createCulturalBuilding(const Epoch _epochType) { CulturalBuilding* result_ptr = nullptr; switch (_epochType) { case Epoch::ANCIENT: result_ptr = new AncientCulturalBuilding(); break; case Epoch::MEDIEVAL: result_ptr = new MedievalCulturalBuilding(); break; default: /* ... */ break; } return result_ptr; } };
Клас Game:
class Game { public: void simulateDevelopment() { m_currentEpoch = Epoch::MEDIEVAL; } void simulateUserActions() { Unit* warrior_ptr = EpochObjectsProvider::createUnit(m_currentEpoch); warrior_ptr->attack(); delete warrior_ptr; CulturalBuilding* culturalBuilding_ptr = EpochObjectsProvider::createCulturalBuilding(m_currentEpoch); culturalBuilding_ptr->getCulturalPoints(); delete culturalBuilding_ptr; } private: Epoch m_currentEpoch{Epoch::ANCIENT}; };
Метод main залишаємо без змін. Запускаємо і бачимо результат на консолі абсолютно ідентичний попередньому.
Таким чином, ми щойно побачили, що патерн Абстрактна фабрика можна замінити набором Фабричних методів. Також цей підхід виглядає набагато простішим для розуміння, особливо для новачків. То чи можна на цьому ставити крапку і написати гарний висновок? Відповідь: «Ні!».
Перш ніж рухатися далі, необхідно розглянути один з принципів SOLID, а саме, принцип відкритості/закритості (англ. OCP — Open/Closed principle).
Принцип відкритості/закритості
Тут неможливо не згадати про Роберта Мартіна — американського інженера та програміста зі стажем більш як 50 років. Він також відомий під псевдонімом Uncle Bob. Трохи відходячи від теми, дуже раджу подивитися його виступи на Youtube по Clean code.
Роберт Мартін є одним із засновників руху за гнучку розробку (Agile) та одним з авторів Маніфесту гнучкої розробки програмного забезпечення. Також, що нас найбільше цікавить у контексті даної статті, він зібрав п’ять принципів розробки ПЗ, відомі як SOLID. Один з них — принцип відкритості/закритості: «Програмні сутності (класи, модулі, функції тощо) повинні бути відкритими до розширення та закритими до змін». Іншими словами, потрібно додавати новий функціонал таким чином, щоб не змінювати вже написаний код.
Якщо коротко, реалізується даний принцип через наслідування та поліморфізм. Йому потрібно слідувати для того, щоб зменшити витрати (як часові, так і фінансові) на тестування (якщо код не змінювався, його не потрібно тестувати заново) і зменшити ймовірність внесення багів (баги будуть лише в новому коді: старий, відтестований залишається без змін). Звичайно, це ідеальна картина. В реальному житті потрібно прагнути мінімізувати зміни в уже написаному коді.
Абстрактна фабрика чи набір Фабричних методів
Аналізуючи бізнес-задачу, яку ми розглядаємо, можна чітко побачити дві осі змін. По-перше, ми можемо захотіти додати нові ери (наприклад, Сучасність тощо). Іншими словами, це буде нове сімейство сутностей: сучасний воїн та сучасна культурна будівля. Другим напрямом можливого розвитку проєкту є додавання нових об’єктів у кожне з уже визначених сімейств. Наприклад, додаємо Наукову будівлю, яка приноситиме очки науки для розвитку цивілізації. Такою будівлею в Давні часи може бути Бібліотека, а в Середньовіччі — Університет (знову ж таки, прошу не зважати на історичний аспект, при реалізації будемо просто використовувати назви AncientScienceBuilding та MedievalScienceBuilding).
Почнемо із додавання Сучасної ери (тобто нового сімейства сутностей). Відповідно, нам потрібен клас ModernUnit, що наслідується від Unit, та ModernCulturalBuilding, що є нащадком CulturalBuilding.
У першому сценарії подивимося, що буде, якщо ми обрали архітектуру на основі Абстрактної фабрики. Усі зміни відносно попередньої версії на діаграмі класів позначатиму червоним кольором.
Бачимо, що додано нові класи бізнес-об’єктів і фабрику. Важливо зазначити, що не внесено жодних змін в інші класи. Так, в нашій системі зміни ще мають відбутися у методі Game::simulateDevelopment, але вони мінімальні та локалізовані.
А тепер розглянемо другий сценарій: ми обрали підхід із використанням набору Фабричних методів.
Клас EpochObjectProvider зазнає значних змін. Усі його методи повинні бути змінені, щоб обробляти нове значення Epoch::MODERN. У даному прикладі таких методів лише два, але в реальному проєкті їх буде набагато більше. Відповідно, грубо порушуємо принцип відкритості/закритості. Для кращого розуміння наведу оновлений код для методу createUnit:
static Unit* createUnit(const Epoch _epochType) { Unit* result_ptr = nullptr; switch (_epochType) { case Epoch::ANCIENT: result_ptr = new AncientUnit(); break; case Epoch::MEDIEVAL: result_ptr = new MedievalUnit(); break; case Epoch::MODERN: result_ptr = new ModernUnit(); break; default: /* ... */ break; } return result_ptr; }
Можна зробити проміжний висновок. Якщо ми плануємо додавати/видаляти сімейства сутностей, то як архітектурне рішення необхідно використати Абстрактну фабрику. Вибір підходу на основі набору Фабричних методів буде некоректним через порушення принципу відкритості/закритості з усіма наслідками, що випливають з цього.
Тепер пропоную повернутися до початкового варіанту із двома ерами та двома сутностями в кожній і розглянути інший можливий випадок. У кожну із наявних ер потрібно додати відповідну Наукову будівлю.
Почнемо із рішення на основі Абстрактної фабрики.
Поточна архітектура передбачає додавання нового абстрактного методу createScienceBuilding до інтерфейсу IAbstractFactory. Але що відбудеться далі? Усі класи-нащадки мають імплементувати цей метод. Так, в цьому випадку зміни зазнають два класи (MedievalFactory та AncientFactory). Але розуміємо, що в реальному проєкті їх буде набагато більше. Таким чином, будь-яка вимога на додавання/видалення елементу сімейства призводить до змін в усіх нащадках IAbstractFactory.
Альтернативним варіантом, очевидно, є використання рішення на основі набору Фабричних методів.
На перший погляд може здатися, що картина аналогічна до попередньої. Ми додали метод createScienceBuilding у клас EpochObjectProvider. Але ця зміна не тягне за собою низки наступних. Так, ми внесли зміни у раніше написаний клас. Але вона єдина (на відміну від попереднього рішення зі змінами у всіх нащадках IAbstractFactory).
Отже, якщо передбачається активне додавання/видалення сутностей всередині сімейств (без додавання/видалення самих сімейств), то як архітектурне рішення необхідно використати набір Фабричних методів.
Висновок
Залишається сформулювати загальний висновок. Чи можна замість достатньо складного патерну Абстрактна фабрика використати простіший підхід на основі набору Фабричних методів? Проста відповідь: «Так, Абстрактну фабрику можна перетворити на набір Фабричних методів». Інше питання полягає у доцільності. Треба розуміти, що за будь-якого сценарію в нас є поняття сімейств та сутностей, які їх наповнюють. Обираючи між двома підходами важливо вдало спрогнозувати, що переважно залишатиметься сталим, а що зазнаватиме постійних змін.
Якщо сутності залишаються сталими та передбачається активне додавання/видалення сімейств, необхідно обрати Абстрактну фабрику.
Якщо сімейства залишаються сталими та передбачається активне додавання/видалення сутностей в них, правильним вибором буде набір Фабричних методів.
А що робити, якщо активно змінюються і сімейства, і сутності в них? Напишіть свої відповіді в коментарях, а я з часом спробую розкрити це питання у новій статті.
Література
У першу чергу потрібно посилатися на першоджерело патернів проєктування ПЗ: книгу, згадану на початку статті: Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides «Design Patterns: Elements of Reusable Object-Oriented Software». Однак, оскільки вона була написана ще в кінці попереднього тисячоліття, деякі підходи та ідеї, описані в книзі, можуть здатися застарілими та неактуальними.
Зі свого боку можу порадити дві книги на тему, які прочитав сам:
- Eric Freeman, Elisabeth Freeman (with Kathy Sierra and Bert Bates) «Head First Design Patterns» — сучасне подання GoF патернів з елементами інтерактиву;
- Robert Martin «Agile Software Development: Principles, Patterns, and Practices» — захопливе розкриття теми патернів проєктування та принципів SOLID від Дядька Боба.
Подяка
У 2015 році я навчався на магістратурі НУ «Львівська Політехніка» кафедри САПР, де Загарюк Роман Вікторович неймовірно цікаво та доступно розкрив нам тему GoF-патернів. Після іспиту він підписав та подарував мені книгу «Head First Design Patterns» (див. Література).
Ця досить нетипова та неочікувана подія посприяла моєму розвитку як ІТ-спеціаліста, ментора та викладача, за що я йому дуже вдячний.
* У статті згадується комп’ютерна гра «Цивілізація» Сіда Меєра (Sid Meier’s Civilization). Ілюстрації бойових юнітів та культурних будівель взято із Sid Meier’s Civilization 5.
30 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів