Классический полиморфизм и std::variant (C++17).

Интересная статья натолкнула на попытку сравнения производительности кода, использующего старый, основанный на наследовании и виртуальных функциях полиморфизм и новый подход, использующий std::variant, появившийся в стандарте C++17.

Итак имеем типы, связанные наследованием от общего предка - абстрактного класса:

struct ShapeAbstract
{
    virtual void Print() = 0;
    virtual double Area() = 0;
    virtual ~ShapeAbstract() {};
};

struct CircleVirt : ShapeAbstract
{
    CircleVirt(double val) : radius(val) {}
    void Print() override { cout << "CircleVirt. " << "Radius: " << radius << endl; }
    double Area() override { return 3.14 * radius * radius; }
    double radius;
};

struct SquareVirt : ShapeAbstract
{
    SquareVirt(double val) : side(val) {}
    void Print() override { cout << "SquareVirt. Side: " << side << endl; }
    double Area() override { return side * side; }
    double side;
};

struct EquilateralTriangleVirt : ShapeAbstract
{
    EquilateralTriangleVirt(double val) : side(val) {}
    void Print() override { cout << "EquilateralTriangleVirt. Side: " << side << endl; }
    double Area() override { return (sqrt(3) / 4) * (side * side); }
    double side;
};

Создадим контейнер и поместим к него указатели на объекты этих типов

vector<ShapeAbstract*> shapes_abstr;
for(int i =0;i<SIZE;i++)
{
shapes_abstr.emplace_back(new EquilateralTriangleVirt { 5.6 });
shapes_abstr.emplace_back(new SquareVirt { 8.2 });
shapes_abstr.emplace_back(new CircleVirt { 3.1 });
}

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

for (ShapeAbstract* shape: shapes_abstr)
{
shape->Area();
}

За исключением появившегося в С++11 вида for (а также 'emplace_back' и 'override')- тут всё старо, как Страуструп (долгих лет ему).

В случае аллергии на "сырые" указатели и прогрессирующий склероз, приводящий к утечкам  компьютерной памяти можно прописать std::shared_ptr.

vector<shared_ptr<ShapeAbstract>> shapes_shared; 

for(int i =0;i<SIZE;i++)
{
shapes_shared.emplace_back(make_shared<EquilateralTriangleVirt>(10.4));
shapes_shared.emplace_back(make_shared<SquareVirt>(8.2));
shapes_shared.emplace_back(make_shared<CircleVirt>(3.1));
}

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

 for (shared_ptr<ShapeAbstract> shape: shapes_shared)
{
      shape->Area();
}

За красивую обертку придется заплатить производительностью во время выполнения.   По проведенным мной измерениям (цифры разнятся и не претендуют на точность, поэтому приводить не буду) - время заполнения контейнера в приведенном выше коде увеличивается в 1,5 раза, а выполнение виртуальных функций Area() для всех элементов контейнера - возрастает в 2-3 раза.

При отказе от наследования и виртуальных функций всё рушится. Разместить несвязанные типы в общем векторе не удастся и никакого полиморфного поведения не предвидится.

С++17 предлагает новый библиотечный тип std::variant. Если кто помнит работу с COM на С++ и union с аналогичным названием, то может вздрогнуть при этой новости. Но не всё так плохо, как считают некоторые пессимисты.

Представим себе типы, не связанные иерархией наследования

struct CircleVar
{
    void Print() { cout << "CircleVar. " << "Radius: " << radius << endl; }
    double Area() { return 3.14 * radius * radius; }
    double radius;
};

struct SquareVar
{
    void Print() { cout << "SquareVar. Side: " << side << endl; }
    double Area() { return side * side; }
    double side;
};

struct EquilateralTriangleVar
{
    void Print() { cout << "EquilateralTriangleVar. Side: " << side << endl; }
    double Area() {
        return (sqrt(3) / 4) * (side * side);
    }
    double side;
}; 

Определим variant, который может содержать указанные типы

using ShapeVar = variant<CircleVar, SquareVar, EquilateralTriangleVar>;

Для работы  std::visit нам потребуется Visitor. Реализуем его так

template <class... Func>
struct Visitor : Func...
{
using Func::operator()...;
};

template <class... Func> Visitor(Func...) -> Visitor <Func...>;

Создаем контейнер с std::variant.

vector<ShapeVar> shapes_var;

Заполняем его

 for(int i =0;i<SIZE;i++)
{
shapes_var.emplace_back(EquilateralTriangleVar { 5.6 });
shapes_var.emplace_back(SquareVar { 8.2 });
shapes_var.emplace_back(CircleVar { 3.1 });
}

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

Вызываем функции объектов в контейнере с использованием std::visit, приведенного выше Visitor и определенных "по месту" лямбда-функций, которые манипулируют функциями объектов желаемым образом.

for (ShapeVar& shape: shapes_var)
{
    visit(Visitor{
                        [](CircleVar& c) { cout << c.Area(); },
                        [](SquareVar& c) { c.Print(); },
                        [](EquilateralTriangleVar& c) { c.Area(); }
    }, shape);
}

Время вызова тоже вдвое меньше - чем время вызова виртуальных функций в случае "сырых" указателей.

Итак - в производительности при использовании std::variant мы выигрываем. По читабельности кода - можно прочитать статьи по ссылкам, приведенным выше. Несвязанность типов внутри std::variant - это  несомненный плюс. Поддержка кода - в случае добавления/изменения типов - это вопрос требующий осмысления.

See more "Another polymorphism", "Inheritance vs std::variant"




Комментарии

Популярные сообщения из этого блога

Полезные новации С++17. Выражения свертки (Fold expressions)

Перегрузка и специализация шаблонных функций.

Поддержка декомпозиции при объявлении (structural bindings) для классов