Виртуальный конструктор

Posted: понедельник, 13 июля 2009 г.
Все мы знаем, что в C++ нет такого понятия как виртуальный конструктор, который бы собирал нужный нам объект в зависимости от каких-либо входных параметров на этапе выполнения. Обычно для этих целей используется параметризованный фабричный метод (Factory Method). Однако мы можем сделать «ход конем» и сымитировать поведение виртуального конструктора с помощью методики, называемой «конверт и письмо» («Letter/Envelope»).

Не помню, где я об этом узнал, но, если я не ошибаюсь, такую технику предложил Джим Коплиен (aka James O. Coplien) в книге «Advanced C++ Programming Styles and Idioms».

Идея состоит в том, чтобы внутри базового класса (Конверта) хранить указатель на объект этого же типа (Письма). При этом Конверт должен «перенаправлять» вызовы виртуальных методов на Письмо. С хорошими примерами у меня, как всегда, небольшие проблемки, поэтому «промоделируем» систему магических техник (или заклинаний) =) Предположим, что для каждой техники используется один из пяти основных элементов (а может и их комбинация), от которых зависит воздействие этой техники на окружающий мир и на предмет, к которому она применяется. В то же время мы хотим иметь возможность работать со всеми техниками независимо от их типа.

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

#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include <vector>

using std::cout;
using std::endl;

enum
{
FIRE      = 0x01,
WIND      = 0x02,
LIGHTNING = 0x04,
SOIL      = 0x08,
WATER     = 0x10
};

class Skill     // aka Jutsu =)
{
public:
// envelope constructor (see below)
Skill(int _type) throw (std::logic_error);

// destructor
virtual ~Skill()
{
if (mLetter)
{
// virtual call in destructor!
erase();
}

delete mLetter;  // delete Letter for Envelope
// delete 0      for Letter
}

virtual void cast() const { mLetter->cast(); }
virtual void show() const { mLetter->show(); }
virtual void erase() { mLetter->erase(); }

protected:
// letter constructor
Skill() : mLetter(NULL) {}

private:
Skill(const Skill &);
Skill & operator= (Skill &);

Skill * mLetter;      // pointer to letter
};


class FireSkill : public Skill
{
public:
~FireSkill() { cout << "~FireSkill()" << endl; }

virtual void cast() const
{
cout << "Katon!" << endl;
}

virtual void show() const
{
cout << "FireSkill::show()" << endl;
}

virtual void erase()
{
cout << "FireSkill:erase()" << endl;
}
private:
friend class Skill;
FireSkill() {}
FireSkill(const FireSkill &);
FireSkill & operator=(FireSkill &);
};


class WoodSkill : public Skill
{
public:
~WoodSkill() { cout << "~WoodSkill()" << endl; }

virtual void cast() const
{
cout << "Mokuton!" << endl;
}

virtual void show() const
{
cout << "WoodSkill::show()" << endl;
}

virtual void erase()
{
cout << "WoodSkill::erase()" << endl;
}
private:
friend class Skill;
WoodSkill() {}
WoodSkill(const WoodSkill &);
WoodSkill & operator=(WoodSkill &);
};


Skill::Skill(int _type) throw (std::logic_error)
{
switch (_type)
{
case FIRE:
mLetter = new FireSkill;
break;

case SOIL | WATER:
mLetter = new WoodSkill;
break;

// ...

        default:
throw std::logic_error(
"Incorrect type of element"
);
}

// virtual call in constructor!
cast();
}


int main()
{
std::vector<Skill*> skills;

try
{
skills.push_back(new Skill(FIRE));
skills.push_back(new Skill(SOIL | WATER));
//      skills.push_back(new Skill(LIGHTNING));
}
catch (std::logic_error le)
{
std::cerr << le.what() << endl;
return EXIT_FAILURE;
}

for (size_t i = 0; i < skills.size(); i++)
{
skills[i]->show();
delete skills[i];
}

return EXIT_SUCCESS;
}



В принципе это не так интересно, но вывод будет следующим:

Katon!
Mokuton!
FireSkill::show()
FireSkill:erase()
~FireSkill()
WoodSkill::show()
WoodSkill::erase()
~WoodSkill()


Давайте лучше разберёмся, что же происходит.

Итак, у нас есть класс Skill (конверт), содержащий указатель на объект такого же типа (письмо). Конструктор копирования и оператор присваивания скроем в private от греха подальше. Основной интерес представляют два конструктора класса, один из которых открытый, а другой защищенный, а также деструктор.

Открытый конструктор, он же конструктор Конверта, он же в нашем случае «виртуальный конструктор» (его определение находится ниже), принимает один параметр — тип «элемента», на основе которого будет вычислен тип конструируемого объекта. В зависимости от входного параметра указатель на письмо инициализируется указателем на конкретный объект (FireSkill, WoodSkill и т.п., которые унаследованы от Skill). В случае, если во входном параметре неверное значение, выбрасывается исключение.

В производных классах техник FireSkill, WoodSkill и т.д. конструкторы по умолчанию закрыты, но базовый класс Skill объявлен как friend, что позволяет создавать объекты этих классов только внутри класса Skill. Конструктор копии и оператор присваивания в этих классах закрыты и не определены. Все виртуальные методы класса Skill переопределены в производных.

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

Когда в виртуальном конструкторе создаются объекты производных классов, при конструировании этих объектов сначала должен вызываться конструктор по умолчанию базового класса. Дефолтный конструктор базового класса ничего не делает, кроме инициализации нулем указателя на письмо. Фактически этот конструктор — конструктор письма, что мы и указываем, записывая в mLetter нуль.

Каким образом происходит вызов виртуальных методов? В базовом классе внутри виртуальных методов идет «перенаправление»: фактически Конверт играет роль оболочки, которая просто вызывает методы Письма. Так как методы Письма вызываются через указатель, то происходит позднее связывание, то есть вызов будет виртуальным. Более того! Мы можем виртуально вызывать методы в конструкторе и деструкторе: при создании объекта Skill (Конверта) происходит вызов параметризованного конструктора этого класса, который конструирует Письмо и инициализирует mLetter. После этого мы вызываем cast(), внутри которого стоит вызов mLetter->cast(). Так как mLetter на этот момент уже инициализирован, происходит виртуальный вызов.

То же самое в деструкторе ~Skill(). Сначала мы проверяем, проинициализирован ли mLetter. Если да, значит мы находимся в деструкторе Конверта, поэтому виртуально вызываем метод зачистки Конверта, а затем его удаляем. Если же нет, значит, мы в деструкторе Конверта, в котором выполняется delete 0 (а эта конструкция вполне безопасна).

Важные моменты:
  1. Все объекты теперь создаются через один конструктор, и дальше мы будто бы работаем с объектом базового класса. Все виртуальные вызовы находятся внутри самого класса. Мы даже можем создать объект класса Skill в стеке — методы этого объекта все равно будут работать будто виртуальные.
  2. В конструкторе и деструкторе мы можем использовать виртуальный вызов методов.
  3. Базовый класс является, можно сказать, в каком-то роде абстрактным, потому что все его виртуальные методы должны быть переопределены в производных классах. Если этого не сделать, это приведет к тому, что, к примеру, mLetter->cast() будет ничем иным как попытка вызвать метод NULL-указателе.
  4. При вызове виртуального конструктора тип создаваемого объекта будет действительно определятся на этапе выполнения, а не на этапе компиляции. Однако такой вызов следует заключать в блок try-catch, иначе можно пропустить исключение.
  5. Если мы захотим добавить в базовый класс еще один виртуальный метод, придется переопределять его во всех производных.


Надеюсь, кому-нибудь пригодится ;)

FIN.

Progg it
blog comments powered by Disqus