Прототип (Prototype)

Паттерн Прототип (Prototype) позволяет создавать объекты на основе уже ранее созданных объектов-прототипов. То есть по сути данный паттерн предлагает технику клонирования объектов.

Когда использовать Прототип?

  • Когда конкретный тип создаваемого объекта должен определяться динамически во время выполнения
  • Когда нежелательно создание отдельной иерархии классов фабрик для создания объектов-продуктов из параллельной иерархии классов (как это делается, например, при использовании паттерна Абстрактная фабрика)
  • Когда клонирование объекта является более предпочтительным вариантом нежели его создание и инициализация с помощью конструктора. Особенно когда известно, что объект может принимать небольшое ограниченное число возможных состояний.

На языке UML отношения между классами при применении данного паттерна можно описать следующим образом:

Паттерн Prototype в C# и .NET

Формальная структура паттерна на C# могла бы выглядеть следующим образом:

class Client
{
    void Operation()
    {
        Prototype prototype = new ConcretePrototype1(1);
        Prototype clone = prototype.Clone();
        prototype = new ConcretePrototype2(2);
        clone = prototype.Clone();
    }
}
 
abstract class Prototype
{
    public int Id { get; private set; }
    public Prototype(int id)
    {
        this.Id = id;
    }
    public abstract Prototype Clone();
}
 
class ConcretePrototype1 : Prototype
{
    public ConcretePrototype1(int id)
        : base(id)
    { }
    public override Prototype Clone()
    {
        return new ConcretePrototype1(Id);
    }
}
 
class ConcretePrototype2 : Prototype
{
    public ConcretePrototype2(int id)
        : base(id)
    { }
    public override Prototype Clone()
    {
        return new ConcretePrototype2(Id);
    }
}

Участники

  • Prototype: определяет интерфейс для клонирования самого себя, который, как правило, представляет метод Clone()
  • ConcretePrototype1 и ConcretePrototype2: конкретные реализации прототипа. Реализуют метод Clone()
  • Client: создает объекты прототипов с помощью метода Clone()

Рассмотрим клонирование на примере фигур — прямоугольников и кругов:

class Program
{
    static void Main(string[] args)
    {
        IFigure figure = new Rectangle(30,40);
        IFigure clonedFigure = figure.Clone();
        figure.GetInfo();
        clonedFigure.GetInfo();
 
        figure = new Circle(30);
        clonedFigure=figure.Clone();
        figure.GetInfo();
        clonedFigure.GetInfo();
 
        Console.Read();
    }
}
 
interface IFigure
{
    IFigure Clone();
    void GetInfo();
}
 
class Rectangle: IFigure
{
    int width;
    int height;
    public Rectangle(int w, int h)
    {
        width = w;
        height = h;
    }
 
    public IFigure Clone()
    {
        return new Rectangle(this.width, this.height);
    }
    public void GetInfo()
    {
        Console.WriteLine("Прямоугольник длиной {0} и шириной {1}", height, width);
    }
}
 
class Circle : IFigure
{
    int radius;
    public Circle(int r)
    {
        radius = r;
    }
 
    public IFigure Clone()
    {
        return new Circle(this.radius);
    }
    public void GetInfo()
    {
        Console.WriteLine("Круг радиусом {0}", radius);
    }
}

Здесь в качестве прототипа используется интерфейс IFigure, который реализуется классами Circle и Rectangle.

Но в данном случае надо заметить, что фреймворк .NET предлагает функционал для копирования в виде метода MemberwiseClone(). Например, мы могли бы изменить реализацию метода Clone() в классах прямоугольника и круга следующим образом:

public IFigure Clone()
{
    return this.MemberwiseClone() as IFigure;
}

Причем данный метод был бы общим для обоих классов. И работа программы никак бы не изменилась.

В то же время надо учитывать, что метод MemberwiseClone() осуществляет неполное копирование — то есть копирование значимых типов. Если же класс фигуры содержал бы объекты ссылочных типов, то оба объекта после клонирования содержали бы ссылку на один и тот же ссылочный объект. Например, пусть фигура круг имеет свойство ссылочного типа:

class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}
class Circle : IFigure
{
    int radius;
    public Point Point { get; set; }
    public Circle(int r, int x, int y)
    {
        radius = r;
        this.Point = new Point { X = x, Y = y };
    }
 
    public IFigure Clone()
    {
        return this.MemberwiseClone() as IFigure;
    }
    public void GetInfo()
    {
        Console.WriteLine("Круг радиусом {0} и центром в точке ({1}, {2})", radius, Point.X, Point.Y);
    }
}

В этом случае при изменении значений в свойстве Point начальной фигуры автоматически бы изменилось соответствующее значение и у клонированной фигуры:

Circle figure = new Circle(30, 50, 60);
Circle clonedFigure=figure.Clone() as Circle;
figure.Point.X = 100; // изменяем координаты начальной фигуры
figure.GetInfo(); // figure.Point.X = 100
clonedFigure.GetInfo(); // clonedFigure.Point.X = 100

Чтобы избежать подобной ситуации, надо применить полное копирование:

using System.IO;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters.Binary;
 
//........................
class Program
{
    static void Main(string[] args)
    {
        Circle figure = new Circle(30, 50, 60);
        // применяем глубокое копирование
        Circle clonedFigure=figure.DeepCopy() as Circle;
        figure.Point.X = 100;
        figure.GetInfo();
        clonedFigure.GetInfo();
 
        Console.Read();
    }
}
//.........................
     
[Serializable]
class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}
[Serializable]
class Circle : IFigure
{
    int radius;
    public Point Point { get; set; }
    public Circle(int r, int x, int y)
    {
        radius = r;
        this.Point = new Point { X = x, Y = y };
    }
 
    public IFigure Clone()
    {
        return this.MemberwiseClone() as IFigure;
    }
 
    public object DeepCopy()
    {
        object figure = null;
        using (MemoryStream tempStream = new MemoryStream())
        {
            BinaryFormatter binFormatter = new BinaryFormatter(null,
                new StreamingContext(StreamingContextStates.Clone));
 
            binFormatter.Serialize(tempStream, this);
            tempStream.Seek(0, SeekOrigin.Begin);
 
            figure = binFormatter.Deserialize(tempStream);
        }
        return figure;
    }
    public void GetInfo()
    {
        Console.WriteLine("Круг радиусом {0} и центром в точке ({1}, {2})", radius, Point.X, Point.Y);
    }
}

Чтобы вручную не создавать у клонированного объекта вложенный объект Point, здесь используются механизмы бинарной сериализации. И в этом случае все классы, объекты которых подлежат копированию, должны быть помечены атрибутом Serializable.

Подробнее

Давайте представим что мы делаем игру в стиле Gauntlet. У нас есть всякие существа и демоны, роящиеся вокруг героя и норовящие откусить кусочек его плоти. Эти незванные сотрапезники появляются через «спаунер (spawners)» и для каждого типа врагов есть отдельный тип спаунера.

Для упрощения примера давайте сделаем предположение что для каждого типа монстра в игре имеется отдельный тип. Т.е. у нас есть C++ классы для GhostDemonSorcerer и т.д.:

Я умышленно не пишу здесь «оригинальный». Паттерны проектирования цитируют легендарный проект Sketchpad 1963-го года за авторством Ивана Сазерленда, который можно считать первым примером применения шаблона в природе. Когда все остальные слушали Дилана и Битлз, Сазерленд был занят всего навсего изобретением базовых концепций CAD, интерактивной графики и объектно-ориентированного программирования. Можете посмотреть демо и впечатлиться

class Monster
{
// Stuff...
};

class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};

Спаунер конструирует экземпляры одного из типов монстров. Для поддержки всех монстров в игре мы можем использовать прямолинейный подход и заведем класс спаунер для каждого класса монстра. В результате получится следующая иерархия:

Реализация будет выглядеть так:

class Spawner {
  public:
    virtual~Spawner() {}
  virtual Monster * spawnMonster() = 0;
};

class GhostSpawner: public Spawner {
  public: virtual Monster * spawnMonster() {
    return new Ghost();
  }
};

class DemonSpawner: public Spawner {
  public: virtual Monster * spawnMonster() {
    return new Demon();
  }
};

Если вам конечно не платят за каждую строчку кода, использовать такой подход совсем не весело. Куча классов, куча похожего кода, куча избыточности, куча дублей, куча самоповторов…

Шаблон прототип предлагает решение. Ключевой мыслью является создание объекта, который может порождать объекты, похожие на себя. Если у вас есть один призрак, вы можете с его помощью получить кучу призраков. Если есть демон, можно сделать больше демонов. Любого монстра можно трактовать как прототипируемого монстра, используемого для генерации новых версий его самого.

Для реализации этой идеи, мы дадим нашему базовому классу Monster абстрактный метод clone():

class Monster {
  public:
    virtual~Monster() {}
  virtual Monster * clone() = 0;

  // Другие вещи...
};

Каждый подкласс монстра предоставляет свою реализацию, которая возвращает объект, идентичный по классу и состоянию ему самому. Например:

class Ghost: public Monster {
  public: Ghost(int health, int speed): health_(health),
  speed_(speed) {}

  virtual Monster * clone() {
    return new Ghost(health_, speed_);
  }

  private: int health_;
  int speed_;
};

Как только все монстры будут его поддерживать, нам больше не нужен будет отдельный класс спаунер для каждого класса монстров. Вместо этого мы обойдемся всего одним:

class Spawner {
  public:
    Spawner(Monster * prototype): prototype_(prototype) {}

  Monster * spawnMonster() {
    return prototype_ - > clone();
  }

  private:
    Monster * prototype_;
};

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

Для создания спаунера призраков, мы просто создаем прототипируемый экземпляр призрака и затем создаем спаунер, который будет хранить этот прототип:


Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);

Интересна одна особенность этого шаблона заключается в том что он не просто клонирует класс прототипа, но и клонирует его состояние. Это значит что мы можем сделать спаунер для быстрых призраков, для слабых, для медленных, просто создавая соответствующего прототипируемого призрака.

На мой взгляд этот шаблон одновременно и элегантен и удивителен. Я не могу представить чтобы дошел до него своим умом, но теперь я просто не могу себе представить что я мог бы о нем не знать.

Насколько хорошо он работает?

Итак нам не нужно создавать отдельный класс спаунер для каждого монстра и это хорошо. Но при этом нам нужно реализовывать метод clone() в каждом классе монстров. Кода там примерно столько же сколько и в спаунере.

К сожалению если вы попытаетесь написать корректную реализацию clone(), вы быстро наткнетесь на несколько подводных камней. Должен это быть глубокий клон или приблизительный? Другими словами, если демон держит вилы, должен ли клонированный демон тоже держать вилы?

Это не просто выглядит как выдуманная проблема, это действительно выдуманная проблема. Нужно принять как должное то что у нас есть отдельные классы для каждого монстра. В наше время так игровые движки писать не принято.

Большинство из нас не раз убеждались на собственном опыте что поддержка такой организации иерархии классов крайне болезненна, поэтому вместо этого для моделирования различных сущностей без отведения под каждую отдельного класса мы используем шаблоны наподобие Компонент(Component) или Тип объекта (Type Object).

Функции спаунера

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


Monster* spawnGhost()
{
return new Ghost();
}

Это уже не настолько примитивный подход, как создание отдельного класс для каждого нового типа монстров. Теперь единственный класс-спаунер может просто хранить указатель на функцию:

typedef Monster * ( * SpawnCallback)();

class Spawner {
  public:
    Spawner(SpawnCallback spawn): spawn_(spawn) {}

  Monster * spawnMonster() {
    return spawn_();
  }

  private:
    SpawnCallback spawn_;
};

И для создания спаунера призраков нужно будет всего лишь вызвать:


Spawner* ghostSpawner = new Spawner(spawnGhost);

Шаблоны (Templates)

Сейчас большинство C++ разработчиков знакомы с концепцией шаблонов. Нашему классу спаунеру нужно создать экземпляр определенного класса, но мы не хотим жестко прописывать в коде определенный класс монстра. Естественным решением этой задачи будет воспользоваться возможностями шаблонов и добавить параметр типа:

class Spawner {
  public:
    virtual~Spawner() {}
  virtual Monster * spawnMonster() = 0;
};

template
class SpawnerFor: public Spawner {
  public: virtual Monster * spawnMonster() {
    return new T();
  }
};

Я не могу утверждать что программисты C++ научились их любить или что некоторых они настолько пугают, что люди просто отказываются от C++. В любом случае все кто сегодня использует C++, используют и шаблоны тоже. Класс Spawner в данном коде не интересуется какой тип монстра он будет создавать. Он просто работает с указателем на Monster. Если бы у нас был только класс SpawnerFor, у нас не было бы ни одного экземпляра супертипа, разделяемого между шаблонами так что любому коду, работающему со спаунерами разных типов монстров, тоже пришлось бы принимать в качестве параметров шаблоны.

Применение выглядит следующим образом:


Spawner* ghostSpawner = new SpawnerFor();

Класс первого типа

Предыдущие два решения требовали от нас иметь класс Spawner, параметризируемый типом. В C++ классы в общем не являются объектами первого класса, так что это требует некоторых усилий. А вот если вы используете язык с динамическими типами наподобие JavaScript, Python или Ruby, где классы — это просто обычные объекты, которые можно как угодно передать, задача решается гораздо проще.

Если вам нужно соорудить спаунер — просто передайте ему класс монстра, которых он должен клонировать, т.е. по сути обычный объект, представляющий класс монстра. Проше пареной репы.

В некотором роде шаблон Объект тип (Type Object) — это очередной способ обхода проблемы отсутствия класса первого типа. В языке с таким типом он тоже может быт полезен, потому что позволяет вам самостоятельно определять что такое «тип». Вам может пригодится семантика отличная от той, что предоставляют встроенные классы.Пример применения: сохраните один универсальный символ ‘Эльф’ с начальными свойствами и создайте экземпляры Эльфа, клонируя его.

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

Прототипы для моделирования данных

Итак я продолжаю перечислять вещи за которые я не люблю прототипы. Депрессивная глава получается. Я задумывал эту книгу скорее как комедию, а не как трагедию, так что покончим с этим и перейдем к областям, где на мой взгляд прототипы или говоря конкретнее делегированиеможет быть полезным.

Если вы посчитаете все байты в игре приходящиеся на код и сравните с объемом остальных данных, вы увидите что с момента появления игр доля данных постоянно увеличивается. Ранние игры практически все генерировали процедурно и как следствие могли поместиться на дискетку или картридж. В большинстве современных игр код — это всего лишь «движок», который позволяет игре работать, а сама игра полностью определена в данных.

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

Вместо того чтобы копировать и вставлять кусок кода в десяти местах, мы помещаем его в отдельную функцию и вызываем ее по имени. Вместо того чтобы копировать метод в кучу классов, мы просто помещаем его в отдельный класс , а остальные классы от него наследуем.

Когда объем данных в игре достигает некоторого предела, вам сразу начинает хотеться обладать подобными возможностями. Моделирование данных — это слишком большая область чтобы обсуждать ее на поверхностном уровне, но я хочу показать вам одну из возможностей, которая пригодится вам в вашей игре: использование прототипов и делегирование для повторного использования данных.

Давайте представим себе, что мы определяем модель данных для бессовестного клона Gauntlet, о котором я писал выше. Геймдизайнеру нужны какие-то файлы, в которые он сможет поместить описание атрибутов монстров и предметов.

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

Можно использовать JSON: сущности данных будут представлены в виде maps или мешков со свойствами (property bags) или еще дюжиной терминов, потому что программисты просто обожают придумывать для одного и того же разные имена.

Итак гоблин в игре описан следующим образом:

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}

Довольно прямолинейный подход и даже не любящие писать дизайнеры могут справиться. Можно например добавить еще парочку сестринских описаний в славном семейном дереве зеленых гоблинов:

{
  "name": "goblin wizard",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "spells": ["fire ball", "lightning bolt"]
}

{
  "name": "goblin archer",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"],
  "attacks": ["short bow"]
}

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

Если бы это был код, мы могли бы создать абстракцию «гоблин» и использовать ее между всему типами гоблинов. Но тупой JSON ничего об этом не знает. Давайте попробуем сделать его чуточку умнее.

Определим для каждого объекта поле » prototype» и поместим туда имя объекта, к которому он делегирует. Любые свойства, отсутствующие у первого объекта нужно будет смотреть в прототипе.

Это позволит нам упростить описание нашей оравы гоблинов:

Таким образом prototype переходит из разряда обычных данных в метаданные. У каждого гоблина есть бородавчатая кожа и желтые зубы. У него нет прототипа. Прототип — это свойство объекта данных, описывающего гоблина, а не самого гоблина.

{
  "name": "goblin grunt",
  "minHealth": 20,
  "maxHealth": 30,
  "resists": ["cold", "poison"],
  "weaknesses": ["fire", "light"]
}

{
  "name": "goblin wizard",
  "prototype": "goblin grunt",
  "spells": ["fire ball", "lightning bolt"]
}

{
  "name": "goblin archer",
  "prototype": "goblin grunt",
  "attacks": ["short bow"]
}

Так как и лучник и чародей имеют в качестве прототипа пехотинца, нам не нужно указывать заново здоровье, сопротивляемости и уязвимости для каждого из них. Добавленная нами в данные логика предельно проста — мы просто добавили простейшее делегирование и сразу смогли избавиться от кучи повторов.

Хочу обратить ваше внимание на то что мы не стали добавлять четвертого «базового гоблина» в качестве абстрактного прототипа, к которому будут делегировать остальные три. Вместо этого мы просто взяли одного из гоблинов, который является простейшим и делегируем к нему.

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

Подумайте о боссах и уникальных предметах. Очень часто они являются лишь немного измененной версией обыкновенных игровых объектов и прототипирование с делегированием очень хорошо подходит для их описания. Магический Меч-голова с плеч можно описать как длинный меч с определенными бонусами:

{
  "name": "Sword of Head-Detaching",
  "prototype": "longsword",
  "damageBonus": "20"
}

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *