Pull to refresh

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

Reading time 7 min
Views 48K
Здравствуйте, дорогие хабравчане!

Я хочу поделиться с вами своим опытом использования паттерна проектирования 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);
}

Спасибо за полезный совет!
Tags:
Hubs:
+52
Comments 45
Comments Comments 45

Articles