Завдання для самостійної роботи

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

1 Завдання на роботу

Модифікувати раніше створену програму індивідуального завдання лабораторної роботи № 5 , побудувавши код з використанням проєктних патернів. Обов'язково реалізувати патерни "Facade", "Singleton" і "Factory Method". Додатково можна застосувати патерни "Abstract Factory" і "Observer", а також будь-які інші патерни на свій розсуд.

2 Методичні вказівки

2.1 Загальні концепції патернів проєктування

Патерн проєктування (design pattern, проєктний зразок, шаблон) – це опис взаємодії об'єктів і класів, адаптований для розв'язання певної задачі в конкретному контексті.

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

Раніше згадувався засадничий патерн проєктування Model-View-Controller (MVC, «модель-вигляд-контролер»). Він був вперше застосований в системі Smalltalk-80. MVC може навіть розглядатися не як шаблон, а як основоположна концепція проєктування, згідно з якою модель даних програми (бізнес-логіка), інтерфейс користувача (візуальне уявлення) і взаємодія з користувачем відокремлені один від одного.

Модель надає дані й методи роботи з даними та не містить інформації про візуальне подання цих даних.

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

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

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

Вперше патерни проєктування були систематично викладені в книзі "Патерни проєктування: Елементи повторно використовуваного об'єктно-орієнтованого програмного забезпечення" ("Design Patterns: Elements of Reusable Object-Oriented Software", "Приемы объектно-ориентированного проектирования. Паттерны проектирования") авторів Е. Гамма Р. Хелм Р. Джонсон Дж. Вліссідес (http://www.uml.org.cn/c++/pdf/DesignPatterns.pdf англ., https://stilsoft.ru/download/catalog/pdf/polnoe-opisanie-322323.pdf, рос.). Ця книга вийшла англійською мовою в 1995 р у видавництві Addison Wesley Longman, Inc. Подальший розвиток патернів' відображено в книзі "Застосування UML і шаблонів проєктування" автора Крега Лармана.

Ідентифікація патерну передбачає визначення таких елементів:

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

Стандартний опис патерну включає визначення таких основних специфікацій:

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

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

Шаблони проєктних рішень базуються на механізмах повторного використання. До них відносяться:

  • успадкування та композиція;
  • делегування;
  • параметризовані типи.

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

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

Можна виділити такі групи шаблонів проєктування:

  • твірні патерни (патерни, що породжують, creational design patterns); найбільш відомі з них:
    • Abstract Factory (Абстрактна фабрика)
    • Builder (Будівельник)
    • Factory Method (Фабричний метод)
    • Dependency Injection (Впровадження залежностей)
    • Lazy Initialization (Відкладена ініціалізація)
    • Prototype (Прототип)
    • Object pool (Пул об'єктів)
    • Singleton (Одинак)
  • структурні патерни; в книзі "Патерни проєктування: Елементи повторно використовуваного об'єктно-орієнтованого програмного забезпечення" до них віднесені:
    • Adapter (Адаптер)
    • Bridge (Міст)
    • Composite (Компонувальник)
    • Decorator (Декоратор)
    • Facade (Фасад)
    • Flyweight (Легковаговик)
    • Proxy (Заступник)
  • патерни поведінки; це, наприклад, такі патерни, як
    • Visitor (Відвідувач)
    • Interpreter (Інтерпретатор)
    • Iterator (Ітератор)
    • Command (Команда)
    • Chain of Responsibility (Ланцюг обов'язків)
    • Mediator (Посередник)
    • Observer (Спостерігач)
    • State (Стан)
    • Strategy (Стратегія)
    • Memento (Знімок)
    • Template Method (Шаблонний метод)
  • патерни розподілу обов'язків (GRASP, за книгою "Застосування UML і шаблонів проєктування"); це, наприклад:
    • Information Expert (Інформаційний експерт)
    • Creator (Творець)
    • Low Coupling (Низька зв'язність)
    • Protected Variations (Приховування реалізації)

Існують також системні патерни, патерни управління тощо. Найбільш часто використовуються патерни перших трьох груп.

2.3 Приклади твірних патернів

2.3.1 Патерн Factory Method

Factory Method (Фабричний метод) – твірний патерн, який надає підкласам інтерфейс для створення екземплярів деякого класу. У момент створення спадкоємці можуть визначити, який клас створювати. Фабрика делегує створення об'єктів спадкоємцям батьківського класу. Це дозволяє використовувати в коді програми не специфічні класи, а маніпулювати абстрактними об'єктами на вищому рівні. Також відомий під назвою віртуальний конструктор (Virtual Constructor).

Фабричний метод використовують у таких випадках:

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

Можна запропонувати два підходи для реалізації Factory Method мовою C#. Перший підхід базується на використанні статичних методів. Об'єкти певних типів створюються залежно від змінних станів або параметрів. Розглянемо простий приклад.

Припустимо, є інтерфейс Shape:

public interface IShape
{
    void Draw();
}

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

public class Circle : IShape
{
    public void Draw()
    {
        Console.WriteLine("Circle");
    }
}

public class Square : IShape
{
    public void Draw()
    {
        Console.WriteLine("Square");
    }

}

Тепер можна створити клас-фабрику, в методі якого об'єкт створюється за ім'ям типу і здійснити тестування:

public class ShapeFactory
{
    public IShape GetShape(string shapeType)
    {
        switch (shapeType.ToUpper())
        {
            case "CIRCLE":
                return new Circle();
            case "SQUARE":
                return new Square();
            default:
                return null;
        }
    }
}

class Program
{
    static void Main(string[] args)
    {
        ShapeFactory shapeFactory = new ShapeFactory();
        IShape shape1 = shapeFactory.GetShape("CIRCLE");
        shape1.Draw();
        IShape shape2 = shapeFactory.GetShape("SQUARE");
        shape2.Draw();
    }
}

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

public abstract class AbstractNumber
{
    abstract public AbstractNumber GetInstance(); // фабричний метод
    abstract public AbstractNumber Sum(AbstractNumber a, AbstractNumber b);

    public AbstractNumber Sum(AbstractNumber[] arr)
    {
        AbstractNumber result = GetInstance();
        foreach (AbstractNumber elem in arr)
        {
            result = Sum(result, elem);
        }
        return result;
    }
}

Створюємо похідний клас IntegerNumber:

public class IntegerNumber : AbstractNumber
{
    private int k = 0;

    public IntegerNumber()
    {
    }

    public IntegerNumber(int k)
    {
        this.k = k;
    }

    override public AbstractNumber GetInstance()
    {
        return new IntegerNumber();
    }

    override public AbstractNumber Sum(AbstractNumber a, AbstractNumber b)
    {
        IntegerNumber result = new IntegerNumber();
        result.k = ((IntegerNumber)a).k + ((IntegerNumber)b).k;
        return result;
    }

    override public String ToString()
    {
        return "IntegerNumber { k = " + k + " }";
    }
}

Створюємо похідний клас ComplexNumber:

public class ComplexNumber : AbstractNumber
{
    private double re = 0;
    private double im = 0;

    public ComplexNumber()
    {
    }

    public ComplexNumber(double re, double im)
    {
        this.re = re;
        this.im = im;
    }

    override public AbstractNumber GetInstance()
    {
        return new ComplexNumber();
    }

    override public AbstractNumber Sum(AbstractNumber a, AbstractNumber b)
    {
        ComplexNumber result = new ComplexNumber();
        result.re = ((ComplexNumber)a).re + ((ComplexNumber)b).re;
        result.im = ((ComplexNumber)a).im + ((ComplexNumber)b).im;
        return result;
    }

    override public String ToString()
    {
        return "ComplexNumber { re = " + re + ", im = " + im + " }";
    }
}

Здійснюємо тестування:

class Program
{
    static void Main(string[] args)
    {
        AbstractNumber[] arr1 = { new IntegerNumber(1), new IntegerNumber(2), new IntegerNumber(4) };
        AbstractNumber result1 = arr1[0]; // в такий спосіб визначаємо тип об'єкта
        Console.WriteLine(result1.Sum(arr1));
        AbstractNumber[] arr2 = { new ComplexNumber(1, 2), new ComplexNumber(3, 4) };
        AbstractNumber result2 = arr2[0]; // в такий спосіб визначаємо тип об'єкта
        Console.WriteLine(result2.Sum(arr2));
    }
}

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

2.3.2 Патерн Abstract Factory

Патерн Abstract Factory (Абстрактна фабрика) використовують в тих випадках, коли необхідно змінювати поведінку системи, варіюючи об'єктами які створюються, зберігаючи інтерфейси. Він дозволяє створювати групи взаємопов'язаних об'єктів, що реалізують загальну поведінку.

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

Використання абстрактної фабрики має такі переваги:

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

2.3.3 Патерн Lazy Initialization

Патерн Lazy initialization передбачає створення об'єкта безпосередньо перед тим, як цей об'єкт буде вперше використано. Таким чином, ініціалізація виконується "на вимогу", а не завчасно. Наприклад, деяке поле типу SomeType містить посилання на об'єкт, який можна одразу створити разом з описом поля:

SomeType field = new SomeType();

void First()
{
    field.SetValue();
}

void Second()
{
    field.GetValue();
}

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

SomeType field = null;

void First()
{
    field = new SomeType();
    field.SetValue();
}

void Second()
{
    field.GetValue();
}

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

SomeType field = null;

public SomeType GetField()
{
    if (field == null)
    {
        field = new SomeType();
    }
    return field;
}

Тепер важливо слідкувати, щоб усі звернення до поля здійснювалися лише через гетер:

void First()
{
    GetField().SetValue();
}

void Second()
{
    GetField().GetValue();
}

2.3.4 Патерн Singleton

Патерн Singleton (Одинак) застосовують в тому випадку, коли клас може мати тільки один екземпляр (або не мати жодного). Це може бути будь-якої великий об'єкт, який не повинен тиражуватися. Наприклад, об'єкт може бути пов'язаний з файлом, в якому фіксується налагоджувальна інформація тощо.

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

Найпростіше рішення на C# полягає в застосуванні "фабричного методу":

public class Singleton
{
    private static Singleton? instance = null;

    private Singleton()
    {
    }

    public static Singleton GetInstance()
    {
        if (instance == null)
        {
            instance = new Singleton();
        }
        return instance;
    }
}

Щоб уникнути втрати масштабованості проєкту застосовувати патерн Singleton слід тільки в тому випадку, коли єдиність екземпляра є безумовною, а не як тимчасове рішення.

2.4 Приклади структурних патернів

2.4.1 Патерн Adapter

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

Наприклад, є узагальнений клас SomeArray, який представляє масив:

using System;
using System.Linq;

class SomeArray<T>
{
    private T[] arr = null;
    
    public SomeArray(params T[] arr)
    {
        this.arr = arr.ToArray(); // функція додана в LINQ через механізм розширення
    }

    public int Size()
    {
        return arr.Length;
    }

    public T this[int i]
    {
        get => arr[i];
        set => arr[i] = value;
    }
    // інші методи
}

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

IList<int> list = new SomeArray<int>(); // Помилка

Оскільки SomeArray не реалізує інтерфейсу IList, це призведе до синтаксичної помилки. Необхідно здійснити адаптацію класу SomeArray. Створюємо новий клас, який реалізує інтерфейс IList і містить поле – посилання на SomeArray:

class ArrayAdapter<T> : IList<T>
{
    private SomeArray<T> someArray;
	
    public ArrayAdapter(params T[] arr)
    {
        someArray = new SomeArray<T>(arr);
    }
	
    public T this[int i]
    { 
        get => someArray[i]; 
        set => someArray[i] = value;
    }

    public int Count => someArray.Size();

    // інші методи для реалізації IList
}

Такий клас можна використовувати замість List:

IList<int> list = new ArrayAdapter<int>();

2.4.2 Патерн Facade

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

Оскільки клас-фасад може бути застосований для створення лише одного об'єкта, такий клас доцільно реалізовувати з використанням патерну Singleton.

Розглянемо застосування патерну Facade для розглянутого раніше прикладу з книжковою полицею і книжками. У нашому випадку модель – це бібліотека класів, яка описує предметну область. До бібліотеки додаємо ще один клас Facade, який містить посилання на BookshelfWithLINQ. Якщо треба, він також може містити посилання на інші об'єкти моделі.

namespace BookshelfLib
{
    public class Facade
    {
        private BookshelfWithLINQ<Author> bookshelf;
        // посилання на інші класи моделі, якщо необхідно

        // реалізація патерну Singleton:
        // модифікатор private для конструктора унеможливлює створення
        // об'єктів не через метод getInstance():
        private static Facade instance = null;
        private Facade()
        {

        }

        public static Facade GetInstance()
        {
            if (instance == null)
            {
                instance = new Facade();
            }
            return instance;
        }

        // Методи, які відповідають функціям GUI-застосунку
        // та вимагають взаємодії з моделлю:

        public void DoNew()
        {
            //...
        }

        public void DoOpen(string fileName)
        {
            //...
        }

        public void DoSave(string fileName)
        {
            //...
        }

        public void DoSort()
        {
            //...
        }
    }
}

У контролері тепер здійснюється взаємодія лише з фасадом:

public partial class MainWindow : Window
{
    private Facade facade = Facade.GetInstance();
    // ...

    // Методи - оброблювачі подій:

    private void ButtonNew_Click(object sender, RoutedEventArgs e)
    {
        facade.DoNew();
        // Оновлення візуальних компонентів
    }

    // ...

}

2.5 Приклади патернів поведінки

2.5.1 Патерн Observer

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

Розглянемо використання патерну на такому прикладі. Клас OperationObserver (спостерігач) визначає інтерфейс поновлення для об'єктів, які повинні бути повідомлені про зміну суб'єкта:

using System;
using System.Collections.Generic;

public abstract class OperationObserver
{
    public abstract double ValueChanged(Rectangle observed);
}

Клас Rectangle (суб'єкт) має інформацію про своїх спостерігачів і надає інтерфейс для реєстрації і повідомлення спостерігачів:

public class Rectangle
{
    private double width;
    private double height;

    private readonly List<OperationObserver> observerList = new List<OperationObserver>();

    public void AddObserver(OperationObserver observer)
    {
        observerList.Add(observer);
    }

    public double Width
    {
        get => width;
        set
        {
            width = value;
            NotifyObservers();
        }
    }

    public double Height
    {
        get => height;
        set
        {
            height = value;
            NotifyObservers();
        }
    }

    private void NotifyObservers()
    {
        foreach (OperationObserver o in observerList)
        {
            o.ValueChanged(this);
        }
    }

    override public String ToString()
    {
        String s = "Width = " + Width + " Height = " + Height + "\n";
        foreach (OperationObserver o in observerList)
        {
            s += o.ToString() + '\n';
        }
        return s;
    }
}

Класи Perimeter і Square є "передплатниками", які отримують повідомлення про зміну розмірів прямокутника й оновлюють свої дані під час отримання такого повідомлення:

public class Perimeter : OperationObserver
{
    private double perimeter;

    override public double ValueChanged(Rectangle observed)
    {
        return perimeter = 2 * (observed.Width + observed.Height);
    }

    override public String ToString()
    {
        return "Perimeter = " + perimeter;
    }
}

public class Area : OperationObserver
{
    private double area;

    override public double ValueChanged(Rectangle observed)
    {
        return area = observed.Width * observed.Height;
    }

    override public String ToString()
    {
        return "Area = " + area;
    }
}

Тепер можна здійснити тестування:

class Program
{
    static void Main(string[] args)
    {
        Rectangle rectangle = new Rectangle();
        rectangle.AddObserver(new Perimeter());
        rectangle.AddObserver(new Area());
        rectangle.Width = 1;
        rectangle.Height = 2;
        Console.WriteLine(rectangle);
        rectangle.Width = 3;
        Console.WriteLine(rectangle);
        rectangle.Height = 4;
        Console.WriteLine(rectangle);
    }
}

2.6 Розв'язання задач проєктування за допомогою патернів

Шаблони проєктування можуть бути корисними для послідовного розв'язання низки задач:

  • пошук відповідних об'єктів; корисними можуть виявитися шаблони Компонувальник, Стратегія, Стан;
  • визначення ступеня деталізації об'єкта; корисними будуть Фасад, Легковаговик, Абстрактна фабрика, Будівельник, Відвідувач, Команда;
  • специфікація інтерфейсів об'єктів; корисними можуть виявитися Декоратор, Заступник, Відвідувач;
  • специфікація реалізації об'єктів; використовуються шаблони Абстрактна фабрика, Будівельник, Фабричний метод, Прототип, Одинак;
  • механізми повторного використання; використовуються Стан, Стратегія, Відвідувач, Ланцюг обов'язків;
  • побудова структур часу виконання (Компонувальник, Декоратор, Ланцюг обов'язків);
  • проєктування з урахуванням майбутніх змін; тут корисними можуть виявитися всі зразки проєктування, перш за все Фасад, Міст, Шаблонний метод.

3 Контрольні запитання

  1. Що таке патерн проєктування?
  2. Опишіть складові частини патерну MVC.
  3. Коли й ким вперше були систематично описані патерни проєктування?
  4. Визначення яких елементів включає ідентифікація патерну?
  5. Які специфікації включає стандартний опис патерну?
  6. На яких механізмах базуються патерни?
  7. Наведіть класифікацію патернів проєктування.
  8. У чому полягає ідея та реалізація патерну Factory Method?
  9. Як реалізувати патерн Factory Method мовою C#?
  10. У чому призначення патерну Abstract Factory?
  11. Для чого використовують патерн Lazy Initialization?
  12. Як реалізувати патерн Lazy Initialization мовою C#?
  13. Коли використовують патерн Singleton?
  14. Як реалізувати патерн Singleton мовою C#?
  15. У чому призначення патерну Adapter?
  16. Як мовою C# реалізувати патерн Adapter?
  17. Коли й для чого застосовують патерн Facade?
  18. Як пов'язаний патерн Facade з патерном MVC?
  19. У чому є ідея і реалізація патерну Observer?
  20. Як слід застосовувати патерни для розв'язання задач проєктування програмного забезпечення?
  21. Наведіть приклади використання композиції та поліморфізму в патернах проєктування.

 

up