Привет, Хабр! Меня зовут Михаил Полукаров, я занимаюсь разработкой Desktop-версии корпоративного супераппа для совместной работы VK Teams.

Если вы тоже работали с большими проектами, где активно применяются объектно-ориентированные паттерны проектирования, то наверняка сталкивались с паттернами проектирования Factory Method или AbstractFactory. В процессе разработки я неоднократно ловил себя на мысли, что часто пишу однотипный код таких фабрик, и задумался о том, как можно было бы избежать таких самоповторений. 

В этой статье я покажу, как сделать универсальную фабрику объектов, покрывающую большую часть потребностей, следующую принципам DRY (Don’t Repeat Yourself), а также как можно использовать некоторые «фишки» новых стандартов С++. 

Примечание: Полный код примеров вы сможете найти на странице проекта в GitHub. Для нашего проекта мы используем стандарт C++17 и библиотеки Qt, поэтому все дальнейшие рассуждения и примеры кода будут использовать именно этот стек.

Введение

Фабричный метод и абстрактная фабрика являются одними из самых часто используемых паттернов проектирования. Одной из причин этого является отсутствие в C++ встроенных в язык механизмов «виртуального» конструирования объектов: виртуальный деструктор есть, а такого же (или похожего) виртуального конструктора — нет. Связано это со множеством факторов и решений самого языка, обсуждение которых выходит далеко за рамки этой статьи. 

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

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

class Shape
{
public:
    virtual ~Shape() = default;
    // ...
};

class Circle : public Shape { ... };
class Rectangle : public Shape { ... };
class Line : public Shape { ... };

Dummy Factory

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

class ShapeFactory
{
    class Creator
    {
        virtual ~Creator() = default;
        virtual Shape* create() const = 0;
    };

    template<class _Shape>
    class ShapeCreator : public Creator
    {
        virtual Shape* create() const override
        {
             return new _Shape;
        }
    };
    using CreatorPointer = std::unique_ptr<Creator>;

public:
     template<class _Shape>
     void registrate(const std::string& key)
     {
           static_assert(std::is_base_of_v<Shape, Shape>, "Shape must be derived from Shape");
           auto[it, inserted] = creators_.emplace(key, {});
           if (inserted)
                 it->second.reset(new Creator<_Shape>);
      }

      Shape* createShape(const std::string& key) const
      {
            auto it = creators_.find(key);
            return it != creators_.end() ? it->second->create() : nullptr;
      }

private:
       std::unordered_map<std::string, CreatorPointer> creators_;
};

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

ShapeFactory factory;
factory.registrate<Circle>("cirlce");
factory.registrate<Rectangle>("rectanle");
factory.registrate<Line>("line");
factory.registrate<MyShape>("myshape"); // новый тип фигуры
std::vector<std::unique_ptr<Shape>> shapes;
shapes.emplace_back(factory.create("circle"));
shapes.emplace_back(factory.create("rectangle"));
// ... и так далее

Вроде бы все хорошо: нужные объекты создаются, фабрика работает.

Однако мы можем несколько улучшить код и повысить его безопасность и эффективность. Для этого достаточно использовать std::function<> вместо Creator и ShapeCreator<_Shape>. Таким образом мы избавимся:

  • от семантики указателей — std::function<> позволит заменить pointer-semantic на value-semantic, а значит, сможем избежать работы с указателями и памятью напрямую;

  • аллокаций в heap — поскольку std::function<> использует Small-Value-Optimization;

  • сложного кода — код будет короток и прямолинеен.

Кроме прочего, становится возможным предоставить метод кастомного создания объектов:

class ShapeFactory
{
public:
    using Creator = std::function<Shape*()>;

    template<class _Shape>
    void registrate(const std::string& key, const Creator& c)
    {
        static_assert(std::is_base_of_v<Shape, Shape>, “Shape must be derived from Shape”);
        creators_[key] = c;
    }

    template<class _Shape>
    void registrate(const std::string& key)
    {
        static_assert(std::is_base_of_v<Shape, Shape>, “Shape must be derived from Shape”);
        creators_[key] = []() -> Shape* { return new _Shape; };
    }

    Shape* createShape(const std::string& key) const
    {
        auto it = creators_.find(key);
        return it != creators_.end() ? (it->second)() : nullptr;
    }

private:
     std::unordered_map<std::string, Creator> creators_;
};

Однако проблем здесь все еще много:

  • Тип ключа фиксирован, а это значит, что, если мы захотим его сменить, придется менять много кода.

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

  • Тип базового класса фиксирован, а это значит, что для другой иерархии классов придется переизобретать всю фабрику с нуля.

Первые шаги к Generic Factory

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

  • тип ключа должен задаваться извне;

  • тип базового класса должен задаваться извне;

  • типы и количество аргументов должны задаваться извне;

  • нужна возможность задавать способ распределения памяти;

  • нужно избегать использования механизмов RTTI;

  • должна быть доступна библиотека Qt и Qt Meta-Object System.

Будем пытаться реализовать эти пункты последовательно.

Типы ключа и базового класса

Первые два пункта просты в реализации — достаточно сделать класс фабрики шаблонным:

template<
    class _Key, // тип ключа
    class _Base // тип базового класса
>
class ObjectFactory
{
public:
    using Creator = std::function<_Base*()>;

    template<class _Product>
    void registrate(const _Key& key, const Creator& c)
    {
        static_assert(std::is_base_of_v<_Base, Product>, "Product must be derived from _Base");
        creators_[key] = c;
    }
   
    template<class _Product>
    void registrate(const _Key& key)
    {
        static_assert(std::is_base_of_v<_Base, Product>, "Product must be derived from _Base");
        creators_[key] = []() -> Base* { return new Product; };
    }

    _Base* createShape(const Key& key) const
    {
         auto it = creators_.find(key);
         return it != creators_.end() ? (it->second)() : nullptr;
    }

private:
    std::unordered_map<_Key, Creator> creators_;
};

Сам код практически не претерпел серьезных изменений, но теперь нет необходимости писать класс фабрики для очередной иерархии. В случае с Shape, например, весь код фабрики сводится к одной строке:

using ShapeFactory = ObjectFactory<QString, Shape>;

Может показаться, что и остальные пункты можно так же легко закрыть, но тут-то и таится главная проблема. 

Аргументы и их типы

Давайте попробуем самый простой и очевидный способ указания аргументов конструктора: просто добавим variadic template параметры в шаблон класса ObjectFactory:

template<
    class _Key, // тип ключа
    class _Base, // тип базового класса
    class... _Args // типы аргументов
>
class ObjectFactory
{
public:
    using Creator = std::function<_Base*(_Args&&...)>;

    template<class _Product>
    void registrate(const _Key& key, const Creator& c)
    {
        static_assert (std::is_base_of_v<_Base, Product>, "Product must inherited from _Base");
        creators_[key] = c;
    }

    template<class _Product>
    void registrate(const _Key& key)
    {
        static_assert (std::is_base_of_v<_Base, Product>, "Product must inherited from _Base");
        creators_[key] = [](_Args&&... args) { return new Product(std::forward<Args>(args)...); };
    }

    Base* create(const Key& key, _Args&&... args) const
    {
        auto it = creators_.find(key);
        return it != creators_.cend() ? (it->second)(std::forward<_Args>(args)...) : nullptr;
    }

private:
    std::unordered_map<_Key, Creator> creators_;
};

Подход вполне рабочий — теперь мы можем обрабатывать случаи, когда у нас в конструкторе несколько аргументов разного типа. Но загвоздка в том, что это работает только в случае, когда у всех дочерних классов одинаковый набор параметров конструктора. Как показывает практика, так бывает далеко не всегда — С++ позволяет писать перегруженные конструкторы. Достаточно взглянуть, скажем, на QPushButton:

explicit QPushButton(QWidget *parent = nullptr);
explicit QPushButton(const QString &text, QWidget *parent = nullptr);
QPushButton(const QIcon& icon, const QString &text, QWidget *parent = nullptr);

С текущим подходом поддержать такое разнообразие конструкторов точно не выйдет.

Прыжок в кроличью нору

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

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

Как понять, что класс-создатель поддерживает переданные аргументы и содержит нужный ключ? 

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

Как вариант: создать std::tuple<_Args...> и взять от него typeid. Но в этом случае мы будем использовать механизмы RTTI, чего хотелось бы избежать. Тут нам на помощь приходит библиотека Qt и ее метаобъектная система, а также некоторые фишки стандарта C++. Идея заключается в использовании qMetaTypeId<T> для получения уникального числового идентификатора типа. 

Примечание: Если библиотека Qt в вашем проекте не используется, можно сделать собственный аналог qMetaTypeId<T>. Различные способы описаны в моей предыдущей статье.

Попробуем теперь написать код, используя std::function, технику type-erasure («стирание типа») и описав класс-создатель, порождающий классы-наследники. Подумав (не буду скрывать, думать пришлось довольно долго), можно прийти к такому решению:

struct Creator
{
    _Key key; // ключ 
    const int* argv = nullptr; // указатель на список типов аргументов
    int argc = 0; // число аргументов
    const void* ptr = nullptr; //указатель на функцию создания со «стертым» типом (void*)

    //шаблон псевдонима типа функции создания (для удобства)
    template<class... _Args>
    using CreatorFunction = std::function<_Base*(_Args&&...)>;

    template<class Product, class... Args>
    static Creator make(const _Key& k)
    {
        // число аргументов
        static constexpr int kArgsCount = sizeof...(_Args);
        // типы аргументов, закодированные с помощью qMetaTypeId 
        static const int kArgsMeta[sizeof...(_Args)] = { qMetaTypeId<_Args>()... };
        // лямбда-функция, помещенная в std::function
        static const CreatorFunction<_Args...> p = [](_Args&&... args)
        {
            return new Product(std::forward<Args>(args)...); 
        };
        return { k, kArgsMeta, kArgsCount, &p };
    }

    //то же, что и функция выше, но для поиска (без взятия указателя на функцию)
    template<class... _Args>
    static Creator id(const _Key& k)
    {
        static constexpr int kArgsCount = sizeof...(_Args);
        static const int kArgsMeta[sizeof...(_Args)] = { qMetaTypeId<_Args>()... };
        return { k, kArgsMeta, kArgsCount, nullptr };
    }

    template<class... _Args>
    Base* create(Args&&... args) const
    {
        //производит обратное приведение (небезопасно) и вызов лямбды
        return (*(static_cast<const CreatorFunction<_Args...>*>(ptr)))(std::forward<_Args>(args)...);
    }
};

Здесь используется сразу несколько интересных трюков. 

Первый — это шаблон псевдонима типа. Он позволяет не записывать каждый раз сложный тип std::function<...> полностью.

Следующий трюк — «разворачивание» вариативных шаблонных аргументов (variadic template parameter pack unfolding):

static const int kArgsMeta[sizeof...(_Args)] = { qMetaTypeId<_Args>()... };

Далее: qMetaTypeId<T> позволяет нам получить уникальный идентификатор типа, не прибегая к механизмам RTTI, а развертывание шаблонов — сложить все идентификаторы аргументов в массив. Это позволит нам найти правильный экземпляр класса Creator для переданных аргументов при создании через фабрику.

И наконец, type-erasure.

// берем указатель на статический std::function как const void* 
// (явный каст здесь не нужен)
return { k, kArgsMeta, kArgsCount, &p }; 

// производит обратное приведение (небезопасно) и вызов 
// (явное приведение типов обязательно)
return (*(static_cast<const CreatorFunction<_Args...>*>(ptr)))(std::forward<_Args>(args)...);

Эта техника позволила нам хранить разные по типу std::function, просто держа const void* указатель на область памяти, где находятся данные самой std::function. Работает это благодаря тому, что экземпляры std::function — статические переменные, которые не меняют положения в памяти, то есть фактически синглтоны Мейерса в миниатюре. Таким образом, мы можем гарантировать корректное время жизни этих переменных.

Теперь остальной класс фабрики может выглядеть следующим образом:

template<class Key, class Base>
class ObjectFactory
{
    class Creator { ... }; // как и выше — без изменений

    // Функтор хеш-функции для структуры Creator
    struct CreatorHash
    {
        inline size_t operator()(const Creator& id) const noexcept
        {
            //используем QHashCombineи qHashRange
            return QtPrivate::QHashCombine{}(qHashRange(id.argv, id.argv + id.argc), id.key);
        }
    };

    // Функтор сравнения для структуры Creator
    struct CreatorMatch
    {
        inline bool operator()(const Creator& lhs, const Creator& rhs) const noexcept
        {
            if (lhs.argc != rhs.argc || lhs.key != rhs.key)
                 return false;
            if (lhs.argv && rhs.argv)
                 return memcmp(lhs.argv, rhs.argv, lhs.argc) == 0;
            return (!lhs.argv && !rhs.argv);
       }
   };

// Тип хеш-таблицы поиска
using CreatorsMap = std::unordered_set<Creator, CreatorHash, CreatorMatch>;

public:
    template<class Product, class... Args>
    void registrate(const _Key& k)
    {
        // добавление в хеш-таблицу — обратите внимание на ключевое слово template
        creators_.emplace(Creator::template make<_Product, _Args...>(k));
    }

    template<class... _Args>
    Base* create(const Key& k, _Args... args) const
    {
        // обратите внимание на std::decay_t<_Args>...
        auto it = creators_.find(Creator::template id<std::decay_t<_Args>...>(k));
        if (it == creators_.end())
            return nullptr;
        return it->create(std::forward<_Args>(args)...);
    }

private:
    CreatorsMap creators_;
};

Здесь также используются некоторые интересные приемы. 

В пространстве имен QtPrivate находятся несколько довольно полезных структур, в частности QHashCombine, которая является, по сути, калькой из библиотеки boost и позволяет комбинировать несколько хеш-значений.

Функция qHashRange() также позаимствована библиотекой Qt из boost и позволяет захешировать последовательность значений. 

Стоит обратить внимание и на использование ключевого слова template в местах вызова Creator::make() и Creator::id(). Дело в том, что, согласно стандарту C++, при вызове шаблонного метода у шаблонного класса обязательно наличие ключевого слова template, чтобы избежать неопределенности при разрешении имен функций. 

Следующий момент — использование std::decay_t. Это специальный type trait, который позволяет получить «упрощенные» типы. Важно это из-за особенностей шаблонов C++. Здесь дело в том, что при выводе шаблонного типа компилятором тип выводится точно; следовательно, нам необходимо «очистить» типы аргументов от модификаторов, которые могут в этот тип попасть. Если здесь не использовать std::decay_t, то мы можем не найти нужный класс-создатель, поскольку qMetaTypeId<T> сгенерирует другие значения идентификаторов типов.

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

  • Недостаточная безопасность. Здесь мы работаем с указателями void* напрямую, производим небезопасный type cast, а также вызов на непроверенном указателе (поскольку из-за type erasure невозможно проверить, насколько точно совпадает полученная нами сигнатура с той, что есть на самом деле).

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

  • Дополнительные накладные расходы на мьютексы. Начиная со стандарта С++11 они неявно добавляются компилятором, чтобы гарантировать потокобезопасность инициализации статических переменных (см. синглтон Мейерса).

Альтернативный подход

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

Сперва попробуем понять, как мы можем описать классы-создатели с разным числом и типами аргументов без использования std::function. Попробуем ухватиться за «внешний полиморфизм», который упоминался в самом начале статьи:

class CreatorBase
{
public:
    CreatorBase(const Key& k) : key(k) {}
    virtual ~CreatorBase() = default;
    const Key& key() const { return key; }

    template<class... _Args>
    virtual Base* create(Args&&... args) = 0; // OOPS! Can’t compile!
private:
    Key key;
};

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

Попробуем сделать базовый класс создателя и шаблонный наследник:

class CreatorBase
{
public:
    CreatorBase(const Key& k) : key(k) {}
    const Key& key() const { return key; }
    virtual ~CreatorBase() = default;
private:
    Key key;
};

template<class Product, class... Args>
class Creator : public CreatorBase
{
public:
    Creator(const _Key& k) : CreatorBase(k) {}
    Base* create(Args&&... args) const 
    {
        return new Product(std::forward<Args>(args)...));
    }
};

Такой код уже скомпилируется, но как теперь вызвать Creator::create()? Из-за шаблонного параметра _Product при вызове необходимо знать конкретный производный класс (чтобы произвести приведение типов), но это именно то, что мы хотим скрыть!

Классы Intermezzo

Проблему выше можно решить, используя класс «посередине». Мне нравится называть такой подход Intermezzo Class (от итал. intermezzo — средний, посередине). 

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

// Creator base class
class CreatorBase
{
public:
    CreatorBase(const Key& k) : key(k) {}
    virtual ~CreatorBase() = default;
    const Key& key() const { return key; }
private:
    Key key;
};

// Intermezzo Class
template<class... _Args> 
class ClassCreator : public CreatorBase
{
public:
    ClassCreator(const _Key& k) : CreatorBase(k) {}
    virtual Base* create(Args&&... args) const = 0;
};


// Concrete Product Creator
template<class Product, class... Args>
class Creator : public ClassCreator<_Args...>
{
public:
    Creator(const Key& k) : ClassCreator<Args...>(k) {}
    Base* create(Args&&... args) const override
    {
        return new Product(std::forward<Args>(args)...);
    }
};

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

Теперь остается придумать, как найти нужную перегрузку конструктора класса наследника _Base. Воспользуемся уже испробованным методом из предыдущего варианта, с той лишь разницей, что теперь нам нужно завести метод isMatched() и пару виртуальных методов в базовом классе-создателе:

//Creator base class
class CreatorBase
{
public:
    CreatorBase(const Key& k) : key(k) {}
    virtual ~CreatorBase() = default;
    // указатель на массив уникальных идентификаторов типов аргументов конструктора
    virtual const int* argsMeta() const { return nullptr; }
    //число аргументов конструктора
    virtual int argsCount() const { return 0; }
    //возвращает ключ типа наследника
    const Key& key() const { return key; }
    //проверяет соответствие ключа и идентификаторов типов аргументов конструктора
    bool isMatched(const _Key& k, const int* args, int n) const
    {
        if (k != key_ || n != argsCount())
            return false;

        if (args && argsMeta())
            return memcmp(args, argsMeta(), n) == 0;
        return (!args && !argsMeta());
}
private:
    Key key;
};

Также поправим реализации его наследников:

// Intermezzo Class
template<class... _Args> 
class ClassCreator : public CreatorBase
{
    static constexpr int kArgsCount = sizeof...(_Args);
    static const int kArgsMeta[sizeof...(_Args)];
public:
    ClassCreator(const _Key& k) : CreatorBase(k) {}

    const int* argsMeta() const override { return kArgsMeta; } // reimp fromCreatorBase
    int argsCount() const override { return kArgsCount; } // reimp fromCreatorBase
    virtual Base* create(Args&&... args) const = 0;
};

// Concrete Product Creator
template<class Product, class... Args>
class Creator : public ClassCreator<_Args...>
{
public:
    Creator(const Key& k) : ClassCreator<Args...>(k) {}
    Base* create(Args&&... args) const override // reimp from ClassCreator<_Args...>
    {
        return new Product(std::forward<Args>(args)...);
    }
};

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

template<class Key, class Base>
template<class... _Args>
const int ObjectFactory<_Key, Base>::ClassCreator<Args...>::kArgsMeta[sizeof...(_Args)] = { qMetaTypeId<_Args>()... };

Отлично, теперь мы можем накидать реализацию класса-фабрики:

template<
    class _Key, // тип ключа
    class _Base // тип базового класса
>
class ObjectFactory
{
    // Классы CreatorBase, ClassCreator, Creator как и выше без изменений
    class CreatorBase { ... }
    // Intermezzo Class
    template<class... _Args> 
    class ClassCreator : public CreatorBase { ... };
    // Concrete Product Creator
    template<class Product, class... Args>
    class Creator : public ClassCreator<_Args...> { ... };

    using CreatorPointer = std::unique_ptr<CreatorBase>;
    using CreatorStorage = std::vector<CreatorPointer>;

public:
    template<class Product, class... Args>
    void registrate(const _Key& key)
    {
        static_assert (std::is_base_of_v<_Base, Product>, "Product must be derived from _base type");
        static constexpr int argc = sizeof...(_Args);
        static const int argv[] = { qMetaTypeId<_Args>()... };
        for (const auto& c : std::as_const(creators_))
        {
            if (c->isMatched(key, argv, argc))
                return;
        }
        creators_.push_back(std::make_unique<Creator<_Product, _Args...>>(key));
    }

    template<class... _Args>
    Base* create(const Key& key, _Args... args) const
    {
         using CreatorType = ClassCreator<_Args...>; // типintermezzo класса
         static constexpr int argc = sizeof...(_Args);
         static const int argv[] = { qMetaTypeId<std::decay_t<_Args>>()... };
         for (const auto& c : creators_)
         {
             if (c->isMatched(key, argv, argc)) // совпадение найдено
             {
                 // производим приведение типа к intermezzo классу
                 const CreatorType* creator = static_cast<const CreatorType*>(c.get());
                 // производим создание наследника, используя perfect forwarding
                 return creator->create(std::forward<_Args>(args)...);
             }
         }
         return nullptr;
    }
private:
    CreatorStorage creators_;
};

// инициализация идентификаторов типов аргументов конструктора класса наследника
template<class Key, class Base>
template<class... _Args>
const int ObjectFactory<_Key, Base>::ClassCreator<Args...>::kArgsMeta[sizeof...(_Args)] = { qMetaTypeId<_Args>()... };

Для простоты я использовал здесь std::vector, поскольку использование std::unordered_map(_set) сильно усложняет код и его понимание. Использование хеш-таблиц оставлю читателю в качестве упражнения. 

Вспомним, какие недостатки предыдущего решения мы хотели убрать:

  • Недостаточная безопасность. Больше нет void* и работы с памятью напрямую.

  • Дополнительные накладные расходы на мьютексы. Статические переменные пользовательского типа больше не используются.

  • Невозможность передачи кастомной функции создания.

Хм-м, один из пунктов не закрыт, но реализовать его на самом деле несложно — достаточно посмотреть на то, как реализуется абстракция над управлением памятью в STL. 

Давайте немного отвлечемся и посмотрим на то, как в этой библиотеке реализуется эта абстракция.

Распределение памяти

В STL задача абстракции распределения памяти решается с помощью специального класса std::allocator<T> (а также std::pmr::polymorphic_allocator) и шаблонного аргумента с соответствующим типом. Например, широко используемый класс std::vector определяется так:

template<class T, class _Alloc = std::allocator<T>>
class vector;

Рассмотрим подробнее класс std::allocator<T>. 

Интересно, что его реализация не использует new expression, как это может показаться логичным на первый взгляд. Вместо этого используется прямой вызов оператора new (::operator new), а также распределяющий оператор new (так называемый placement new) в отдельном методе. Другими словами, создание экземпляра типа (что бы это ни было) происходит в два этапа:

  • метод allocator<T>::allocate() производит выделение «сырого» участка памяти через явный вызов operator new;

  • метод allocator<T>::construct(void*, _Args…) производит вызов конструктора на этом участке памяти через placement new.

Отсюда возникает идея: а почему бы и нам не использовать такой же подход?

Единственный минус — придется усложнить реализацию.

Итак, начнем с метода фабрики ObjectFactory::create(). Можно предоставить дополнительный метод, который будет принимать аллокатор первым аргументом, а исходный метод изменить так, чтобы он передавал аллокатор по умолчанию. Например, так:

template<class Base, class Key>
class ObjectFactory 
{
    // все как и раньше
    // ...

    template<class... _Args>
    Base* create(const Key& key, _Args... args) const
    {
        // используем аллокатор по умолчанию и передаем его в перегруженную функцию
        return this->construct(std::allocator<char>{}, std::forward<_Args>(args)...);
    }

    template<class Alloc, class… Args>
    Base* construct(Alloc& al, const Key& key, Args... args) const
    {
        using CreatorType = ClassCreator<_Args...>; // тип intermezzo класса
        static constexpr int argc = sizeof...(_Args);
        static const int argv[] = { qMetaTypeId<std::decay_t<_Args>>()... };
        for (const auto& c : creators_)
        {
            if (c->isMatched(key, argv, argc)) // совпадение найдено
            {
                 // производим приведение типа к intermezzo классу
                 const CreatorType* creator = static_cast<const CreatorType*>(c.get());
                 // производим создание наследника, используя переданный аллокатор
                 return creator->create(al, std::forward<_Args>(args)...);
            }
       }
       return nullptr;
    }
};

К сожалению, перегрузку метода ObjectFactory::create() сделать нельзя, поскольку из-за variardic templates получается неоднозначность при вызове, которую компилятор разрешить не в силах.

Теперь необходимо придумать, как воспользоваться аллокатором в классе ClassCreator<Args...>. Загвоздка здесь в том, что в этом классе у нас нет данных о конкретном наследнике. С другой стороны, все, что нам необходимо знать на этом уровне, — это количество памяти, которое надо выделить. Так, само конструирование мы можем делегировать виртуальному методу, реализация которого будет в классе Creator<_Product, _Args…>, где тип наследника уже известен! 

Следовательно, можно провернуть следующий трюк: 

  • в базовом классе CreatorBase предусмотреть переменную size_, хранящую необходимый размер памяти в байтах;

  • в конструкторе этого класса предусмотреть инициализацию этой переменной;

  • в наследнике ClassCreator<_Args...> передавать эту же переменную, а также предусмотреть выделение памяти с использованием переданного аллокатора и виртуальный метод construct(void*,_Args&&…);

  • в наследнике Creator<_Product, Args...> передать в конструктор базового класса sizeof(Product) и реализовать метод construct(void*,_Args&&…).

В коде это выглядит так (некоторые методы сокращены для удобства чтения):

class CreatorBase
{
public:
     CreatorBase(const Key& k, sizet n) // добавлен аргумент размера
         : key_(k)
         , size_(n) // инициализируем размер
     {}

    virtual ~CreatorBase() = default;
    virtual const int* argsMeta() const { return nullptr; }
    virtual int argsCount() const { return 0; }
    const Key& key() const { return key; }
    bool isMatched(const _Key& k, const int* args, int n) const; // как и раньше

protected:
    Key key;
    const size_t size_;
};


template<class... _Args>
class ClassCreator : public CreatorBase
{
     static constexpr int kArgsCount = sizeof...(_Args);
     static const int kArgsMeta[sizeof...(_Args)];
public:
     ClassCreator(const Key& k, sizet n)
         : CreatorBase(k, n) // аргумент n прокидывается без изменений
     {}

     const int* argsMeta() const override { return kArgsMeta; }
     int argsCount() const override { return kArgsCount; }

     template<class _Allocator>
     inline Base* create(const Allocator& al, _Args&&... args) const
     {
          // rebind allocator
          using allocator_type = typename std::allocator_traits<_Allocator>::template rebind_alloc<char>;

         _Base* result = nullptr;
         allocator_type alloc_proxy(al);
         char* addr = nullptr;
         try
         {
             // аллокация и/или конструктор может бросить исключение
             addr = alloc_proxy.allocate(this->size_);
             result = construct(addr, std::forward<_Args>(args)...);
        }
        catch (...)
        {
             // деаллоцируем память в случае исключения
             alloc_proxy.deallocate(addr, this->size_);
             addr = nullptr;
             throw;
        }
        return result;
    }

protected:
     //виртуальный метод для вызова конструктора класса
     virtual Base* construct(void*, Args&&...) const = 0; 
};


template<class Product, class... Args>
class Creator : public ClassCreator<_Args...>
{
public:
    Creator(const _Key& k)
        : ClassCreator<_Args...>(k, sizeof(_Product)) // используем sizeof(_Product)
    {}

    // \reimp from ClassCreator<...>
    Base* construct(void* addr, Args&&... args) const override
    {
         // используем placement new для вызова конструктора,
         // в выделенной памяти по адресу addr
         return new(addr) Product(std::forward<Args>(args)...);
    }
};

Здесь стоит уделить внимание методу ClassCreator<_Args...>::create(). В первых строках производится rebind к аллокатору char типа. Сделано это специально, поскольку здесь мы имеем дело с байтами. Также стоит уделить особое внимание участку:

try
{
     addr = alloc_proxy.allocate(this->size_);
     result = construct(addr, std::forward<_Args>(args)...);
}
catch (...)
{
     alloc_proxy.deallocate(addr, this->size_);
     addr = nullptr;
     throw;
}

Здесь нет UB, как может показаться на первый взгляд. В случае неудачной попытки выделения памяти бросится исключение и вызовется deallocate(). Однако это не проблема, поскольку addr останется равным nullptr, а вызов deallocate для nullptr — вполне определенная стандартом операция, которая просто ничего не делает. Если же исключение было выброшено в конструкторе, мы освободим память, которая была выделена под объект, и таким образом избежим утечек памяти. 

Примечание: Для стандартов C++20 и выше, можно заменить этот участок вызовом std::make_obj_using_allocator().

Еще об аргументах и типах

Посмотрим теперь, как можно использовать написанный класс фабрики.

Сперва дополним классы нашей иерархии перегруженными конструкторами и виртуальным методом draw(), чтобы видеть, какие именно классы были созданы и каким конструктором:

class Shape
{
public:
    virtual ~Shape() = default;
    Shape();
    Shape(const std::string& name) : name_(name) {}
    virtual void draw(std::ostream& out)  const = 0;
protected:
    std::string name_;
};

class Circle : public Shape
{
public:
    Circle() : Circle("Circle", 0, 0, 0.0) {}
    Circle(int x, int y, double r) : Circle("Circle", x, y, r) {}
    Circle(const std::string& name, int x, int y, double r) 
        : Shape(name)
        , x_(x)
       , y_(y)
       , r_(r)
    {}
void draw(std::ostream &out) const override
{
    out << '\'' << name_<< "' {" << x_ << ';' << y_ << "}: "<< r_;
}
private:
    int x_ = 0, y_ = 0;
    double r_ = 0;
};

class Rectangle : public Shape
{
public:
    Rectangle() : Rectangle("Rectangle", 0, 0, 0, 0) {}
    Rectangle(int x, int y, int w, int h) : Rectangle("Rectangle", x, y, w, h) {}
    Rectangle(const std::string& name, int x, int y, int w, int h)
        : Shape(name)
        , x_(x)
        , y_(y)
        , w_(w)
        , h_(h)
    {}
void draw(std::ostream &out) const override
{
    out << '\'' << name_ << "' {" << x_ << ';' << y_ << "}: " << '[' << w_ << 'x' << h_ << ']';
}
private:
   int x_ = 0, y_ = 0, w_ = 0, h_ = 0;
};

class Line : public Shape
{
public:
    Line() : Line("Line", 0, 0, 0, 0) {}
    Line(int x1, int y1, int x2, int y2) : Line("Line", x1, y1, x2, y2) {}
    Line(const std::string& name, int x1, int y1, int x2, int y2)
        : Shape(name)
        , x1_(x1)
        , y1_(y1)
        , x2_(x2)
        , y2_(y2)
    {}
void draw(std::ostream &out) const override
{
    out << '\'' << name_ << "' {" << x1_ << ';' << y1_ << "}," << " {" << x2_ << ';' << y2_ <<'}';
}
private:
    int x1_ = 0, y1_ = 0;
    int x2_ = 0, y2_ = 0;
};

Все готово, чтобы протестировать, как работает фабрика:

int main()
{
    ObjectFactory<std::string, Shape> factory;
    factory.registrate<Circle>("circle");
    factory.registrate<Circle, int, int, double>("circle");
    factory.registrate<Circle, std::string, int, int, double>("circle");
    factory.registrate<Rectangle>("rect");
    factory.registrate<Rectangle, int, int, int, int>("rect");
    factory.registrate<Rectangle, std::string, int, int, int, int>("rect");
    factory.registrate<Line>("line");
    factory.registrate<Line, int, int, int, int>("line");
    factory.registrate<Line, std::string, int, int, int, int>("line");
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.emplace_back(factory.create("circle"));
    shapes.emplace_back(factory.create("rect", "MyRect", 10, 10, 24, 24));
    shapes.emplace_back(factory.create("line", "MyLine", 0, 0, 5, 5));
   
    for (const auto& s : shapes)
    {
        if (!s)
            continue;
        s->draw(std::cout);
        std::cout << std::endl;
    }
    return 0;
}

Мы ожидали увидеть три созданных объекта-наследника в векторе, однако в векторе оказывается только один, а остальные — nullptr:

'Circle' {0;0}: 0

Что произошло здесь? Почему не отработали методы создания Rectagle и Line? 

Ответ кроется в особенностях шаблонов C++. Как упоминалось ранее, при автоматическом выводе типов шаблонных аргументов компилятором выводится точный тип. В данном случае при вызове передается const char*, а не std::string. В результате массив идентификаторов выводится следующим образом:

static const int kArgsMeta = { 
    qMetaTypeId<const char*>(), 
    qMetaTypeId<int>(), 
    qMetaTypeId<int>(), 
    qMetaTypeId<int>(), 
    qMetaTypeId<int>()
};

Хотя правильным будет другой вариант:

static const int kArgsMeta = { 
    qMetaTypeId<std::string>(), 
    qMetaTypeId<int>(), 
    qMetaTypeId<int>(), 
    qMetaTypeId<int>(), 
    qMetaTypeId<int>()
};

Именно поэтому мы не можем найти в массиве классов-создателей нужный. 

Как же исправить код, чтобы он заработал правильно и начали выполняться неявные приведения типов?

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

shapes.emplace_back(factory.create("rect", "MyRect", 10, 10, 24, 24));
shapes.emplace_back(factory.create<std::string, int, int, int, int>("rect", "MyRect", 10, 10, 24, 24));
shapes.emplace_back(factory.create("line", "MyLine", 0, 0, 5, 5));
shapes.emplace_back(factory.create<std::string, int, int, int, int>("line", "MyLine", 0, 0, 5, 5));

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

А что с кастомным распределением памяти? 

Теперь это не проблема. Например, можно использовать polymorphic_allocator и monotonic_buffer_resource для выделения объектов на стеке (что гораздо быстрее, чем использование динамической памяти). Например:

struct destroyer 
{
    template <typename T>
    constexpr void operator()(T* arg) const { std::destroy_at(arg); }
};

int main()
{
    using ShapePtr = std::unique_ptr<Shape, destroyer>;

    // pool for Rectangle objects
    std::array<uint8_t, sizeof(Rectangle) * 4> rbuffer = { 0 };
    std::pmr::monotonic_buffer_resource mbr_rects{ rbuffer.data(), rbuffer.size() };
    std::pmr::polymorphic_allocator<uint8_t> rect_alloc{ &mbr_rects };

    // pool for Circle objects
    std::array<uint8_t, sizeof(Circle) * 4> cbuffer = { 0 };
    std::pmr::monotonic_buffer_resource mbr_circles{ cbuffer.data(), cbuffer.size() };
    std::pmr::polymorphic_allocator<uint8_t> circle_alloc{ &mbr_circles };

    // pool for Line objects
    std::array<uint8_t, sizeof(Line) * 4> lbuffer = { 0 };
    std::pmr::monotonic_buffer_resource mbr_lines{ lbuffer.data(), lbuffer.size() };
    std::pmr::polymorphic_allocator<uint8_t> line_alloc{ &mbr_lines };

    // Create factory and registrate types
    ObjectFactory<String, Shape> factory;
    factory.registrate<Line>("Line");
    factory.registrate<Line(String, int, int, int, int)>("Line");
    factory.registrate<Rectangle()>("Rect");
    factory.registrate<Rectangle(String, int, int, int, int)>("Rect");
    factory.registrate<Circle>("Circle");
    factory.registrate<Circle(String, int, int, double)>("Circle");
  
    // Use factory to create shapes
    std::vector<ShapePtr> shapes;
    shapes.emplace_back(factory.construct<decltype(rect_alloc)>(rect_alloc, "Rect"));
    shapes.emplace_back(factory.construct<decltype(rect_alloc), String, int, int, int, int>(rect_alloc, "Rect", "MyRect", 10, 10, 20, 20));
   
    shapes.emplace_back(factory.construct<decltype(circle_alloc)>(circle_alloc, "Circle"));
    shapes.emplace_back(factory.construct<decltype(circle_alloc), String, int, int, double>(circle_alloc, "Circle", "MyCircle", 5, 5, 0.5));
    shapes.emplace_back(factory.construct<decltype(line_alloc)>(line_alloc, "Line"));
    shapes.emplace_back(factory.construct<decltype(line_alloc), String, int, int, int, int>(line_alloc, "Line", "MyLine", 3, 3, 6, 6));
    for (const auto& s : shapes)
    {
        if (!s)
            continue;
        s->draw(std::cout);
        std::cout << std::endl;
    }
    return 0;
}

Получаем вывод:

'Rect' {0;0}: [0x0]
'MyRect' {10;10}: [20x20]
'Circle' {0;0}: 0
'MyCircle' {5;5}: 0.5
'Line' {0;0}, {0;0}
'MyLine' {3;3}, {6;6}

Здесь стоит обратить внимание на структуру destroyer — она необходима, чтобы предотвратить вызов оператора delete на стековой памяти. Дело в том, что по умолчанию std::unique_ptr использует default_deleter<T>, который, в свою очередь, всегда вызывает operator delete. Здесь мы меняем это поведение с помощью destroyer, который просто вызывает деструктор класса, используя std::destroy_at().

Привязываем бантики

Можно дополнить решение некоторыми полезными возможностями. Стоит начать с того, что нам доступна библиотека Qt, а значит, если регистрируемые классы содержат макросы Q_OBJECT или Q_GADGET, то можно получать метаинформацию о зарегистрированных типах. Сложность заключается в том, чтобы определить, содержит ли тип такую метаинформацию. Чтобы это реализовать, воспользуемся техникой SFINAE (Substitution-Failure-Is-Not-An-Error):

// SFINAE характеристика типа, показывающая,
// содержит ли T статическую переменную staticMetaObject 
template <class T>
class HasQtMetaObject
{
    template<class U, class = typename std::enable_if<!std::is_member_pointer<decltype(&U::staticMetaObject)>::value>::type>
    static std::true_type check(int);
    template <class>
    static std::false_type check(...);
public:
    static constexpr bool value = decltype(check<T>(0))::value;
};

Теперь в CreatorBase можно добавить виртуальный метод:

class CreatorBase
{
    // ...
    // как и раньше
    virtual const QMetaObject* metaObject() const { return nullptr; }
    // ...
};

Теперь реализуем его в Creator<_Product, _Args...> следующим образом:

template
class Creator : public ClassCreator<_Args...>
{
    // ...
    // как и раньше
    const QMetaObject* metaObject() const override
    {
        if constexpr (HasQtMetaObject<_Product>)
             return &_Product::staticMetaObject;
        else
             return nullptr; 
    }
    // ...
};

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

template<class Key, class Base>
class ObjectFactory
{
    // ...
    // как и раньше
    // ...
  
    // Получение метаинформации (если есть) о наследнике по ключу
    const QMetaObject* metaData(const _Key& key) const noexcept
    {
        for (const auto& c : creators_)
        {
            if (c->key() == key)
                return c->metaObject();
        }
        return nullptr;
    }

    // Проверка, содержит ли фабрика метод создания наследника
    // по ключу key с аргументами конструктора _Args...
    template<class... _Args>
    bool contains(const _Key& key) const noexcept
    {
         static constexpr int argc = sizeof...(_Args);
         static const int argv[] = { qMetaTypeId<std::decay_t<_Args>>()... };
         for (const auto& c : creators_)
         {
             if (c->isMatched(key, argv, argc))
                 return true;
         }
         return false;
    }

    // Получение всех уникальных зарегистрированных ключей 
    template<class Container = QList<Key>>
    _Container keys() const
    {
        _Container result;
        for (const auto& c : creators_)
            result.push_back(c->key());

        std::sort(result.begin(), result.end());
        result.erase(std::unique(result.begin(), result.end()), result.end());
        return result;
    }

    // Подсчет количества зарегистрированных методов создания для ключа key
    int count(const _Key& key) const noexcept
    {
        int n = 0;
        for (const auto& c : creators_)
            n += (c->key() == key);
        return n;
    }

    // Общее число всех зарегистрированных методов создания для всех наследников
    int size() const noexcept
    {
        return creators_.size();
    }
    // ...
};

Стоит обратить внимание на то, что некоторые из этих методов не получилось бы реализовать, используя std::function<>, как в предыдущей попытке реализации.

Еще один интересный «бантик». Можно обратить внимание на то, что запись регистрации типа в фабрике не очень выразительная:

factory.registrate<Circle, std::string, int, int, double>("circle");

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

factory.registrate<Circle(std::string, int, int, double)>("circle");

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

template<class Product, class... Args>
struct ClassRegisterer
{
    static void registrate(ObjectFactory& factory, const _Key& key)
    {
        static_assert (std::is_base_of_v<_Base, Product>, "Product must be derived from _base type");
        static constexpr int argc = sizeof...(_Args);
        static const int argv[] = { qMetaTypeId<_Args>()... };
        for (const auto& c : std::as_const(factory.creators_))
        {
            if (c->isMatched(key, argv, argc))
                return;
        }
        factory.creators_.push_back(std::make_unique<Creator<_Product, _Args...>>(key));
    }
};

template<class Product, class... Args>
struct ClassRegisterer<_Product(_Args...)>
    : public ClassRegisterer<_Product, _Args...>
{};

Далее необходимо использовать метафункции std::enable_if_t<> и std::is_function_v<> для разрешения перегрузки:

template<class Key, class Base>
class ObjectFactory
{
    //
   // ...

    template<class Product, class... Args>
    inline typename std::enable_if_t<!std::is_function_v<_Product>, void> registrate(const _Key& key)
    {
        ClassRegisterer<_Product, _Args...>::registrate(*this, key);
    }

    template<class _Fn>
    inline typename std::enable_if_t<std::is_function_v<_Fn>, void> registrate(const _Key& key)
    {
        ClassRegisterer<_Fn>::registrate(*this, key);
    }
};

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

factory.registrate<Circle, std::string, int, int, double>("circle");

factory.registrate<Circle(std::string, int, int, double)>("circle");

Такие разные фабрики

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

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

template<class Key, class Base>
class PrototypeFactory
{
    using FactoryType = ObjectFactory<_Key, _Base>;
    using PrototypeMap = std::unordered_map<std::string, std::unique_ptr<Shape>>;

    template<class... _Args>
    Shape* create(const std::string& key, _Args... args)
    {
        if (!impl_.contains(key, args...))
            return nullptr; // сочетание key/args... не найдено

        auto [it, inserted] = prototypes_.emplace(key, {});
        if (inserted) 
        {
            Base* p = impl.create<_Args...>(key, std::forward<_Args>(args)...);
            it->second.reset(p);
        }
        return it->second;
        // альтернативный вариант, если Shape поддерживает клонирование:
        // return it->second->clone(); 
    }

private:
    FactoryType impl_;
    PrototypeMap prototypes_;
};

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

using ShapeFactory = ObjectFactory<std::string, Shape>;

// синглтон Мейерса 
ShapeFactory& shapeFactory()
{
    static ShapeFactory globalInstance;
    return globalInstance;
}

// Вспомогательный класс для автоматической регистрации
template<class T, class... _Args>
struct ShapeRegisterer 
{
    explicit ShapeRegisterer(const std::string& k)
    {
       shapeFactory().registrate<T(_Args...)>(k);
    }
};

// Макрос регистрации
#define REGISTRATE_SHAPE_CLASS(_Class) \
       static const ShapeRegisterer<_Class> __##_Class##Registerer{ #_Class };

Здесь стоит обратить внимание на макрос регистрации: 

  • макрос создает глобальную статическую константу шаблонной структуры ShapeRegisterer, инстанцированную типом _Class;

  • выражение __##_Class##Registerer позволяет произвести конкатенацию литералов, чтобы получить уникальное имя переменной;

  • #_Class преобразует в литеральную строку переданный аргумент _Class. 

Чтобы было проще понять, как это работает, «развернем» макрос, например, для класса Circle:

static const ShapeRegisterer<Circle> __CircleRegisterer{ “Circle” };

Фишка этого кода в том, что, согласно стандарту C++, компилятор должен проинициализировать все статические глобальные переменные до вызова main-функции. Это означает, что еще до входа в main() будет произведен вызов конструктора ShapeRegisterer<Circle>, в котором будет вызвана инициализация объекта глобальной фабрики (если она еще не была инициализирована) и произведена регистрация типа.

Если необходимо поддержать перегрузку по конструкторам, то можно воспользоваться макросом __VA_ARGS__, который действует для макросов, как variardic template argument pack работает для шаблонов.

Однако, чтобы сгенерировать уникальное имя, придется теперь что-то придумывать — например, просто предоставить еще один аргумент _Id:

// Макрос регистрации

#define REGISTRATE_SHAPE_CLASS(_Class, _Id, ...) \

       static const ShapeRegisterer<_Class, __VA_ARGS__> __##_Class##Registerer##_Id { #_Class };

Реализация наследника типа Shape теперь может выглядеть следующим образом:

// MyShape.h
class MyShape : public Shape
{
public:
    MyShape();
    MyShape(const std::string& name, int i);
    void draw(std::ostream& out) const override;
private:
    int i_;
};

// MyShape.cpp
#include "MyShape.h"

MyShape::MyShape() : Shape("myshape") {}

MyShape::MyShape(const std::string& name, int i) : Shape(name), i_(i) {}

void MyShape::draw(std::ostream& out) const { out << "MyShape: " << i_; }

// производит автоматическую регистрацию MyShape в фабрике
REGISTRATE_SHAPE_CLASS(MyShape, 0) 
REGISTRATE_SHAPE_CLASS(MyShape, 1, std::string, int) 

Для статически компонуемых модулей проблем с таким кодом не возникает. Однако при использовании динамического связывания возникают сложности с пересечением границ библиотеки. Дело в том, что при сборке динамических библиотек компилятор имеет право проводить оптимизации. Одной из таких оптимизаций является удаление неиспользуемых переменных. Если реализация MyShape будет находиться в динамически компонуемом модуле отдельно от самой фабрики, то статическая переменная, сгенерированная макросом REGISTER_SHAPE_CLASS(), будет удалена компилятором как неиспользуемая и класс не будет зарегистрирован в фабрике. 

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

Сравнение с библиотекой Boost

Конечно же, я не первый, кто пытается решить подобные задачи. Внимательный читатель наверняка вспомнит о библиотеке Boost и ее модулях Boost.Fuctional/Factory и Boost.In-Place Factory.

Сразу стоит отметить различия в используемых парадигмах. 

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

Библиотека Boost.Fuctional/Factory использует паттерн абстрактная фабрика: предоставляет обобщенные фабричные функторы, которые могут быть составлены в композицию путем использования паттернов функционального программирования. Встроенные механизмы регистрации здесь отсутствуют — для их реализации требуется отдельное хранилище (например, std::map).

Библиотека Boost.In-Place Factory фокусируется на создании объектов без динамической аллокации памяти, используя tag dispatching.

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

Механизмы поддержки безопасности типов:

Критерий

ObjectFactory

Boost.Factory

Boost.In-Place Factory

Валидация аргументов

Compile-time + Run-time проверки ID типа

Run-time через сигнатуры функций

Compile-time проверки 

Проверка соответствия сигнатуры конструктора

Явная регистрация через метатипы аргументов

Автоматическое выведение типа

Прямой вызов конструктора

Type Erasure

Используя иерархию классов и Intermezzo классы

Используя boost::function

Отсутствует

Прочие возможности:

Возможность

ObjectFactory

Boost

Variardic Arguments

Да (С++11 fold expressions)

Да (через bind)

Argument Forwarding

Perfect Forwarding

Требуется boost::bind

Dependency Injection

Ручная регистрация типов

Boost.DI

Code Bloating

Средняя (инстанцирование шаблонов)

Высокая (инстанцирование шаблонов + функциональная композиция)

Поддержка метаинформации/Qt

Да

Нет

Заключение

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

  • низкоуровневому управлению памятью;

  • использованию шаблонов и метапрограммирования;

  • кросс-платформенности;

  • балансу производительности, безопасности и надежности.

Такое решение может использоваться:

  • в высокочастотном трейдинге;

  • решениях для встраиваемых платформ;

  • игровых движках;

  • плагинных системах в корпоративных решениях.

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

Ответ здесь сильно зависит от различных факторов:

  • конкретного стиля, который используется командой;

  • объема унаследованного кода;

  • используемых библиотек и компилятора.

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

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

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

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

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


  1. rukhi7
    30.07.2025 16:57

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

    фабрика классов обычно нужна (точно имеет смысл), когда вы достаете объекты с известными интерфейсами из динамически линкуемой библиотеки, подключенной в runtime. Если вы попробуете ваш код использовать в такой библиотеке, чтобы создавать объекты для клиентов этой библиотеки в runtime, работать этот код, к сожалению, не сможет.


    1. pvsur
      30.07.2025 16:57

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


  1. 9241304
    30.07.2025 16:57

    Этот код должен был помереть минимум 10 лет назад. Но кому-то очень нравится "стабильность" тонн бесполезного кода в погоне за мнимой абстракцией и универсальностью. А ещё обязательно ж надо вкрутить sfinae. Для важности нужно и протухший буст упомянуть. И вот, очередная гениальная нёх готова. Пользоваться этим в реальном проекте смысла нет - можно проще и быстрее. В конце концов, если так хочется поупражняться в остроумии, можно второй проект взять, ну или в шахматы поиграть. Всяко полезнее, чем городить эти выебоны в коде


    1. mpolukarov Автор
      30.07.2025 16:57

      Очень сильные и голословные утверждения. По всей видимости автор комментария не сталкивался еще с реальными проектами. Можно ли сделать проще? Кажется что да - ща switch/casе сделаем и норм. А через полгода оказывается что код разросся до 100500 строчек в котором уже никто не может разобраться. Страдают по итогу все - и продукт, и пользователи, и QA, и коллеги которые вынуждены разбираться в тоннах такого кода. А было так просто сразу подумать и использовать шаблон на 200-300 строк. Да, порог вхождения чуть выше, но бонусов - явно больше. Статья не призывает использовать код как есть, лишь показывает как можно сделать. Очевидно, что золотого молотка не существует - в каждом конкретном случае придется что-то выбирать. По поводу реальных проектов - опять же неправда: вот использование подобного подхода в catboost, например.


  1. Racheengel
    30.07.2025 16:57

    Такое ощущение, что это FizzBuzzFactoryEnterpriseEdition by CopilotGPT...

    После одного взгляда на код глаза ещё минут 10 продолжают болеть... Зачем ЭТО надо???


    1. time_shifter
      30.07.2025 16:57

      Лёгких путей не ищет , нахуярит чего то потом сам разобраться не может баги ищет


  1. Koshey_Immortal
    30.07.2025 16:57

    В этой статье я покажу, как сделать универсальную фабрику объектов, покрывающую большую часть потребностей, следующую принципам DRY (Don’t Repeat Yourself)

    Но не следующую принципу KIS (Keep It Simple) :) Великолепная статья с великолепным примером оверинжиниринга и антипаттернов. Чего только стоят функции регистрации, типов и способов их создания. Вместо создания объекта, мы еще принимаем строку с названием типа, делаем аллокации в мапах, и кучу разной ненужной логики. Хотя это все можно было перенести даже на этап компиляции.

    Более того, в этой "универсальной" фабрике намешано столько функционала, что это прямое нарушение SOLID, а именно принципа единой ответственности. Фабрика должна создавать объекты. Точка. А тут она и создает, и регистрирует способы их создания. Отвечает сразу за все и вся. Еще и в памяти роется.


    1. mpolukarov Автор
      30.07.2025 16:57

      Не соглашусь. В статье описаны несколько вариантов, сложность которых постепенно нарастает. Статья только показывает как можно делать, но не призывает использовать самый сложный вариант - выбор всегда делается исходя из текущих потребностей. Поэтому не считаю, что тут есть какой-то оверинженириг и уж тем более антипаттерны. SOLID вообще не понятно при чем - чтобы что-то создать надо знать ЧТО создавать и КАК создавать - прямая обязанность фабрики объектов, вся сложность спрятана внутри реализации.


  1. here-we-go-again
    30.07.2025 16:57

    ЗОЧЕМ