Лабораторна робота 5

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

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

Модифікувати раніше створену програму індивідуального завдання лабораторної роботи № 4 , побудувавши код з використанням проектних патернів. Обов'язково реалізувати патерни "Facade", "Singleton" "Lazy Initialization" і "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 (англ) http://www.sugardas.lt/~p2d/books/Priemioop.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 Fecond()
{
    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