Как мы можем прочесть в первой главе книги Effective C++, язык С++ является по сути своей объединением 4 разных частей:
- Процедурная часть, доставшаяся в наследство от языка С
- Объектно-ориентировання часть
- STL, пытающийся следовать функциональной парадигме
- Шаблоны
Эти четыре, по сути, подъязыка составляют то, что мы называем единым языком С++. Поскольку все они объединены в одном языке, то это даёт им возможность взаимодействовать. Это взаимодействие порой порождает интересные ситуации. Сегодня мы рассмотрим одну из них — взаимодействие объектно-ориентированной модели и STL. Оно может принимать разнообразные формы и в данной статье мы рассмотрим передачу полиморфных функциональных объектов в алгоритмы STL. Эти два мира не всегда хорошо контачат, но мы можем построить между ними достаточно неплохой мостик.
Полиморфные функциональные объекты — что это?
Под функциональным объектом в С++ я понимаю объект, у которого можно вызвать 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.
Странно, правда? Мы передали алгоритму объект класса 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. В точности то, что нам и было необходимо.
Мне данное решение кажется лучшим, чем использование лямбда-функций, ведь тот же результат достигается более простым и лаконичным кодом. Возможно, существуют и другие решения данной проблемы — буду рад увидеть их в комментариях.
Автор: tangro