Перейти к содержимому

Полиморфизм

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

Статический полиморфизм (перегрузка функций)

Заголовок раздела «Статический полиморфизм (перегрузка функций)»

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

void f(int a)
{
std::cout << "int";
}
void f(float a)
{
std::cout << "float";
}
int main()
{
f(5); // int
f(5.0f); // float
return 0;
}

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

Заметьте, в C, в отличие от C++, перегрузки функций нет. Чтобы получить ту же функциональность в C, пришлось бы дать функциям разные имена. Обычно к именам так или иначе добавляется префикс с типом аргумента.

void f_int(int a)
{
std::cout << "int";
}
void f_float(float a)
{
std::cout << "float";
}
int main()
{
f_int(5); // int
f_float(5.0f); // float
return 0;
}

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

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

С ее помощью можно реализовать примитивную форму паттерна «стратегия». Для коллекций он не так мощен, как паттерн «итератор», но и в этом контексте его можно использовать. А еще эти два приема можно объединить и получить абстракцию над циклами.

См. пример.

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

Конечно, этого можно добиться и через switch. Но что, если главной функции неизвестно — или не должно быть известно — обо всех возможных функциях? Как в следующем примере. Такое легко случается, если вы хотите держать сами функции в каком-то другом модуле статическими (static) и выставлять наружу лишь функцию, добавляющую эти внутренние функции в список. Подобного со switch никак не добиться, не выставив внутренние функции.

main.cpp
#include "math_functions.h"
// ...
addMathFuncs(functionList);
math_functions.h
void addMathFuncs(std::vector<ButtonFunc>& functionList);
math_functions.cpp
static void add()
{
// ...
}
static void subtract()
{
// ...
}
void addMathFuncs(std::vector<ButtonFunc>& functionList)
{
functionList.push_back(add);
functionList.push_back(subtract);
}

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

void add5(int& a)
{
a += 5;
}
void addN(int& a, int n)
{
a += n;
}
// ...
// Полностью нормально
forEachItem(list, add5);
// Невозможно
int increment = 5;
forEachItem(list, addN(???, increment));

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

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

См. пример. У него есть несколько недостатков: например, нелегко сделать const-перегрузку, не дублируя весь код, — но не зацикливайтесь на этом. Другая проблема: разным функциям могут требоваться разные типы контекста, так что полиморфизм здесь по сути снова статический, но по типу контекста, а не по функции.

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

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

Шаблонные состояние и функция в одном (функторы)

Заголовок раздела «Шаблонные состояние и функция в одном (функторы)»

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

Чтобы надежно вызывать метод из шаблонного определения, нужно решить, как функция должна называться. C++ выбрал для этого оператор вызова operator(), потому что с ним можно передавать указатели на функции как функторы. См. пример.

Заметьте: для каждого нового типа, передаваемого в forEachItem, в итоговом исполняемом файле будет создана новая копия функции (если только она не будет урезана / слита с существующим определением / оптимизирована прочь). Чаще всего об этом не стоит беспокоиться, но особенно большая функция может раздуть исполняемый файл (то есть сделать его слишком большим), и тогда более динамичный подход может оказаться разумнее.

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

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

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

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

Заметьте, mutable требуется, если функция хочет менять значение своего контекста. Без него к сгенерированному методу operator() добавляется const, так что действуют обычные правила const для ссылок и значений.

Акт копирования переменных называется захватом (capture).

Можно захватывать и по ссылке, добавив & перед именем переменной в группе захвата. Так контекст можно разделить между несколькими лямбдами. См. пример.

Типы захватов можно смешивать.

int a = 1;
float b = 8;
auto func = [&a, b]() mutable {
a += 5;
b *= 10;
};
func();
assert(a == 6);
assert(b == 8); // `b` скопирована по значению

И можно захватывать несколько переменных одного типа:

int a;
float b;
auto func = [&a, &b]() mutable {
a += 5;
b *= 10;
};

Например, в этом примере в функцию можно передавать лямбды:

// Здесь `a` — ссылка.
forEachItem(values, [&a = values[0]](float& value){ value *= a; });
// или сохранить адрес (здесь `a` — указатель)
forEachItem(values, [a = &values[0]](float& value){ value *= *a; });

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

auto createBadFunctor()
{
int localVariable{5};
return [&localVariable](){ return localVariable; };
}
int main()
{
auto func = createBadFunctor();
func(); // ссылается на удаленную память — неопределенное поведение
return 0;
}

Захватить все видимое в текущей области видимости можно по значению через [=] или по ссылке через [&].

int a = 1;
int b = 2;
{
auto func = [=]() mutable {
a += 5;
b *= 10;
};
func(); // a = 1, b = 2
}
{
auto func = [&]() {
a += 5;
b *= 10;
};
func(); // a = 6, b = 20
}

Можно также задать & или = как умолчание, а затем переопределить его для конкретных переменных:

int a = 1;
int b = 2;
// По умолчанию `&`, но `a` нужно скопировать.
auto func = [&, a]() mutable {
a += 5;
b *= 10;
};
func(); // a = 1, b = 20

Переменные захватываются, только если они используются в лямбде. Это проиллюстрировано здесь.

Это несколько ломает мозг, но объявления функций имеют типы в этом языке. Синтаксис такой же, как у типов указателей на функции, но без (*):

using FuncType = void(int, int);
FuncType f; // объявление
void f(int a, int b); // эквивалентное объявление
void f(int a, int b) { } // определение

Как и для переменных, можно завести ссылки на функции:

using FuncRefType = void(&)(int, int);
void f(int a, int b) { }
FuncRefType g = f;

Или через decltype:

void f(int a, int b) {}
decltype(f)& g = f;

В документации std::function представлена как general-purpose polymorphic function wrapper — универсальная полиморфная обертка для функций. Идея std::function — уметь присваивать себе любую функцию или функтор, потенциально с захваченным контекстом, и вызывать их позже.

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

Стоит отметить, что std::function — RAII-тип и может выделять динамическую память.

std::function реализует именно динамический полиморфизм, а значит, не приведет к разрастанию кода, о котором говорилось ранее, но даст накладные расходы во время выполнения при вызове функции (как вызов функции через указатель медленнее прямого вызова, так и вызов функтора через std::function медленнее прямого).

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

Передача нескольких функций (vtable, толстые указатели)

Заголовок раздела «Передача нескольких функций (vtable, толстые указатели)»

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

using IntroductionFunc = void(*)();
using SumFunc = int(*)(int a, int b);
struct Behavior
{
IntroductionFunc introduceOneself;
SumFunc answer;
};
void test(Behavior& behavior)
{
behavior.introduceOneself();
int answer = behavior.answer(5, 6);
std::cout << answer << std::endl;
}
int main()
{
Behavior dumbCat{
[](){ std::cout << "Meow" << std::endl; },
[](int a, int b){ return 0; },
};
Behavior smartDog{
[](){ std::cout << "Woof" << std::endl; },
[](int a, int b){ return a + b; },
};
test(dumbCat); // Meow, 0
test(smartDog); // Woof, 11
return 0;
}

Можно представить похожую реализацию с std::function, которая позволит каждой функции захватить и некоторый контекст.

Теперь: что делать, если мы хотим, чтобы обе функции разделяли один контекст, и при этом хотим динамический полиморфизм (функции должны быть либо указателями на функции, либо std::function)? Конечно, можно разделять контекст в std::function, но это расточительно. Память контекста окажется где-то сохранена, и в каждую функцию придется сохранять ссылку на нее. Можно избежать хранения указателей, просто передавая контекст вручную.

Решение, которое делает то, что нам нужно, — использовать void* для контекста. Да, это громоздко и опасно, но оно делает то, что нам нужно. Мы передаем указатель на контекст вместе с указателем на структуру с функциями, которая также называется таблицей виртуальных методов или vtable. Мы используем указатель на vtable, а не саму vtable, просто потому, что хотим использовать одну vtable для множества контекстов. См. пример.

Примерно так динамический полиморфизм устроен в Rust.

Допустим, мы хотим иметь несколько vtable. Пусть одна vtable отвечает за приветствия (методы вроде introduceSelf при приветствии и excuseSelf при прощании), а другая — за вычисление ответов на вопросы (вроде computeSum и writeCppProgram).

Конечно, это можно сделать толстыми указателями, но виртуальное наследование в C++ идет другим путем. Вместо этого они изменяют указатель, подстраивая его так, чтобы он смотрел на таблицу методов, а не на начало объекта, и хранят в vtable смещение до начала данных объекта, чтобы можно было добраться до контекста.

🤓 раскладка vtable и подстройка указателя — деталь реализации, стандарт технически не предписывает ничего из этого. Но по сути это примерно то, что наследование в C++ и делает: на практике каждый современный компилятор подстраивает указатели и хранит смещения подобным образом.

См. пример.

Я не буду заставлять вас досконально разбираться в этом коде, но если хотите понять тему — обязательно разберитесь. Вот видео, которое мне тоже советовали.

Чисто абстрактные базовые классы и виртуальные функции

Заголовок раздела «Чисто абстрактные базовые классы и виртуальные функции»

Идея таблицы методов, которую я описал и реализовал ранее, в C++ доступна как примитив языка.

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

Чтобы задать устройство vtable, можно определить чисто абстрактный базовый класс с виртуальными методами, равными 0, что означает «не реализовано». Другое название этого — интерфейс.

class GreetingAbstractBase
{
public:
virtual void introduceSelf() = 0;
virtual void excuseSelf() = 0;
};

Затем можно унаследовать их в классе со своим контекстом и переопределить методы. Это инициализирует vtable и добавит неявное поле-указатель на каждую из них в вашем производном классе (том, который наследует).

class Person :
public GreetingAbstractBase,
public QuestionAbstractBase
{
// ...
void introduceSelf() override
{
// ...
}
// ...
};

Синтаксис приведения и вызова становится куда проще — в конце концов, это примитив языка.

Person person;
Person* personPtr = &person;
GreetingAbstractBase* greetingPtr = personPtr;
QuestionAbstractBase* questionPtr = personPtr;
// Указатели смотрят на разные участки памяти.
assert(static_cast<void*>(greetingPtr) != static_cast<void*>(questionPtr));
// Контекст подстраивается и передается неявно.
greetingPtr->introduceSelf();

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

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

См. пример