Как мы можем прочесть в первой главе книги Effective C++, язык С++ является по сути своей объединением 4 разных частей:

  • Процедурная часть, доставшаяся в наследство от языка С
  • Объектно-ориентировання часть
  • STL, пытающийся следовать функциональной парадигме
  • Шаблоны

Эти четыре, по сути, подъязыка составляют то, что мы называем единым языком С++. Поскольку все они объединены в одном языке, то это даёт им возможность взаимодействовать. Это взаимодействие порой порождает интересные ситуации. Сегодня мы рассмотрим одну из них — взаимодействие объектно-ориентированной модели и STL. Оно может принимать разнообразные формы и в данной статье мы рассмотрим передачу полиморфных функциональных объектов в алгоритмы STL. Эти два мира не всегда хорошо контачат, но мы можем построить между ними достаточно неплохой мостик.

image

Полиморфные функциональные объекты — что это?


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

struct Base
{
    int operator()(int) const
    {
        method();
        return 42;
    }
    virtual void method() const { std::cout << "Base class called.\n"; }
};

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

struct Derived : public Base
{
    void method() const override { std::cout << "Derived class called.\n"; }
};

Давайте попробуем передать наследника в STL-алгоритм тривиальным способом, вот так:

void f(Base const& base)
{
    std::vector<int> v = {1, 2, 3};
    std::transform(begin(v), end(v), begin(v), base);
}
 
int main()
{    
    Derived d;
    f(d);
}

Что бы вы думали выведет этот код?

Вот это
Base class called.
Base class called.
Base class called.

Странно, правда? Мы передали алгоритму объект класса Derived, с перегруженным виртуальным методом, но алгоритм решил вызвать вместо него метод базового класса. Чтобы понять, что произошло, давайте взглянем на прототип функции std::transform:

template< typename InputIterator, typename OutputIterator, typename Function>
OutputIt transform(InputIterator first, InputIterator last, OutputIterator out, Function f);

Посмотрите внимательно на её последний параметр (Function f) и обратите внимание, что он передаётся по значению. Как объясняется в главе 20 той же книги Effective C++, полиморфные объекты «срезаются», когда мы передаём их по значению: даже если ссылка на Base const& указывает на объект типа Derived, создание копии base создаёт объект типа Base, а не объект типа Derived.

Таким образом, нам нужен способ передать STL-алгоритму ссылку на полиморфный объект, а не на его копию.

Как это сделать?

Давайте завернём наш объект в ещё один


Эта мысль вообще приходит первой: «Проблема? Давайте решим её с помощью добавления косвенности!» Если наш объект должен быть сначала передан по ссылке, а STL-алгоритм принимает лишь объекты по значению, то мы можем создать промежуточный объект, который будет хранить ссылку на нужный нам полиморфный объект, а вот сам этот объект уже может передаваться по значению.

Простейший путь сделать это — использовать лямбда-функцию:

std::transform(begin(v), end(v), begin(v), [&base](int n){ return base(n); }

Теперь код выводит следующее:

Derived class called.
Derived class called.
Derived class called.


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

std::transform(begin(v), end(v), begin(v), [&base](module::domain::component myObject){ return base(myObject); }

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

Компактное решение: использовать std::ref


Есть и другой способ передачи полиморфного объекта по значению и заключается он в использовании std::ref

std::transform(begin(v), end(v), begin(v), std::ref(base));

Эффект будет такой же, как и от лямбда-функции:

Derived class called.
Derived class called.
Derived class called.


Возможно, сейчас у вас возникает вопрос «А почему?». У меня, например, он возник. Во-первых, как это вообще скомпилировалось? std::ref возвращает объект типа std::reference_wrapper, который моделирует ссылку (с тем лишь исключением, что ей можно переприсвоить другой объект с помощью использования operator=). Как же std::reference_wrapper может играть роль функционального объекта? Я посмотрел документацию по std::reference_wrapper на cppreference.com и нашел вот это:
std::reference_wrapper::operator()

Calls the Callable object, reference to which is stored. This function is available only if the stored reference points to a Callable object.
То есть это такая специальная фича в std::reference_wrapper: если std::ref принимает функциональный объект типа F, то возвращаемый объект-имитатор ссылки тоже будет функционального типа и его operator() будет вызывать operator() типа F. В точности то, что нам и было необходимо.

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

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


  1. slonopotamus
    25.04.2018 08:42
    +1

    Более лучшее решение — включать мозг при проектировании API. К сожалению, для C++ уже всё потрачено, в него добавлено чудовищное количество граблей. Включая подобные описанным в посте — из-за того что написать код который скопирует объект проще чем код который НЕ скопирует, это самое копирование вылезает тут и там.


    Замечу что в C такой проблемы бы не было, там бы transform принимал указатель на функцию и void* userdata, не имея проблем с полиморфизмом.


    1. tangro Автор
      25.04.2018 11:52

      Не имя проблем с полиморфизмом и имея проблемы с void*.


      1. slonopotamus
        25.04.2018 18:22

        C тоже имеет кучу проблем. Там где в C используется void* + функция, C++ может предложить более типобезопасный указатель/ссылку на объект.


    1. Antervis
      25.04.2018 12:11

      Замечу что в C такой проблемы бы не было, там бы transform принимал указатель на функцию и void* userdata, не имея проблем с полиморфизмом.

      «нет обобщенного программирования — нет проблем»?


  1. KruLV
    25.04.2018 11:52

    Самое первое что в голову приходит — это std::ref. Прочитал заголовок, ожидал срыва покровов, думал мне расскажут почему его на самом деле нельзя использовать.