22 ноября 2011 в 20:53

Паттерн Visitor. Продвинутое использование из песочницы

Здравствуйте, дорогие хабравчане!

Я хочу поделиться с вами своим опытом использования паттерна проектирования visitor и его интересной модификацией, которую я назвал upcast visitor. К сожалению, непросто придумать простой короткий пример и описать как все работает, также эта статья может показаться сложной для начинающих, тем не менее я постараюсь максимально упростить задачу. Примеры кода приведены на языке С++ и обязательны к прочтению. Без понимания кода вникнуть в суть статьи будет затруднительно.

Предыстория


Представьте, что мы проектируем 2D игру, в которой фрукты падают с дерева, по пути ударяясь о ветки. Цель игры — поймать все фрукты, двигая корзину под деревом.
Строим следующую диаграмму классов:


Следуя принципам ООП, мы объявляем виртуальную функцию рисования в базовом классе и переопределяем ее во всех производных (за исключением класса Fruit).

Например, так выглядит код, отвечающий за рисование яблока:
void Apple::draw()
{
    Graphics& graphics = GetGraphicsFromSomeMagicalPlace();
    Texture& texture = GetAppleTextureFromEvenMoreMagicalPlace();
    graphics.draw(texture, x(), y(), angle());
}

Наш главный метод рисования, который вызывается в игровом цикле, выглядит примерно так:
void Game::draw(std::vector<Object*>& allObjects)
{
    std::vector<Object*>::iterator it = allObjects.begin();
    std::vector<Object*>::iterator end = allObjects.end();
    while(it != end)
    {
       (*it)->draw();
       ++it;
    }
}

Вуху! Полиморфизм — это круто, думаем мы и продолжаем писать игру. Нам хочется реалистично изобразить падение, поэтому мы решаем использовать библиотеку Box2D. Некоторым классам из диаграммы необходимо иметь указатель на объект из Box2D, но не всем. Например, класс Tree не нуждается в таком функционале, его задача только в том, чтобы отрисовать себя и хранить информацию о количестве фруктов. Поэтому мы не будем помещать этот указатель в базовый класс Object, а создадим промежуточный класс Box2DObject, после чего наша диаграмма будет выглядеть примерно так (показана только верхняя часть диаграммы):

Код, отвечающий за создание объектов b2Body, будет находиться в конструкторе каждого производного класса, а код, отвечающий за удаление — в деструкторе. Возьмем к примеру класс Branch:
Branch::Branch()
{
    b2BodyDef bodyDef;
    bodyDef.type = b2_staticBody;
    b2Body* body = GetBox2DWorldFromSomewhrere().CreateBody(&bodyDef);
    b2PolygonShape shape;
    shape.SetAsBox(BRANCH_WIDTH, BRANCH_HEIGHT);
    b2FixtureDef fixtureDef;
    fixtureDef.shape = &shape;
    fixtureDef.friction = BRANCH_FRICTION;
    fixtureDef.restitution = BRANCH_RESTITUTION;
    body->CreateFixture(&fixtureDef);
    m_box2dBody = body;
    body->SetUserData(this);
}

Branch::~Branch()
{
    GetBox2DWorldFromSomewhrere().DestroyBody(m_box2dBody);
}

Опять круто, подумаем мы. Хоть понятие «виртуальность» и не применимо для конструкторов, все же можно провести аналогию с рисованием — каждый класс создает объекты из Box2D по-своему.
Мы замечаем, что код удаления будет одинаковым для всех производных от Box2DObject классов, поэтому логично переместить его в деструктор Box2DObject. Вот так:
Box2DObject::~Box2DObject()
{
    GetBox2DWorldFromSomewhrere().DestroyBody(m_box2dBody);
}

Также код для создания апельсина и яблока ничем не отличается, поэтому переносим его в конструктор базового класса Fruit:
Fruit::Fruit()
{
    b2BodyDef bodyDef;
    bodyDef.type = b2_dynamicBody;
    b2CircleShape shape;
    shape.m_radius = FRUIT_RADIUS;
    b2Body* body = GetBox2DWorldFromSomewhrere().CreateBody(&bodyDef);
    b2FixtureDef fixtureDef;
    fixtureDef.shape = &shape;
    fixtureDef.density = FRUIT_DENSITY;
    fixtureDef.friction = FRUIT_FRICTION;
    fixtureDef.restitution = FRUIT_RESTITUTION;
    body->CreateFixture(&fixtureDef);
    m_box2DBody = body;
    body->SetUserData(this);
}

Вот, теперь вроде все работает, но есть несколько проблем:
— Код, отвечающий за рисование и работу с библиотекой Box2D хаотически разбросан по всем классам.
— Интерфейсы для работы с графикой и Box2D необходимо «протаскивать» через цепочку конструкторов иерархии и сохранять в базовых классах (а иначе как же к ним обращаться)?
— При большом количестве классов в иерархии код может компилироваться намного дольше, потому что заголовочные файлы для работы с графикой и Box2D включаются в каждый cpp файл.
— При добавлении новой функциональности прийдется модифицировать все классы в иерархии.

Паттерн Visitor


Как же избежать этих проблем?
Конечно же, нужно воспользоваться паттерном проектирования visitor! Если вы с ним знакомы, вы наверное, уже догадались, что у нас будут visitor’ы для рисования и для Box2D. Если не знакомы — сейчас все поймете, это очень полезный паттерн, который я обожаю использовать.
Нам понадобится четыре новых класса: Visitor, Painter, Creator и Destroyer:

Все методы по работе с графикой уйдут в Painter, а по работе с Box2D — в Creator и Destroyer. Также нам нужно добавить чисто виртуальный метод accept в базовый класс Object и реализовать его во всех листьях иерархии.
class Object
{
public:
    virtual void accept(Visitor& visitor) = 0;
    //…Everything else
};

Иерархия после этого станет такой:

Реализация метода accept одинакова абсолютно во всех листьях и состоит из одной строчки.
class Tree : public Object
{
public:
    virtual void accept(Visitor& visitor)
    {
        visitor.visit(*this); // Superhard to understand single line of code
    }
    //…Everything else
};

Когда я первый раз знакомился с паттерном visitor, эта строка кода вынесла мне мозг и я ничего не понял. Через несколько месяцев я взял книгу банды четырех и смог осилить этот паттерн. Смысл в том, что при разыменовании указателя this из интерфейса Visitor выбирается необходимая функция (работает перегрузка по типу параметра). Другими словами, когда эта строка вызывается из класса Tree, мы попадаем в void visit(Tree& tree), когда вызывается из класса Basket — мы попадаем в void visit(Basket& basket). И в каждом конктетном visitor’e уже делаем с объектом что хотим. К примеру возьмем рисование:
void Painter::visit(Apple& apple)
{
    m_graphics.draw(m_appleTexture, apple.x(), apple.y(), apple.angle());
}

void Painter::visit(Tree& tree)
{
    m_graphics.draw(m_treeTexture, tree.x(), tree.y());
}
В зависимости от типа параметра мы рисуем яблоко или дерево. Тот же принцип в классе Creator:
void Creator::visit(Basket& basket)
{
   // Setup basket b2Body
}

void Creator::visit(Branch& branch)
{
    // Setup branch b2Body
}

Наш общий метод рисования, который вызывается из игрового цикла, станет таким:
void Game::draw(std::vector<Object*>& allObjects)
{
    Painter& painter = GetPainter();
    std::vector<Object*>::iterator it = allObjects.begin();
    std::vector<Object*>::iterator end = allObjects.end();
    while(it != end)
    {
       (*it)->accept(painter);
       ++it;
    }
}

При создании объекта нам нужно будет посетить его Creator’ом, а при удалении — Destroyer’ом. Я считаю, что это лучше, чем помещать код в конструктор и деструктор.
Итак, мы решили все проблемы, которые у нас возникли. Теперь:
— Код, отвечающий за рисование, находится в одном файле. В двух других файлах находится код для работы с библиотекой Box2D.
— Доступ к интерфейсам для работы с графикой и Box2D нужен только из классов Painter, Creator и Destroyer.
— Код компилируется быстро, потому что заголовочные файлы для работы с графикой и Box2D включаются только в painter.cpp, creator.cpp и destroyer.cpp.
— При добавлении новой функциональности придется модифицировать только Visitor’ы. Классы иерархии остаются прежними.

Прослойка UpcastVisitor


Тут можно было бы закончить и насладиться проделанной работой, но мы с ужасом замечаем, что в нескольких местах у нас дублируется код. В классе Destroyer все методы имеют одинаковый вид, например, для яблока и апельсина используется один и тот же код:
void Destroyer::visit(Apple& apple)
{
    m_box2dWorld.DestroyBody(apple.b2Body());
}

void Destroyer::visit(Orange& orange)
{
    m_box2dWorld.DestroyBody(orange.b2Body());
}

Посещение всех классов, унаследованных от Box2DObject реализовано в Destroyer'е одинаково. Приходится выносить дублирующийся код в дополнительный метод visitBox2DObject и вызывать его отовсюду:
void Destroyer::visitBox2DObject(Box2DObject& object)
{
    m_box2dWorld.DestroyBody(object.b2Body());
}

void Destroyer::visit(Apple& apple)
{
    visitBox2DObject(apple);
}

void Destroyer::visit(Orange& orange)
{
    visitBox2DObject(orange);
}

А так не хочется делать этого. Хочется, чтобы вместо кучи перегруженных методов в классе Destroyer, которые содержат один и тот же код, был всего один с «правильным» типом параметра — Box2DObject. И чтобы он работал для Apple, Orange, Basket и т.д., к примеру:
class Destroyer : public Visitor
{
public:
    void visit(Box2DObject& object)
    {
        m_box2dWorld.DestroyBody(object.b2Body());
    }
    // ...
};

Рискнем и добавим этот метод в базовый класс:
class Visitor
{
public:
    void visit(Box2DObject& object);
    // …
};

К сожалению, он никогда не вызовется, потому что в классе Box2DObject нету реализации метода accept. А даже если бы и была, она бы перегружалась в производных классах и результат был бы тот же — метод visit с типом параметра Box2DObject никогда бы не вызвался. Тут то мне в голову и пришла идея visitor’a-прослойки между базовым классом Visitor и классами Creator и Destroyer. Я назвал эту прослойку UpcastVisitor. Вот так изменится иерархия наших visitor'ов:

Теперь самое главное — код:
void UpcastVisitor::visit(Object& object)
{
}

void UpcastVisitor::visit(Box2DObject& object)
{
    visit(static_cast<Object&>(object));
}

void UpcastVisitor::visit(Fruit& fruit)
{
    visit(static_cast<Box2DObject&>(fruit));
}

void UpcastVisitor::visit(Apple& apple)
{
    visit(static_cast<Fruit&>(apple));
}

void UpcastVisitor::visit(Orange& orange)
{
    visit(static_cast<Fruit&>(orange));
}
Если к UpcastVisitor’у попадает объект типа Apple, он приводит его к типу Fruit, потом к типу Box2DObject и в самом конце к типу Object. По такому же принципу каждый посещаемый класс в UpcastVisitor'e проходит по цепочке иерархии к самому базовому. Теперь наследуем класс Destroyer от UpcastVisitor и радуемся! Если UpcastVisitor реализован правильно, то нам необходимо перегрузить лишь один метод.
class Destroyer : public UpcastVisitor
{
public:
    virtual void visit(Box2DObject& object)
    {
        m_box2dWorld.DestroyBody(object.b2Body());
    }
    // ...
};


UpcastVisitor пригодится вам также в классе Creator, потому что код создания сущностей Box2D одинаков для яблока и апельсина:
class Creator : public UpcastVisitor
{
public:
    virtual void visit(Fruit& fruit)
    {
        // Setup fruit’s Box2D body
    }
    // …
};

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

Если вы уже пользуетесь паттерном visitor, и у вас возникала подобная ситуация, можете смело создавать класс UpcastVisitor. Это очень просто, нужно лишь добавить методы посещения всех промежуточных типов в базовый класс Visitor а также следить и обновлять UpcastVisitor по мере появления новых типов в иерархии. Ну и наследоваться от него по мере необходимости.

Спасибо за внимание! Надеюсь, эта статья будет кому-то полезной!

Update:
7vies подсказывает, что в UpcastVisitor'е можно избавиться от static_cast следующим образом:
void UpcastVisitor::visit(Box2DObject& object) 
{
    Object& o = object;
    visit(o);
}

Спасибо за полезный совет!
@beemaster
карма
60,0
рейтинг 0,0
Самое читаемое Разработка

Комментарии (45)

  • +2
    Где ссылка на игру? :)

    А вообще практика хорошая и называть ее UpcastVisitor нет смысла, потому как визитор для того и придуман чтобы создавать иерархии под конкретные задач. Вы не придумали нровый паттерн, а применили существующий. Но за статью спасибо. Плюсанул.
    • +1
      А как же тогда называть такую практику? И куда помещать код, который находится в классе UpcastVisitor? Насчет существующего паттерна — покажите мне пожалуйста, где такое используется. Вы, возможно, не до конца поняли, что вся новизна находится в конце статьи, после длинной предыстории и объяснения того, зачем вообще нужно использовать visitor.

      Ссылка на игру будет в следующей статье :)
      • +1
        Ну, все что я увидел — это здоровая практика, убрал копи-паст, вынес его в отдельный класс. Если раньше вы писали одно и тоже в классе Creator и Destroyer, то теперь это все в UpcastVisitor, который наследуют Creator и Destroyer. Это как-то на новизну, не тянет, мне кажется это сделал бы любой здравомыслящий программист. Или я не прав?
        • 0
          В UpcastVisitor'е нет ничего из классов Creator и Destroyer, он никак не взаимодействует с библиотекой Box2D, там просто происходит преобразование типов. UpcastVisitor позволяет в Destroyer'e избежать перегрузки четырех методов. Вместо этого перегружается только один — для типа Box2DObject. Любой здравомыслящий программист остановился бы на том, что все эти четыре перегрузки вызывали бы один и тот же метод. Я же пошел дальше и превратил четыре перегрузки в одну.
  • +5
    Автор, спасибо за статью.
    Все было прекрасно до момента использования UpcastVisitor. Код сильно не сокращает, но для понимания — усложняет.
  • +1
    Мне кажется, или Вы, убрали все методы кроме одного из Visitor'a (а их там было по количеству листьев) наплодили в upcastVisitor'e методов еще больше (метод есть для каждого класса иерархии), т.е. код Вы только увеличили (ну зато он не дублируется, да=) ). Польза будет сомнительна даже если в Visitor'e (ну Destroyer'e в данном случае), будут одинаковые методы из большого числа строк, конечно, возможно код уменьшится, но все равно для каждого класса придется писать метод в UpcastVisitor'e, а трудозатраты на копипаст одинаковы (хотя его можно было бы и автоматизировать).

    Поправьте меня, если я не прав, но мне показалось, что все обстоит именно так.
    • +1
      Если у вас в программе большое количество visitor'ов (например 10), и половина из них будет пользоваться UpcastVisitor'ом, то кода будет меньше. Просто я пытался привести простой пример. В UpcastVisitor'e каждый метод всегда состоит всего из одной строчки, его не сложно добавить при создании нового класса.
      • 0
        Да, согласен… фактически он будет аккумулировать дублирующийся код.
  • +2
    Поздравляю вас, вы написали код с повышенной двусторонней связностью (каждый объект знает о базовом визиторе, визитор знает обо _всех_ объектах). Добавили новый объект — добро пожаловать в каждый визитор обновлять методы Visit. А главное — зачем?

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

    Причем с точки зрения поддержки наиболее выгодным оказывается писать визиторы, которые содержат внутри себя минимум логики (в особенности объектоспецифичной), а саму эту логику переносить обратно в посещаемый объект.
    • +3
      Базовый Visitor всегда знает о всех классах иерархии. По крайней мере о всех листьях. Такой уж это паттерн. Традиционное применение — разделение объектов и алгоритмов для работы с ними. Переносить логику обратно в посещаемый объект противоречит самой идее паттерна.
      Обход своих детей — это дополнительная фича, которой, наверное, часто пользуются. Но эта фича никак не противоречит и не мешает работе UpcastVisitor'а. Например, если бы дерево хранило все ветки:
      class Tree
      {
      public:
          virtual void accept(Visitor& visitor)
          {
              for (int i = 0; i < m_branches.size(); ++i)
              {
                  m_branches[i].accept(visitor);
              }
              visitor.visit(*this);
          }
          //...
      };
      

      Все прекрасно работает, UpcastVisitor посещает все объекты, как и планировалось.
  • +1
    «Базовый Visitor всегда знает о всех классах иерархии. По крайней мере о всех листьях.»
    Вот это и создает геморрой при появлении новых классов иерархии. Редкостный.

    «Традиционное применение — разделение объектов и алгоритмов для работы с ними.»
    Спасибо, кэп. В результате, как сказано выше, получаем набор сильно связанных классов. Для неспецифичных алгоритмов (навроде клонирования) работает еще неплохо, а для специфичных — получается совсем плохо, потому что код класса и (частная!) работа с ним разнесены, что усложняет понимание.

    Учитывая редкостную непрозрачность паттерна, я без самой крайней необходимости его не использую и всячески избегаю.
  • 0
    При появлении новых классов нужно добавить один метод в самый базовый Visitor, с пустой реализацией. Разве это сложно?

    Обычно при наличии visitor'ов из классов иерархии выносится большая часть алгоритмов, в этом нет ничего необычного, затем он и нужен, этот паттерн. Да, это вносит некоторую сложность, зато с лихвой окупается гибкостью.
    • 0
      «Разве это сложно?»
      Это просто. Только после этого надо _не забыть_ — и это никак не лечится компилятором — добавить оверрайды там, где это нужно. Собственно, отсутствие поддержки компилятором — тоже недостаток этого паттерна.

      «Обычно при наличии visitor'ов из классов иерархии выносится большая часть алгоритмов, в этом нет ничего необычного, затем он и нужен, этот паттерн.»
      Я склонен думать, что он нужен для обратного — для того, чтобы не дублировать код обхода узлов. В этом его основной смысл. А для выноса алгоритмов используется паттерн «стратегия».

      «Да, это вносит некоторую сложность, зато с лихвой окупается гибкостью. „
      “Don't write smart code». Визитор — классический пример смарткода. Я сторонник простых решений, пусть даже для этого придется пожертвовать какой-то (чаще всего оказывающейся ненужной) гибкостью, потому что поддерживать это потом обычным людям. Паттерн «визитер» в поддержке очень дорог, это проверено личным опытом.

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

    В вашем случае визитор вообще не нужен по одной простой причине: для того, чтобы нарисовать все объекты в игровом мире, вам достаточно последовательно вызывать painter.visit(*it). В коде _вообще_ ничего не изменится, а вот читаемость мгновенно вырастет.
    • +1
      Если вы так напишете, то все перестанет работать, потому что именно из метода accept визитор получает информацию о типе объекта. А если написать painter.visit(*it), то будет вызываться заглушка из базового класса Visitor::visit(Object& object) и ничего не нарисуется.
      • –1
        А вот это свойство не паттерна, а конкретного языка, где один указатель разыменовывается одним образом, а другой — другим. В других языках это будет работать без костылей, и получится, что ваш паттерн не нужен. Учитывая, что паттерн всегда выше языка, это означает, что паттерн применен не по делу (а для «умного» обхода ограничений языка).

        Повторюсь, существенная часть паттерна visitor (и единственная, оправдывающая его существование) — это операции с объектными структурами (а не просто выбор нужного оверлоада по типу), где только конкретный объект знает свою структуру.
        • +3
          В С++ указатели всегда разыменовываются одним образом. В Java и C# нет указателей, но ситуация там аналогичная и паттерн visitor применяется точно так же. О каких костылях и каких языках идет речь?

          Я с вами не согласен, в первую очередь паттерн visitor используется для отделения структуры объектов от алгоритмов, работающих с ней, позволяя добавлять новые методы для работы с классами без модификации этих классов, а так же изменять существующие алгоритмы в одном месте, а не в каждом отдельном классе.
          • +1
            «В Java и C# нет указателей, но ситуация там аналогичная и паттерн visitor применяется точно так же. „
            В java и c# есть рефлексия, и там можно обойтись без double dispatch.

            “в первую очередь паттерн visitor используется для отделения структуры объектов от алгоритмов, работающих с ней»
            Слово «структура» в этом предложении вам ни о чем не говорит?
            • 0
              Хотел уточнить у вас какой подход вы практикуете для работы с объектами разных классов с разными интерфейсами?
              Например, у нас есть сервис, который получает команды от пользователя и отдает ему ответ. Притом сами запросы и ответы статичны и определены в доменной модели, меняется всего лишь транспорт доставки ответа клиенту. Хотелось бы чтобы доменная модель при возврате ответа играла роль Producer, а конкретный транспорт Consumer. И хотелось бы чтобы Producer генерировал «рабочие элементы» в каком-то обобщенном виде. В таком случае Consumer видит только некий базовый класс/интерфейс ClientResponseBase. И хотелось бы не смешивать иерархию ClientResponseBase с конкретными транспортами. И Vizitor имеет свои плюсы, есть возможность запихнуть всю логику упаковки всех вариантов ответа для одного конкретного транспорта в один класс и она больше не всплывет нигде.
              Если не сложно поделитесь опытом решения схожих задач.
              • 0
                Притом сами запросы и ответы статичны и определены в доменной модели, меняется всего лишь транспорт доставки ответа клиенту. Хотелось бы чтобы доменная модель при возврате ответа играла роль Producer, а конкретный транспорт Consumer. И хотелось бы чтобы Producer генерировал «рабочие элементы» в каком-то обобщенном виде.

                Все придумано до нас в WCF и/или WebAPI (и даже еще раньше, когда придумывали XmlSerializer и ISerializable). Такие задачи традиционно решаются с помощью конвенций по форматированию DTO (не доменной модели, как у вас, использовать доменную модель в качестве DTO опасно) + разметки атрибутами, если конвенций не хватает. Дальше отдельный уровень (форматер), опираясь на эти конвенции, преобразует DTO в транспортный слой и обратно.
                • 0
                  Может быть вы меня не так поняли, я имел ввиду что когда мы пытаемся упаковать в конкретный транспорт разные типы сообщений(которые приходят в виде обобщенного класса/интерфейса) я не вижу способа избежать typeof. А если пытаться впихнуть детали транспорта пусть даже в DTO, то во-первых, специфичный код транспорта начинает размазываться, а во вторых с увеличение числа различных транспортов эти DTO начинают превращаться в свалку.
                  Мне как-то не очень нравится вариант с внесением специфичной для транспорта логики в DTO и для меня остается вариант с typeof, что мне очень не нравится.
                  Double dispatch в лице Visitor позволяет решить проблему typeof и загрязнения DTO деталями транспорта. Вся транспорто-специфичная логика уходит в Visitor.
                  • 0
                    То, что вы описываете — это совершенно типовая задача, многократно решенная во всех системах удаленного взаимодействия.

                    То, что вы называете «транспортом», на самом деле, состоит из двух частей — форматирующей и передающей/принимающей, причем первая может зависеть от второй (способ передачи влияет на формат). Задача форматирования, решаемая в первой части — это то, что, опять-таки, многократно решено под названием «сериализация».

                    В подавляющем большинстве случаев сериализация сводится к тому, чтобы получить коллекцию туплов имя-тип-значение, которая потом уже приводится форматтером к конкретному виду, нужному для передачи. Для того, чтобы получить такую коллекцию, визитор избыточен — достаточно метаданных объекта (если платформа их поддерживает) или простейшего интерфеса (если метаданных нет или недостаточно). Как пример, в .net все это реализовано как минимум трижды (в Serializable, DataContract и XmlSerializer), и никакого визитора не требует.

  • 0
    Паттерн интересный. Но при большой иерархии классов можно будет элементарно запутаться, особенно если к программе придется вернуться через какое-то время.
    И UpcastVisitor нам жизнь не облегчил — код-то все равно дублируется.
    • 0
      Извините, не совсем вас понял. Где дублируется код?
  • +1
    К слову, это один из примеров static_cast'а не к месту, было бы достаточно implicit cast в духе
    visit(Box2DObject& object) {
    Object& o = object;
    visit(o);
    }

    Естественно, при желании оно заворачивается в темплейт.
    • 0
      Спасибо, обновил статью.
  • 0
    1. Помоему любой cast — это «запах», говорящий о том что введен лишний уровень абстракции: мы как бы говорим, что яблоки и автомобильные шины «родственны», но при работе с ними все равно применяем конкретно яблоки и конкретно шины. В чем тогда «родственность»?

    2. Я возможно заблуждаюсь (потому, что не вижу весь код целиком и не доконца представляю его окончательный вид), но помоему Object и его потомки «бедноваты». Они являются носителями данных и только: accept выполняющий передачу управления переданному в параметре визитору выглядит странно. С таким же успехом это мог бы выполнить клиент этих классов.
  • 0
    Мне кажется, что пример для иллюстрации не самый удачный, поскольку посетитель уже знает обо всех конечных классах и эта зависимость прописана в его интерфейсе. Кроме того в реальном приложении типов объектов будет с несколько десятков как минимум, что приведет к разбуханию классов-посетителей.
    В данном конкретном примере напрашивается вынесение логики отрисовки и удаления в отдельный класс для каждого типа объектов, то есть создание интерфейса типа
    class IDrawingStrategy
    {
    public:
    virtual void Draw(Object *obj) = null;
    ...
    }

    И в дальнейшем каждому объекту надо присвоить соответствующую стратегию. При этом единственное место, где потребуется информация о всех классах иерархии — это фабричный метод получения стратегии по типу объекта.
    • 0
      Разбухать будет только базовый класс Visitor и UpcastVisitor, все остальные визиторы будут реализовывать только то, что им нужно. Это абсолютно нормально.
      Если я все правильно понял, вы хотите хранить в каждом объекте как минимум три стратегии — для отрисовки, создания и удаления. С таким подходом в реальном приложении при большом количестве объектов занимаемая память может увеличиться в несколько раз. Паттерн visitor лишен этого недостатка, т.к. в объекте ничего не хранится.
      Также при добавлении нового типа стратегии вам нужно будет модифицировать все классы иерархии, добавляя в них вызовы метода этой стратегии. В случае использования паттерна visitor вам просто нужно будет создать новый тип visitor'а, что намного проще.
      • 0
        >Разбухать будет только базовый класс Visitor и UpcastVisitor, все остальные визиторы будут реализовывать только то, что им нужно
        Не очень хорошо, когда базовый класс знает о конечных компонентах.

        >Если я все правильно понял, вы хотите хранить в каждом объекте как минимум три стратегии
        Теоретически их можно объединить. Один указатель — все-таки не очень большой перерасход памяти. Хотя, не спорю, в некоторых случаях и он может быть критичным.

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

        > В случае использования паттерна visitor вам просто нужно будет создать новый тип visitor'а, что намного проще.
        Не совсем так, если нужна новый тип (не имплементация) стратегии — то на языке посетителей это означает, что надо добавить новый метод в базовый класс посетителя и поменять методы объектов, чтобы они новый функционал делали через посетителя.
        • 0
          >надо добавить новый метод в базовый класс посетителя и поменять методы объектов, чтобы они новый функционал делали через посетителя

          Нет, не нужно ничего добавлять в базовый класс посетителя и менять методы объектов тоже не нужно. В объектах для поддержки паттерна visitor должен быть только один метод accept.
          Простой пример — вы пользуетесь паттерном visitor только для рисования, у вас уже есть класс Painter. Захотели добавить сохранение состояния программы — добавляете новый класс Saver, который унаследован от Visitor. В нем находится примерно следующее:
          void Saver::visit(Tree& tree)
          {
              m_document.addNode("tree");
          }
          
          void Saver::visit(Apple& apple)
          {
              m_document.addNode("apple");
          }
          

          При этом вносить изменения в существующие классы и базовый Visitor не нужно.
          Пользоваться Saver'ом нужно следующим образом:
          void MainGameClass::saveAllObjects(std::vector<Object*> objects)
          {
              for (int i = 0; i < objects.size(); ++i)
              {
                  objects[i]->accept(m_saver);
              }
              m_saver.saveToDisk();
          }
          
          • 0
            Я имел ввиду если существующая логика перекладывается на плечи посетителя.
            Если добавляется новая логика, то ее можно или добавить в существующую стратегию, или создать новую, но при этом саму стратегию надо будет вызывать только в базовом классе объекта.
  • 0
    Что скажете, если заменить шаблон visitor частичной специализацией шаблонной функции.
    Кажется плюсы Visitor все соблюдаются. Реализацию методов accept() можно вынести в отдельный файл «Visitor'a». Только конечное дествие происходит уже не Visitor, а в «посещаемом классе».
    //main.cpp
    void _tmain(int argc, _TCHAR* argv[])
    {
    	Drawer drawer; //аналог visitor
    	Orange orange;
    	orange.accept(drawer);
    }
    
    //Orange.h
    class Orange
    {
    public:
    	template<class ActionState> void accept(ActionState& as);
    };
    
    //orange.cpp
    template<> void Orange::accept(Drawer& as){
    	puts("accept drawer");
    }
    
    • 0
      s/частичной специализацией/специализацией/
    • 0
      Вы пишете, что реализацию методов accept() можно вынести в отдельный файл visitor'a, а в вашем коде реализация в файле orange.cpp, не совсем понятно.
      Если конечное действие происходит в посещаемом классе, то visitor не нужен, сойдет обычный виртуальный метод:
      class Object
      {
      public:
          virtual void draw() = 0;
      };
      
      class Orange : public Object
      {
      public:
          virtual void draw() { puts("draw orange"); }
      };
      
      int main()
      {
          Object* orange = new Orange();
          orange->draw();
          return 0;
      }
      

      Смысл visitor'а именно в том, чтобы вынести действие из посещаемого класса.
      • 0
        1. Реализацию Orange::accept(Drawer& as) можно перенести в drawer.cpp.
        2. Виртуальный метод как раз не используется, потому что определение Orange не зависит от типа ActionState(можно менять, добавлять классы ActionState). Это один из плюсов шаблона Visitor.
        • 0
          s/, потому что/потому, что/
        • 0
          Самый большой недостаток вашей идеи в том, что метод accept в классе Orange невиртуальный. Соответственно вы не сможете хранить посещаемые объекты по указателям на базовый класс. Если же вы не храните объекты по указателям на базовый класс, то исчезает необходимость использовать visitor, можно обойтись простой перегрузкой методов:
          class Apple {};
          class Orange {};
          
          class Drawer
          {
          public:
              void draw(Apple& apple);
              void draw(Orange& orange);
          };
          
          int main()
          {
              Drawer drawer;
              Apple apple;
              Orange orange;
              drawer.draw(apple);
              drawer.draw(orange);
              return 0;
          }
          
  • 0
    Вот мне иногда в PHP не хватает перегрузки методов, это ещё один отличный пример, почему её хочется.
  • 0
    Пользуясь случаем спрошу: мне одному кажется что т.н. «патттерн Visitor» — это не более чем double-dipatch (+адаптация для языков в которых нет перегрузки методов) в новой маркетинговой упаковке?
    • 0
      Поздравляю вас, если вы понимаете, что visitor и double dispatch это связанные вещи, то вы понимаете, как работает паттерн, хотя visitor — это не double dipatch. Правильнее говорить: «использует double dispatch» или «основан на double dispatch». Из википедии: «The visitor takes the instance reference as input, and implements the goal through double dispatch.»
      Double dispatch сам по себе не подразумевает наличие иерархии, как посещаемых объектов, так и visitor'ов.
      Простой пример double dispatch'а:
      class Human
      {
      public:
          void eat(Apple& apple) { cout << "Yum-yum"; }
          void eat(Orange& orange) { cout << "Yum-yum-yuuuum!"; }
      };
      
      class Apple
      {
      public:
          void feed(Human& human) { human.eat(*this); }
      };
      
      class Orange
      {
      public:
          void feed(Human& human) { human.eat(*this); }
      };
      
      int main()
      {
          Human human;
          Apple apple;
          apple.feed(human);
          Orange orange;
          orange.feed(human);
          return 0;
      }
      

      Как видим, тут нет базового класса visitor'a и базового посещаемого класса, а также ключевого слова accept. Вместо этого голый double dispatch :)
      • 0
        Есть задача: единообразно обойти коллекцию классов и в зависимости от типа текущего элемента коллекции выполнить некоторые действия.
        Если ей решить в виде кучи if instance of — получится мрак, если её решить используя двойную диспетчеризацию — - получится то что все называют «паттерн Visitor». Но как-то мне это кажется недостаточным для того чтобы плодить понятия и создавать «паттерн» и дальше пытаться насаждать его.

        «Как видим, тут нет базового класса visitor'a и базового посещаемого класса, а также ключевого слова accept. Вместо этого голый double dispatch :)» Естественно там нет всех этих buzzwords — они же являются частью маркетинговых украшений — зрите в корень.

        Ещё любителям «паттернов»:
        В чём принципиально отличие DTO и ViewModel, ChainofResponibility от последовательности Proxy которые в свою очередь являются применением принципов SOLID к проектированию. С сожалением прихожу к выводу, что отрасль поражает мода на bullshiting пришедшая из маркетинга, когда простые вещи у которых уже есть название стараются называть всё новыми и новыми терминами.


        • 0
          Я могу продолжить ваш список. Мало кто из начинающих Java программистов знает, что такое observer, зато все прекрасно понимают, что такое listener. Я считаю, не нужно расстраиваться по этому поводу, а стараться уметь оперировать различными терминами, маркетинговыми и не очень.
          Ведь buzzwords в паттернах играют огромную роль, поскольку позволяют быстро объяснить или узнать, как взаимодействуют компоненты в системе. Одна из основных задач паттерна — описать общеиспользуемый подход одним своим названием.
      • 0
        Я извиняюсь за приступ некрофилии, мне кажется что в вашем случае это не double dispatch. Как гласит Википедия
        In software engineering, double dispatch is a special form of multiple dispatch, and a mechanism that dispatches a function call to different concrete functions depending on the runtime types of two objects involved in the call.

        У вас же все типы объектов известны на стадии компиляции. Диспетчеризацией и не пахнет. В англоязычной вики есть простой пример именно double dispatching.
  • 0
    Агрегатором Box2DObject в Вашем случае является Box2DObject.
    Следовательно, логичнее всего поместить его уничтожение в деструктор объекта Box2DObject.
    И не порождать лишних сущностей.
    Так что если использование Creator'а еще обосновано, то Destroyer, IMNSHO, не нужен в данном случае.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.