Pull to refresh

Фабричный метод без размещения в динамической памяти

Reading time 8 min
Views 17K
У классической реализации фабричного метода на C++ есть один существенный недостаток — используемый при реализации этого шаблона динамический полиморфизм предполагает размещение объектов в динамической памяти. Если при этом размеры создаваемых фабричным методом объектов не велики, а создаются они часто, то это может негативно сказаться на производительности. Это связанно с тем, что во первых оператор new не очень эффективен при выделении памяти малого размера, а во вторых с тем что частая деаллокация небольших блоков памяти сама по себе требует много ресурсов.
Для решения этой проблемы было бы хорошо сохранить динамический полиморфизм (без него реализовать шаблон не получится) и при этом выделять память на стеке.
Если вам интересно, как это у меня получилось, добро пожаловать под кат.



Одна из возможных реализаций классического фабричного метода:
#include <iostream>
#include <memory>

struct Base
{
    static std::unique_ptr<Base> create(bool x);
    virtual void f() const = 0;
    virtual ~Base() { std::cout << "~Base()" << std::endl;}
};

struct A: public Base
{
    A() {std::cout << "A()" << std::endl;}
    virtual void f() const override {std::cout << "A::f\t" << ((size_t)this) << std::endl;}
    virtual ~A() {std::cout << "~A()" << std::endl;}
};

struct B: public Base
{
    B() {std::cout << "B()" << std::endl;}
    virtual void f() const override {std::cout << "B::f\t" << ((size_t)this) << std::endl;}
    virtual ~B()  {std::cout << "~B()" << std::endl;}
};

std::unique_ptr<Base> Base::create(bool x)
{
    if(x) return  std::unique_ptr<Base>(new A());
    else  return  std::unique_ptr<Base>(new B());
}

int main()
{
    auto p = Base::create(true);
    p->f();
    std::cout << "p addr:\t" << ((size_t)&p) << std::endl;
    return 0;
}
// compile & run:
// g++ -std=c++11 1.cpp && ./a.out

output:
A()
A::f	21336080
p addr:	140733537175632
~A()
~Base()

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

Теперь избавимся от динамического выделения памяти.
Как я сказал выше, мы исходим из того, что создаваемые объекты имеют небольшой размер и предлагаемый ниже вариант улучшает производительность за счет незначительного перерасхода памяти.
#include <iostream>
#include <memory>

struct Base
{
    virtual void f() const = 0;
    virtual ~Base() { std::cout << "~Base()" << std::endl;}
};

struct A: public Base {/* code here */};
struct B: public Base {/* code here */};

class BaseCreator
{
    union U 
    {
        A a;
        B b;
    };
public:
    BaseCreator(bool x) : _x(x)
    {
        if(x) (new(m) A());
        else  (new(m) B());
    }

    ~BaseCreator()
    {
        if(_x) {
            reinterpret_cast<A*>(m)->A::~A();
        }
        else   {
            reinterpret_cast<B*>(m)->B::~B();
        }
    }

    Base* operator->()
    {
        return reinterpret_cast<Base *>(m);
    }

private:
    bool _x;
    unsigned char m[sizeof(U)];
};

int main(int argc, char const *argv[])
{
    BaseCreator p(true);
    p->f();
    std::cout << "p addr:\t" << ((size_t)&p) << std::endl;
    return 0;
}

output:
A()
A::f	140735807769160
p addr:	140735807769160
~A()
~Base()

По напечатанным адресам, вы можете видеть, что таки да. Объект разместился на стеке.
Идея здесь очень простая: мы берем объединение объектов которые будет создавать фабричный метод и с помощью него узнаем размер самого ёмкого типа. Затем выделяем на стеке память нужного размера unsigned char m[sizeof(U)]; и с помощью специальной формы new размещаем в ней объект new(m) A().
reinterpret_cast<A*>(m)->A::~A(); корректно разрушает размещенный в выделенной памяти объект.

В принципе, на этом можно было бы и остановиться, но в полученном решении мне не нравится то что информация о создаваемых типах в классе BaseCreator присутствует в трех местах. И если нам понадобится, что бы наш фабричный метод создавал объекты еще одного типа, нам придется синхронно вносить изменения во все эти три места. При этом в случае ошибки компилятор ничего не скажет. Да и в режиме выполнения ошибка может всплыть не сразу. А если типов будет не 2-3, а 10-15 то вообще беда.

Попробуем улучшить наш класс BaseCreator
class BaseCreator
{
    union U 
    {
        A a;
        B b;
    };

public:
    BaseCreator(bool x)
    {
        if(x) createObj<A>();
        else  createObj<B>();
    }

    ~BaseCreator()
    {
        deleter(m);
    }

    // Запретим копирование
    BaseCreator(const BaseCreator &) = delete;
    // Только перемещение
    BaseCreator(BaseCreator &&) = default;

    Base* operator->()
    {
        return reinterpret_cast<Base *>(m);
    }

private:
    typedef void (deleter_t)(void *);

    template<typename T>
    void createObj()
    {
        new(m) T();
        deleter = freeObj<T>;
    }

    template<typename T>
    static void freeObj(void *p)
    {
        reinterpret_cast<T*>(p)->T::~T();
    }

    unsigned char m[sizeof(U)];
    deleter_t *deleter;
};


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

А что, если бы у нас было «умное объединение», которое не просто знало бы свой размер, но и позволяло бы динамически создавать в нем объекты перечисленных в этом объединении типов? Ну и при этом, разумеется осуществляло бы контроль типов.

Нет проблем! Это же C++!
template <typename ...Types>
class TypeUnion
{
public:
    // Разрешаем создание неинициализированных объектов
    TypeUnion() {};
    // Запретим копирование
    TypeUnion(const TypeUnion &) = delete;
    // Только перемещение
    TypeUnion(TypeUnion &&) = default;

    ~TypeUnion()
    {
        // Проверяем был ли размещен какой-нибудь объект
        // если да, разрушаем его
        if(deleter) deleter(mem);
    }

    // этот метод размещает в "объединении" объект типа T
    // при этом тип T должен быть перечислен среди типов указанных при создании объединения
    // Список аргументов args будет передан конструктору
    template <typename T, typename ...Args>
    void assign(Args&&... args)
    {
        // Проверяем на этапе компиляции возможность создания объекта в "объединении"
        static_assert ( usize, "TypeUnion is empty" );
        static_assert ( same_as<T>(), "Type must be present in the types list " );

        // Проверяем не размещен ли уже какой-то объект в памяти
        // Если размещен, освобождаем память от него.
        if(deleter) deleter(mem);

        // В выделенной памяти создаем объект типа Т
        // Создаем объект, используя точную передачу аргументов
        new(mem) T(std::forward<Args>(args)...);
        
        // эта функция корректно разрушит инстацированный объект
        deleter = freeMem<T>;
    }

    // Получаем указатель на размещенный в "объединении" объект
    template<typename T>
    T* get()
    {
        static_assert ( usize, "TypeUnion is empty" );
        assert ( deleter ); // TypeUnion::assign was not called
        return reinterpret_cast<T*>(mem);
    }

private:
    // функция этого типа будет использована для вызова деструктора
    typedef void (deleter_t)(void *);

    // Вдруг кто то захочет создать TypeUnion с пустым списком типов?
    static constexpr size_t max()
    {
        return 0;
    }
    // вычисляем максимум на этапе компиляции
    static constexpr size_t max(size_t r0)
    {
        return r0;
    }
    template <typename ...R>
    static constexpr size_t max(size_t r0, R... r)
    {
        return ( r0 > max(r...) ? r0 : max(r...) );
    }

    // is_same для нескольких типов
    template <typename T>
    static constexpr bool same_as()
    {
        return max( std::is_same<T, Types>::value... );
    }

    // шаблонная функция используется для разрушения размещенного в памяти объекта
    template<typename T>
    static void freeMem(void *p)
    {
        reinterpret_cast<T*>(p)->T::~T();
    }

    // Вычисляем максимальный размер из содержащихся типов на этапе компиляции
    static constexpr size_t usize = max( sizeof(Types)... );

    // Выделяем память, вмещающую объект наиболшего типа
    unsigned char mem[usize];

    deleter_t *deleter = nullptr;
};


Теперь и BaseCreator выглядит куда приятнее:
class BaseCreator
{
    TypeUnion<A, B> obj;

public:
    BaseCreator(bool x)
    {
        if(x) obj.assign<A>();
        else  obj.assign<B>();
    }

    // Запретим копирование
    BaseCreator(const BaseCreator &) = delete;
    // Только перемещение
    BaseCreator(BaseCreator &&) = default;

    Base* operator->()
    {
        return obj.get<Base>();
    }
};

Вот теперь перфект. Запись TypeUnion<A, B> obj нагляднее чем union U {A a; B b;}. И ошибка с несоответствием типов будет отловлена на этапе компиляции.

Полный код примера
#include <iostream>
#include <memory>
#include <cassert>

struct Base
{
    virtual void f() const = 0;
    virtual ~Base() {std::cout << "~Base()\n";}
};

struct A: public Base
{
    A(){std::cout << "A()\n";}
    virtual void f() const override{std::cout << "A::f\n";}
    virtual ~A() {std::cout << "~A()\n";}
};
struct B: public Base
{
    B(){std::cout << "B()\n";}
    virtual void f() const override{std::cout << "B::f\n";}
    virtual ~B() {std::cout << "~B()\n";}
    size_t i = 0;
};



template <typename ...Types>
class TypeUnion
{
public:
    // Разрешаем создание неинициализированных объектов
    TypeUnion() {};
    // Запретим копирование
    TypeUnion(const TypeUnion &) = delete;
    // Только перемещение
    TypeUnion(TypeUnion &&) = default;

    ~TypeUnion()
    {
        // Проверяем был ли размещен какой-нибудь объект
        // если да, разрушаем его
        if(deleter) deleter(mem);
    }

    // этот метод размещает в "объединении" объект типа T
    // при этом тип T должен быть перечислен среди типов указанных при создании объединения
    // Список аргументов args будет передан конструктору
    template <typename T, typename ...Args>
    void assign(Args&&... args)
    {
        // Проверяем на этапе компиляции возможность создания объекта в "объединении"
        static_assert ( usize, "TypeUnion is empty" );
        static_assert ( same_as<T>(), "Type must be present in the types list " );

        // Проверяем не размещен ли уже какой-то объект в памяти
        // Если размещен, освобождаем память от него.
        if(deleter) deleter(mem);

        // В выделенной памяти создаем объект типа Т
        // Создаем объект, используя точную передачу аргументов
        new(mem) T(std::forward<Args>(args)...);
        
        // эта функция корректно разрушит инстацированный объект
        deleter = freeMem<T>;
    }

    // Получаем указатель на размещенный в "объединении" объект
    template<typename T>
    T* get()
    {
        static_assert ( usize, "TypeUnion is empty" );
        assert ( deleter ); // TypeUnion::assign was not called
        return reinterpret_cast<T*>(mem);
    }

private:
    // функция этого типа будет использована для вызова деструктора
    typedef void (deleter_t)(void *);

    // Вдруг кто то захочет создать TypeUnion с пустым списком типов?
    static constexpr size_t max()
    {
        return 0;
    }
    // вычисляем максимум на этапе компиляции
    static constexpr size_t max(size_t r0)
    {
        return r0;
    }
    template <typename ...R>
    static constexpr size_t max(size_t r0, R... r)
    {
        return ( r0 > max(r...) ? r0 : max(r...) );
    }

    // is_same для нескольких типов
    template <typename T>
    static constexpr bool same_as()
    {
        return max( std::is_same<T, Types>::value... );
    }

    // шаблонная функция используется для разрушения размещенного в памяти объекта
    template<typename T>
    static void freeMem(void *p)
    {
        reinterpret_cast<T*>(p)->T::~T();
    }

    // Вычисляем максимальный размер из содержащихся типов на этапе компиляции
    static constexpr size_t usize = max( sizeof(Types)... );

    // Выделяем память, вмещающую объект наиболшего типа
    unsigned char mem[usize];

    deleter_t *deleter = nullptr;
};

class BaseCreator
{
    TypeUnion<A, B> obj;

public:
    BaseCreator(bool x)
    {
        if(x) obj.assign<A>();
        else  obj.assign<B>();
    }

    // Запретим копирование
    BaseCreator(const BaseCreator &) = delete;
    // Только перемещение
    BaseCreator(BaseCreator &&) = default;

    Base* operator->()
    {
        return obj.get<Base>();
    }
};

int main(int argc, char const *argv[])
{
    BaseCreator p(false);
    p->f();

    std::cout << "sizeof(BaseCreator):" << sizeof(BaseCreator) << std::endl;
    std::cout << "sizeof(A):" << sizeof(A) << std::endl;
    std::cout << "sizeof(B):" << sizeof(B) << std::endl;
    return 0;
}
//
// clang++ -std=c++11 1.cpp && ./a.out



Остались какие-нибудь грабли, которые я не заметил?

Спасибо за внимание!
Tags:
Hubs:
+27
Comments 49
Comments Comments 49

Articles