C++ сложный и интересный язык, совершенствоваться в нем можно чуть ли не всю жизнь. В какой-то момент мне захотелось изучать его следующим образом: взять какой-то аспект языка, возможно довольно узкий, и разобраться с ним максимально глубоко и подробно. Такой подход в значительной степени был стимулирован замечательными книгами Скотта Мейерса, Герба Саттера и Стефана Дьюхэрста. Когда накопилось определенное количество материалов, решил познакомить с ними Хабровчан. Так появилась эта серия, которую я назвал «C++, копаем в глубь». Серия помечена как Tutorial, но ориентирована она все-таки не на начинающих, а скорее на intermediate уровень. Первая тема — это перегрузка в C++. Тема оказалась очень обширной и получилось три статьи. Первая статья посвящена перегрузке функций и шаблонов, вторая перегрузке операторов и третья перегрузке операторов управления памятью. Итак начнем копать.
Оглавление
Введение
1. Общие положения
1.1. Перегруженные функции
1.2. Общая схема алгоритма поиска функции
1.3. Текущая область видимости и разрешение перегрузки во вложенных областях видимости
1.3.1. Выбор текущей области видимости
1.3.2. Разрешение перегрузки в классах
1.3.3. Локальное объявление функций
1.4. Расширение области видимости для разрешения перегрузки
1.4.1. Использования using
-объявления в классе
1.4.2. Использования using
-объявления локально и в пространстве имен
1.4.3. Использования using
-директивы
1.4.4. Поиск, зависимый от типа аргументов
2. Некоторые правила разрешения перегрузки
2.1. Неявные преобразования типа и параметры «близкого» типа
2.2. Нулевой указатель
2.3. Универсальная инициализация и списки инициализации
2.4. Функции с переменным числом параметров
2.5. Шаблоны функций
2.5.1. Общие правила перегрузки
2.5.2. Принцип SFINAE
2.5.3. Пример разрешения перегрузки
2.5.4. Управление перегрузкой шаблонов
2.6. Правила разрешения перегрузки для параметров «родственного» типа
2.6.1. Передача параметров по ссылке, ссылке на константу и по значению
2.6.2. Rvalue ссылки
2.6.3. Универсальные ссылки
3. Другие темы, связанные с перегрузкой
3.1. Параметры неполного типа
3.2. Инициализация указателя на функцию
3.3. Перегрузка и параметры по умолчанию
3.4. Перегрузка виртуальных функций
3.5. Метапрограммирование
4. Итоги
Приложения
Приложение А. Двойная диспетчеризация и паттерн Visitor
Приложение Б. Подмена стандартных функций пользовательскими версиями
Список литературы
Введение
В широком смысле перегрузка (overloading) — это возможность одновременно использовать несколько функций с одним именем. Компилятор различает их благодаря тому, что они имеют разный набор параметров. В точки вызова компилятор анализирует типы аргументов и определяет, какая конкретно функция должна быть вызвана. В русскоязычной литературе иногда можно встретить термин «совместное использование», но, похоже, он не прижился.
Перегрузка поддерживается многими языками программирования, мы будем рассматривать только C++17.
1. Общие положения
1.1. Перегруженные функции
Функции (а также шаблоны функций) называются перегруженными (overloaded), если они объявлены в одной области видимости (scope) и имеют одно и то же имя. Перегруженные функции не могут иметь разные типы возвращаемого значения, спецификатор исключений или спецификатор удаленной функции (=delete
) при одинаковых параметрах.
void Foo();
char Foo(); // ошибка
void Foo(int x);
void Foo(int x) noexcept; // ошибка
void Foo(double x);
void Foo(double) = delete; // ошибка
Но несколько идентичных объявлений допустимы, компилятор просто игнорирует копии.
Также надо учитывать, что компилятор выполняет некоторые стандартные преобразования типов параметров функций. Для типа массива выполняется сведение (decay) к указателю, поэтому
void Foo(int x[4]);
void Foo(int x[]);
void Foo(int *x);
не перегруженные функции, это одно и то же.
Параметры типа функция сводятся к указателю на функцию.
Для параметров, передаваемых по значению, удаляется квалификатор const
(и volatile
), поэтому
void Foo(int x);
void Foo(const int x);
не перегруженные функции, это одно и то же.
1.2. Общая схема алгоритма поиска функции
В общих чертах алгоритм поиска функции можно описать следующим образом. На первом этапе компилятор осуществляет поиск (lookup) тех перегруженных функций, которые по правилам языка допустимы для данного вызова (candidate functions). В случае шаблонов выполняется еще вывод аргументов шаблона (template argument deduction). У этих функций количество параметров должно совпадать с количеством аргументов и тип аргументов должен совпадать с типом параметров (или существовать неявное преобразование типа аргументов к типу параметров). Если таких функций не найдено, поиск завершается ошибкой. Если найдена ровно одна функция, то поиск завершается успешно. Если найдено несколько функций, то начинается следующий этап, компилятор пытается выбрать ту, которая подходит «лучше всего» для данных аргументов (match the arguments most closely). Этот этап называется разрешением перегрузки (overload resolution). Если такая функция найдена, то разрешение перегрузки завершается успешно, иначе возникает ошибка (ambiguous call to overloaded function). Рассмотрим пример:
void Foo(float x);
void Foo(double x);
Для вызова Foo("meow")
ни одной подходящей функции не найдено, для вызова Foo(42)
подходят обе функции, компилятор не может выбрать наиболее подходящую, а вот вызовы Foo(3.14f)
и Foo(3.14)
разрешаются успешно.
Правила выбора наиболее подходящей функции (overload resolution rules) при попытке полного и формального описания могут оказаться весьма сложными и запутанными (это из тех вещей, которые до конца знают только разработчики компилятора), но как это часто бывает, во многих практически значимых случаях они являются интуитивно понятными и особых проблем у программиста не вызывают. Часть из них будет описана ниже.
Обычно термином «разрешение перегрузки» удобно описывать обе фазы: поиск функций-кандидатов и выбор наиболее подходящей функции. В дальнейшем мы будем придерживаться этого соглашения.
Но успешное разрешение перегрузки — это еще не все. После разрешения перегрузки производится проверка на доступность выбранной функции в точке вызова (то есть не является ли она private
или protected
). В случае успеха производится проверка на удаленность (то есть не объявлена ли она как =delete
). Если эти проверки не проходят, компиляция завершается с ошибкой. Обратим внимание на то, что эти проверки никак не влияют на процедуру разрешения перегрузки, они всегда выполнятся после.
1.3. Текущая область видимости и разрешение перегрузки во вложенных областях видимости
Как уже отмечалось выше, перегруженные функции по определению находятся в одной области видимости. Области видимости вложены друг в друга. Области видимости, определяемые пространствами имен могут быть вложены друг в друга. Любое пространство имен вложено в глобальное пространство имен. Область видимости производного класса вложена в области видимости базовых классов, которые, в свою очередь, вложены в область видимости пространства имен. Локальные области видимости (блоки) вложены в другие блоки и далее в область видимости класса или пространства имен.
При разрешении перегрузки компилятор прежде всего должен выбрать область видимости, в которой и будет выполнятся разрешение перегрузки. Такая область видимости называется текущей. Если в текущей области видимости нет ни одной функции с искомым именем, текущей областью видимости становится объемлющая область видимости. Но, если в текущей области видимости найдена хотя бы одна функция с искомым именем, то выполняется разрешение перегрузки в данной области видимости и объемлющая область видимости рассматриваться не будет. Функции из текущей области видимости будут скрывать (hide) одноименные функции из объемлющих областей видимости. Подчеркнем, что это не зависит от результата разрешения перегрузки, подходящая функция может быть не найдена, оказаться неоднозначной, недоступной или удаленной, все равно продолжения поиска в объемлющей области видимости не будет.
1.3.1. Выбор текущей области видимости
Первоначальная текущая область видимости и возможные объемлющие области видимости, в которые может осуществляться переход для разрешения перегрузки, определяется контекстом в точке вызова функции. Приведем примеры.
class X {/* ... */};
// ...
X x;
x.Foo();
X::Foo(42);
В этих случаях первоначальной текущей областью видимости будет класс X
. Объемлющие области видимости ограничены базовыми классами X
.
namespace N {/* ... */}
// ...
N::Foo();
В этом случае первоначальной текущей областью видимости будет пространство имен N
, переход в объемлющие области вообще не выполняется.
::Foo();
В этом случае первоначальной текущей областью видимости будет глобальное пространство имен, объемлющих областей видимости нет.
Рассмотрим теперь «голые» вызовы функций без дополнительных квалификаторов класса или пространства имен.
Если такой вызов находится в пространстве имен, то это пространство имен и будет первоначальной текущей областью видимости, объемлющими областями видимости будут объемлющие пространства имен.
Если такой вызов находится в пространстве имен класса (например при инициализации статического члена), то соответствующий класс будет первоначальной текущей областью видимости, объемлющими областями видимости будут базовые классы и далее объемлющие пространства имен.
Пусть такой вызов находится в блоке
{
Foo();
}
В этом случае первоначальной текущей областью видимости будет этот блок. (Напомним, что возможны локальные объявления функций, подробнее см. далее.) Объемлющими областями видимости будут объемлющие блоки, далее класс (если блок находится в функции-члене) и далее объемлющие пространства имен.
1.3.2. Разрешение перегрузки в классах
Рассмотрим пример.
class B
{
// ...
public:
void Foo(int x);
};
class D : public B
{
// ...
public:
D();
void Foo(double x);
};
// ...
D d;
d.Foo(42);
Какая из двух доступных Foo
, будет выбрана? Правильный ответ D::Foo(double)
, хотя B::Foo(int)
подходит лучше и доступна в точке вызова. Поиск начинается с текущей области видимости (в данном случае класс D
), найдена функция с соответствующим именем, объемлющая область видимости (в данном случае класс B
) не рассматривается. Единственная найденная функция D::Foo(double)
может быть вызвана с данным аргументом и разрешение перегрузки завершается успешно. Если бы D::Foo(double)
была бы объявлена закрытой или защищенной или удаленной, то компиляция завершилась бы ошибкой, но B::Foo(int)
все равно бы не рассматривалась, хотя она и доступна в точке вызова. И только, если из класса D
совсем убрать Foo
, то компилятор сделал бы текущей областью видимости класс B
и выбрал бы B::Foo(int)
.
Эти правила могут оказаться достаточно неожиданными для программиста. Наследование в C++ спроектировано так, чтобы сделать границу между производным и базовым классом максимально прозрачной, а в данном случае такой прозрачности нет. При неблагоприятных условиях это может привести к трудно обнаруживаемым ошибкам. Например, можно получить бесконечную рекурсию. (Но это еще не худший вариант, такая ошибка сразу обнаружится при выполнении.)
1.3.3. Локальное объявление функций
Рассмотрим теперь одну редко используемую особенность C++, которая называется локальные объявления функций. Функции можно объявлять локально (в блоке), например:
{
void Foo();
void Foo(int x);
// ...
Foo(42);
// ...
}
Функции, объявленные локально, должны быть определены в глобальном пространстве имен, локальные определения в C++ не разрешены. Если функция вызывается в блоке без дополнительных квалификаторов класса или пространства имен, то текущей областью видимости, в которой происходит разрешение перегрузки, будет этот блок. Если в блоке есть локальные объявления функций, то одноименные функции из объемлющих областей видимости будут скрыты. Если в блоках нет локальных объявлений функций (что обычно и бывает), то текущая область видимости переместится в конце концов в класс (если блок находится в функции-члене класса) и далее в объемлющие пространства имен.
1.4. Расширение области видимости для разрешения перегрузки
Область видимости для разрешения перегрузки можно расширить с помощью using
-объявления и using
-директивы. Также, в определенном случае, компилятор самостоятельно расширяет область видимости для разрешения перегрузки.
Следует обратить внимание, на то, что расширение области видимости может вызвать конфликты, например, если в расширенную область видимости добавляются функции с таким же набором параметров, как и в текущей.
1.4.1. Использования using-объявления в классе
Вот как это делается для предыдущего примера:
class B
{
// ...
public:
void Foo(int x);
};
class D : public B
{
// ...
public:
using B::Foo;
void Foo(double x);
};
// ...
D d;
d.Foo(42);
После этого в разрешении перегрузки будут участвовать перегруженные Foo
из области видимости класса B
, и компилятор выберет B::Foo(int)
.
1.4.2. Использования using-объявления локально и в пространстве имен
Использовать using
-объявление с использованием имени класса можно только в области видимости производного класса, как показано в предыдущем разделе. Локально или в области видимости пространства имен можно использовать using
-объявления с использованием имени пространства имен. Но надо быть внимательным, так как такое объявление будет скрывать соответствующие имена из объемлющей области видимости и, если неудачно выбрать область видимости для размещения using
-объявления, то можно получить не расширение, а нежелательное изменение области видимости для разрешения перегрузки. Пример:
namespace N
{
void Foo(int x);
}
void Foo(const char* x);
// ...
void Test()
{
using N::Foo; // скрывает Foo(const char*)
Foo(42); // OK, N::Foo(int x)
Foo("meow"); // ошибка, Foo(const char*) скрыта
}
В данном случае, для того чтобы обе версии Foo
участвовали в разрешении перегрузки, using
-объявление надо размещать так:
namespace N
{
void Foo(int x);
}
void Foo(const char* x);
// ...
using N::Foo;
void Test()
{
Foo(42); // OK, N::Foo(int)
Foo("meow"); // OK, Foo(const char*)
}
1.4.3. Использования using-директивы
Пусть у нас есть некоторое пространство имен N
. Инструкция
using namespace N;
называется using
-директивой. В области ее видимости можно использовать имена из пространства имен N
без квалификатора N::
. При разрешении перегрузки также будут участвовать функции из N
, то есть using
-директива приводит к расширению области видимости для разрешения перегрузки (и, в отличии от using
-объявления, ничего не скрывает). Но, вообще, к using
-директиве надо относиться весьма осторожно, об этом написано немало.
Если используется анонимное пространство имен, то функции, объявленные в нем, будут участвовать в разрешении перегрузки вместе с одноименными функциями, объявленными в объемлющем пространстве имен. (Фактически анонимное пространство имен доступно через скрытую using
-директиву.)
namespace
{
void Foo(int x);
}
void Foo(const char* x);
// ...
Foo(42); // OK, Foo(int), анонимное пространство имен
Foo("meow"); // OK, Foo(const char*)
1.4.4. Поиск, зависимый от типа аргументов
Есть одна ситуация, когда компилятор самостоятельно расширяет текущую область видимости для разрешения перегрузки. Рассмотрим объявление класса и функции в некотором пространстве имен:
namespace N
{
class X {/* ... */};
void Foo(const X& x);
}
Рассмотрим код (вне пространства имен N
):
N::X x;
Foo(x);
В этом случае при разрешении перегрузки компилятор подключит пространство имен N
и, если не будет конфликта с текущей областью видимости, будет выбрана N::Foo(const X&)
. Это и называется поиском, зависимым от типа аргументов (argument depended lookup, ADL), называемый еще поиском Кёнига. ADL играет важную роль при перегрузке операторов, функций стандартной библиотеки и в других случаях.
2. Некоторые правила разрешения перегрузки
В данном разделе рассматриваются более специальные правила разрешения перегрузки, применяемые в особых случаях.
2.1. Неявные преобразования типа и параметры «близкого» типа
В C++ довольно много неявных преобразований типа. Это в определенных ситуациях может привести к проблемам, в том числе создавать неоднозначность при разрешении перегрузки. Но тем не менее при разрешении перегрузки типы, преобразующиеся в друг друга с помощью неявных преобразований, различаются. Общее правило такое: вариант, не требующий преобразований, имеет приоритет.
Одним из проблемных типов является bool
. Для совместимости с С существует неявное преобразование bool
в int
и неявное преобразование любого числового типа и указателя в bool
. Это может породить много трудно обнаруживаемых ошибок. Но в простых случаях при разрешении перегрузки bool
четко отделяется от int
.
void Foo(int x);
void Foo(bool x);
// ...
int x = 6, y = 5;
Foo(x == y); // Foo(bool)
Foo(x = y); // Foo(int)
Но для таких перегруженных функций
void Foo(bool x, int y);
void Foo(int x, bool y);
неоднозначный вызов уже сделать легче, вот пример:
Foo(1, 2);
Определенные проблемы также доставляют перечисления, тип перечисления неявно преобразуется в целочисленные типы. При перегрузке тип перечисления может четко отделятся от int
.
enum Qq { One = 1, Two };
void Foo(int x);
void Foo(Qq x);
// ...
Foo(One); // Foo(Qq)
Foo(42); // Foo(int)
Семантически и побитово совпадающие типы, например, int
и long
также различаются при разрешении перегрузки.
void Foo(int x);
void Foo(long x);
// ...
Foo(42); // Foo(int)
Foo(42L); // Foo(long)
Иногда при разрешении перегрузки желательно исключить некоторые неявные преобразования. В этом случае можно воспользоваться удаленными функциями. Предположим мы хотим иметь функции, которые можно вызывать для целочисленных аргументов, но нельзя вызывать для аргументов плавающего типа. Это можно сделать так:
void Foo(long x);
void Foo(long long x);
void Foo(float) = delete;
void Foo(double) = delete;
void Foo(long double) = delete;
2.2. Нулевой указатель
В C++11 был введен новый тип — std::nullptr_t
с единственным значением nullptr
. Это позволило избежать потенциальных проблем связанных с неявным преобразованием литерального нуля к типу указателя.
void Foo(int x);
void Foo(void* x);
// ...
Foo(0); // Foo(int)
Foo(nullptr); // Foo(void*)
В C++98 приходилось писать
Foo((void*)0);
Но это еще не все преимущества. Так как nullptr
имеет свой собственный тип, можно перегружать функции по значению nullptr
.
void Foo(void* x);
void Foo(std::nullptr_t);
// ...
void* x = nullptr;
Foo(x); // Foo(void*)
Foo(nullptr); // Foo(std::nullptr_t)
Подобные перегрузки используются в интерфейсе стандартных интеллектуальных указателей.
2.3. Универсальная инициализация и списки инициализации
В С++11 появилась концепция универсальной инициализации с использованием фигурных скобок и добавлен новый шаблон — std::intializer_list<>
. Как это часто бывает, возникли некоторые неоднозначности, которые пришлось устранять с помощью дополнительных правил. По существу эти правила относятся к правилам разрешения перегрузки для конструкторов. Вот эти правила.
- Пустые фигурные скобки —
{}
, означают выбор конструктора по умолчанию. Если его нет, то возникает ошибка. - Для непустого списка в фигурных скобках сначала ищется конструктор, с параметром типа
std::intializer_list<>
. Если его нет или элементы списка не подходят дляstd::intializer_list<>
, ищется другой конструктор, подходящий для элементов списка. Но если элементы списка можно преобразовать к типу, требуемомуstd::intializer_list<>
, с помощью неявного сужающего преобразования (то есть преобразования с потерей точности числовых данных), то возникает ошибка.
Рассмотрим несколько примеров для стандартного вектора std::vector<T>
, который имеет конструктор с параметром типа std::intializer_list<T>
.
Вот первый пример:
std::vector<int> v1(3, 1), v2{3, 1};
В этом случае v1
— это вектор размера 3 с элементами равными 1. v2
— это вектор размера 2 с элементами равными 3 и 1, выбирается конструктор с параметром типа std::intializer_list<int>
, хотя есть и другой конструктор, принимающий элементы списка.
Другой пример:
std::vector<const char*> u1(3, "meow"), u2{3, "meow"};
В этом случае u1
и u2
одинаковы, векторы размера 3. Для u2
элементы списка не подходят для конструктора с параметром типа std::intializer_list<const char*>
и поэтому выбирается конструктор тот же, что и для u1
.
И третий пример:
std::vector<bool> b1(3, true), b2{3, true};
В этом случае b1
вектор размера 3 с элементами, равными true
. А вот b2
не компилируется, так как для конструктора с параметром типа std::intializer_list<bool>
требуется сужающее преобразование от int
к bool
.
Подробнее про универсальную инициализацию можно почитать у Скотта Мейерса [Meyers2].
2.4. Функции с переменным числом параметров
Функция с переменным числом параметров, то есть имеющая ...
в конце списка параметров, как кандидат на разрешение перегрузки всегда будет рассматриваться только в тех случаях, когда нет ни одной подходящей функции с постоянным числом параметров. Наличие хотя бы одной подходящий функции с постоянным числом параметров исключает функцию с переменным числом параметров из кандидатов на разрешение перегрузки.
2.5. Шаблоны функций
Напомним, что шаблоны функций могут иметь полную специализацию для некоторого шаблонного аргумента, но не могут иметь частичных специализаций. Вместо частичной специализации используется перегруженный шаблон — одноименный шаблон функции c другими параметрами.
Шаблоны функций и их полные специализации могут участвовать в перегрузке вместе с нешаблонными функциями. Полные специализации шаблонов участвуют в перегрузке довольно специфическим образом (можно даже говорить, что они в перегрузке не участвуют), детали изложены ниже.
2.5.1. Общие правила перегрузки
При разрешении перегрузки сначала рассматриваются нешаблонные функции и конкретизации шаблонов. В первую очередь рассматриваются варианты точного совпадения типов аргументов и параметров, то есть варианты не требующие неявных преобразований типов аргументов. Если таких вариантов насколько, то приоритет имеют нешаблонные функции. Если нешаблонная функция не выбрана, то среди конкретизаций шаблонов приоритет будут иметь более специализированные шаблоны.
Если выбрана конкретизация шаблона, то проверяется, нет ли полной специализации этого шаблона для выведенного типа аргумента конкретизации. Если такая специализация есть, то выбирается она. Обратим внимание на то, что полные специализации рассматриваются в последнюю очередь, после выбора шаблона. Подробнее про описанный алгоритм разрешения перегрузки можно почитать у Герба Саттера [Sutter2].
В C++11 появились шаблоны с переменным количеством параметров или вариативные шаблоны (variadic templates). Если для некоторого вызова допустимыми являются конкретизации вариативного шаблона и обычного, то последний всегда будет считаться более специализированным и, соответственно, выбран при разрешении перегрузки.
2.5.2. Принцип SFINAE
Если у нас есть шаблон функции, то может возникнуть ситуация, когда для некоторого вызова компилятор не сможет вывести тип аргумента шаблона. Вот пример:
template<typename T>
void Foo(const T* x);
// ...
Foo(42);
В этом случае, если в текущей области видимости есть перегруженные шаблоны, для которых аргументы выведены успешно, или перегруженные нешаблонные функции, то ошибки не возникает, такой шаблон просто «молча» исключается из разрешения перегрузки. Это и называется принципом SFINAE, который расшифровывается как Substitution Failure is not an Error (сбой при подстановке не является ошибкой).
2.5.3. Пример разрешения перегрузки
Рассмотрим пример перегруженных функций, шаблонов и полных специализаций шаблонов.
void Foo(int x); // нешаблонная функция
template<typename T>
void Foo(T x); // шаблон 1
template<>
void Foo<double>(double x); // полная специализация шаблона 1 для double
template<>
void Foo<const char*>(const char* x); // полная специализация шаблона 1 для const char*
template<typename T>
void Foo(const T* x); // шаблон 2, более специализированный чем шаблон 1
template<typename T>
class U {/* ... */};
template<typename T>
void Foo(U<T> u); // шаблон 3, более специализированный чем шаблон 1
Посмотрим, как в соответствии с описанными выще правилами разрешается перегрузка для следующих вызовов:
Foo(42); // #1 — нешаблонная функция
Foo(3.14); // #2 — полная специализация шаблона 1 для double
Foo(42L); // #3 — конкретизация шаблона 1 для long
Foo("meow"); // #4 — конкретизация шаблона 2 для char
Foo(U<int>()); // #5 — конкретизация шаблона 3 для int
В первом вызове есть точно подходящая нешаблонная функция и одна точно подходящая конкретизация: шаблон 1 для int
. Выбирается нешаблонная функция.
Во втором вызове нешаблонная функция подходит не точно, требуется преобразование от double
к int
. Есть одна точно подходящая конкретизация: шаблон 1 для double
и есть полная специализация шаблона 1 для double
, которая и выбирается.
В третьем вызове нешаблонная функция подходит не точно, требуется преобразование от long
к int
. Есть одна точно подходящая конкретизация: шаблон 1 для long
, она и выбирается.
В четвертом вызове нешаблонная функция совсем не подходит, есть две точно подходящих конкретизации: шаблон 1 для const char*
и шаблон 2 для char
. Выбирается шаблон 2 как более специализированный и, соответственно, полная специализация шаблона 1 для const char*
не рассматривается.
В пятом вызове нешаблонная функция совсем не подходит, есть две точно подходящих конкретизации: шаблон 1 для U<int>
и шаблон 3 для int
. Выбирается шаблон 3 как более специализированный.
2.5.4. Управление перегрузкой шаблонов
Рассмотрим перегруженные функции и шаблоны:
void Foo(int x); // нешаблонная функция
template<typename T> // шаблон
void Foo(T x);
Нешаблонная функция будет выбрана только для аргументов типа int
, int&
, const int
, const int&
, для остальных целочисленных аргументов (long
, short
, unsigned int
, etc.) будет выбрана шаблонная версия. В такой ситуации говорят, что шаблонная версия является жадной (greedy). Это не всегда является нужным поведением, например, часто желательно, чтобы первая функция выбиралась для всех целочисленных аргументов. Решить эту задачу можно несколькими способами. Можно добавить перегруженные функции для всех целочисленных типов, но это весьма утомительно. Другой вариант — использовать технику отключения шаблонов (template disabling). Для этого шаблон надо переписать в следующем виде:
template<
typename T,
typename S = std::enable_if_t<!std::is_integral<T>::value>>
void Foo(T x);
Теперь для целочисленных аргументов этот шаблон нельзя конкретизировать и в соответствии с принципом SFINAE он будет исключен при разрешении перегрузки и, таким образом, будет выбрана нешаблонная функция и выполнены необходимые неявные преобразования аргументов.
Ну и, наконец, варианты с использованием условных инструкций и операторов, вообще без использования перегрузки:
template<typename T> // целочисленные аргументы
void FooInt(T x);
template<typename T> // остальные аргументы
void FooEx(T x);
// C++17
template<typename T>
void Foo(T x)
{
if constexpr (std::is_integral<T>::value)
{
FooInt(x);
}
else
{
FooEx(x);
}
}
// C++11
template<typename T>
void Foo(T x)
{
std::is_integral<T>::value
? FooInt(x)
: FooEx(x);
}
Описанные варианты требуют включения заголовочного файла <type_traits>
.
2.6. Правила разрешения перегрузки для параметров «родственного» типа
В данном разделе мы рассмотрим правила перегрузки в случаях когда параметры функций имеют «родственные» типы: сам тип, ссылка, ссылка на константу, rvalue ссылка.
Для описания этих правил необходимо использовать так называемые категории аргументов. Для нашего уровня детализации достаточно использовать четыре категории:
- lvalue — именованные неконстантные переменные;
- константные (неизменяемые) lvalue — именованные константные переменные;
- rvalue — анонимные временные неконстантные переменные или lvalue, к которым применено преобразование
std::move();
- константные (неизменяемые) rvalue — анонимные временные константные переменные.
Обе константные категории часто можно рассматривать как единую категорию — константы.
Рассмотрим теперь, допустимые категории аргументов для рассматриваемых типов параметров.
Пусть параметр имеет тип ссылки:
void Foo(T& x);
В этом случае допустимой категорией аргументов будет только lvalue.
Пусть параметр имеет тип rvalue-ссылки:
void Foo(T&& x);
В этом случае допустимой категорией аргументов будет только rvalue.
Пусть параметр имеет тип ссылки на константу или сам тип:
void Foo(const T& x);
void Foo(T x);
В этих случаях допустимы любые категории аргументов.
2.6.1. Передача параметров по ссылке, ссылке на константу и по значению
Пусть функции перегружены следующим образом:
void Foo(T& x);
void Foo(const T& x);
В этом случае для lvalue будет выбрана первая функция (хотя вторая также допустима), для остальных категорий вторая.
Пусть теперь функции перегружены следующим образом:
void Foo(T& x);
void Foo(T x);
Здесь для констант и rvalue будет выбрана вторая функция, а вот для lvalue выбор будет неоднозначный.
Пусть функции перегружены следующим образом:
void Foo(const T& x);
void Foo(T x);
Для любых аргументов выбор будет неоднозначный.
Для нестатических функций-членов квалификатор const
позволяет перегружать функции в зависимости от константности скрытого параметра this
.
class X
{
public:
X();
void Foo(); // this указывает на X*
void Foo() const; // this указывает на const X*
void DoSomething() const;
void DoSomethingElse();
// ...
};
void X::DoSomething() const
{
// ...
Foo(); // Foo() const
// ...
}
void X::DoSomethingElse()
{
// ...
Foo(); // Foo()
// ...
}
// ...
X x;
x.Foo(); // Foo()
const X cx;
cx.Foo(); // Foo() const
Hеконстантные функции-члены можно вызывать для rvalue объекта, то есть тем самым можно модифицировать rvalue. Но передавать в функцию rvalue аргумент через ссылку на неконстанту нельзя.
class X
{
public:
X();
void Swap(X& other) noexcept;
// ...
};
// ...
X x;
// ...
X().Swap(x); // OK
x.Swap(X()); // ошибка, хотя и делает то же самое
Возможность модифицировать rvalue объект может показаться несколько странной и даже бессмысленной. Но это не совсем так, иногда ее можно с пользой использовать. В данном примере демонстрируется известная идиома полной очистки объекта с помощью rvalue объекта и функции обмена состояниями. (Ну и не надо забывать, что вся семантика перемещения базируется на модификации rvalue объекта.) Но вообще модификация rvalue объекта может создать всякого рода проблемы. Для того, чтобы предотвратить это, у функций, которые возвращают объект по значению, тип возвращаемого значения объявляют константным. Подробнее об этом можно почитать у Герба Саттера [Sutter1].
2.6.2. Rvalue ссылки
Одно из самых значительных нововведений C++11 является семантика перемещения. Для ее реализации был введен специальный тип — rvalue-ссылка. Rvalue-ссылки это разновидность обычных C++ ссылок, отличие состоит в правилах инициализации и правилах разрешения перегрузок функций, имеющих параметры типа rvalue-ссылка. Программист должен четко знать описанные ниже правила, иначе результат перегрузки может оказаться неожиданным для программиста, компилятор «молча» заменит перемещение на копирование и все преимущества перемещения будут утеряны.
Пусть функции перегружены следующим образом:
void Foo(T&& x);
void Foo(const T& x);
В этом случае первая функция будет выбрана для rvalue аргументов (хотя вторая также допустима), а вторая для остальных категорий.
Пусть функции перегружены следующим образом:
void Foo(T&& x);
void Foo(T x);
В этом случае вторая функция будет выбрана для lvalue и константных аргументов, а вот для rvalue аргументов выбор будет неоднозначным, то есть первая функция не будет выбрана.
Пусть функции перегружены следующим образом:
void Foo(T&& x);
void Foo(T& x);
В этом случае первая функция будет выбрана для rvalue аргументов, вторая для lvalue аргументов, а для константных аргументов разрешение перегрузки завершится неудачей.
Отметим, что четвертая категория — константные rvalue, — может стать актуальной при использовании функций, которые возвращают объект по значению, который объявлен константным. (Причины обсуждаются в предыдущем разделе.) В случае, если этот тип перемещаемый, то его нельзя объявлять константным, так как это ломает всю семантику перемещения.
Еще одно нововведение С++11, связанное с rvalue-ссылками — это ссылочные квалификаторы для нестатических функций-членов. Они позволяют перегружать по категории (lvalue/rvalue) скрытого параметра this
.
class X
{
public:
X();
void Foo() &; // this указывает на lvalue
void Foo() &&; // this указывает на rvalue
// ...
};
// ...
X x;
x.Foo (); // Foo() &
X().Foo(); // Foo() &&
Следует обратить внимание на один важный момент: именованная rvalue-ссылка сама по себе является lvalue. Это надо учитывать при определении функций, имеющих параметры типа rvalue-ссылка, такие параметры являются lvalue и, если они используются в качестве аргументов какого-то внутреннего вызова, то скорее всего потребуют использования преобразования std::move()
, иначе при разрешении перегрузки версия с параметром типа rvalue-ссылкой не будет выбрана.
2.6.3. Универсальные ссылки
В шаблоне
template<typename T>
void Foo(T&& x);
тип параметра функции является не rvalue-ссылка, а так называемая универсальная ссылка (universal reference). Для таких шаблонов допустимы аргументы любой категории. Для lvalue аргументов тип x
выводится как T&
, для констант как const T&
, для rvalue аргументов как T&&
. Сам по себе параметр x
является lvalue, поэтому если он используются в качестве аргумента какого-то внутреннего вызова к нему обычно применяется преобразование std::forward<T>()
, которое в случае, когда тип параметра T&&
, превращает этот параметр в rvalue. Шаблоны с универсальными ссылками являются жадными (greedy), то есть при разрешении перегрузки у них высокий приоритет. Как бороться с жадными шаблонами, рассмотрено в разделе 2.5. Подробнее про универсальные ссылки и тесно связанную с ними прямую передачу (perfect forwarding) см. [Meyers2].
3. Другие темы, связанные с перегрузкой
3.1. Параметры неполного типа
В C++ в ряде случаев компилятору достаточно знать, что то или иное имя является именем какого-то пользовательского типа (класса, структуры, объединения, перечисления), а полное объявление типа не нужно. В этом случае можно использовать неполное объявление (incomplete declaration), называемое еще упреждающим (forward declaration). Типы с неполным объявлением называются неполными. Механизм перегрузки работает и для неполных типов.
class X; // неполное объявление X
class Y; // неполное объявление Y
void Foo(X* x);
void Foo(Y* y);
// ...
X* px;
// ...
Foo(px); // void Foo(X* x);
В данном случае полное объявление класса X
может быть недоступно, но разрешение перегрузки работает.
3.2. Инициализация указателя на функцию
При инициализации указателя на функцию также можно использовать перегруженные функции.
void Foo(int x);
void Foo(const char* x);
// ...
void (*pF)(int) = Foo; // Foo(int)
Разрешение перегрузки работает даже проще, для успешной инициализации нужно точное совпадение параметров и возвращаемого значения. Правила относительно областей видимости (сокрытие, расширение) такие же, но вот ADL не работает.
namespace N
{
class X {/* ... */};
void Foo(const X& x);
}
// ...
void (*pF)(const N::X&) = Foo; // ошибка
void (*pF)(const N::X&) = N::Foo; // OK
using N::Foo;
void (*pF)(const N::X&) = Foo; // OK
Перегруженные функции также можно использовать при инициализации экземпляра конкретизации функционального шаблона std::function<>
.
#include <functional>
void Foo(int x);
void Foo(const char* x);
// ...
std::function<void(int)> f = Foo; // Foo(int)
ADL также не работает, а вот точного совпадения типа аргумента шаблона и типа функции, используемой для инициализации, уже не нужно, но это тема отдельного обсуждения.
Перегруженные функции-члены также можно использовать при инициализации указателей на функции-члены класса.
class X
{
// ...
void Foo(int a);
void Foo(const char* a);
};
// ...
void (X::*pF)(int) = &X::Foo; // X::Foo(int)
3.3. Перегрузка и параметры по умолчанию
С точки зрения программиста использование перегруженных функций и функций с параметрами по умолчанию может быть очень похожим.
Перегруженные функции
void Foo(int x);
void Foo() { Foo(0); }
можно заменить на единственную функцию с параметром по умолчанию
void Foo(int x = 0);
и в коде, использующем Foo()
, это будет почти не заметно. Но с точки зрения компилятора разница будет большая и программист это увидит, если попробует взять адреса функций.
void (*pF1)(int) = Foo; // OK
void (*pF0)() = Foo; // ошибка, Foo без параметров не найдена
В случае перегруженных функций этот код был бы корректным.
Перегрузку и параметры по умолчанию можно смешивать в определенных пределах, компиляторы справляются с этой проблемой, но вряд ли это можно отнести к хорошему стилю кодирования.
3.4. Перегрузка виртуальных функций
К перегрузке виртуальных функций надо относиться с осторожностью. Дело в том, что разрешение перегрузки выполняется на этапе компиляции, и, соответственно, используется статический тип переменной, для которой вызывается виртуальная функция. Это не очень хорошо согласуется с динамической природой виртуальных функций и может привести к неприятным неожиданностям («потерей» наследуемых функций базового класса, см. раздел 1.3). Подробнее ситуация описана в [Dewhurst]. Но относиться с осторожностью — это не значит не использовать совсем. Если мы проектируем полиморфную иерархию классов, в корне которой находится интерфейсный класс (абстрактный класс, у которого почти все функции-члены чисто виртуальные), все перегрузки сделаны в этом классе и доступ к производным классам осуществляется только через этот интерфейсный класс, то никаких неприятностей не будет. Подобная модель построения полиморфной иерархии классов используется весьма широко. В Приложении А мы покажем, как перегрузка виртуальных функций используется при реализации известного паттерна проектирования Visitor.
3.5. Метапрограммирование
Разрешение перегрузки происходит на этапе компиляции, поэтому не удивительно, что этот механизм активно используется в метапрограммировании — программировании кода, который выполняется на этапе компиляции. Метапрограммирование активно используется при написании шаблонов, в том числе и шаблонов стандартной библиотеки. Без его использования практически невозможно написать универсальные, гибкие и эффективные шаблоны.
Как известно, итераторы делятся на пять категорий. Идентификатором категории служит пустой тип-метка, например std::input_iterator_tag
. Этот тип доступен через внутренний тип (typedef
) итератора — iterator_category
. Если в шаблоне надо обеспечить разное поведение в зависимости от категории итератора, то надо написать перегруженные функции для каждого из этих типов-меток, поставить вызов в соответствующее место и передать в качестве аргумента экземпляр типа iterator_category
, после чего компилятор выберет функцию, соответствующую категории итератора. Вот пример:
void DisplayIterCat(std::input_iterator_tag tag)
{
std::cout << "Input iteratorn";
}
// перегрузка DisplayIterCat для остальных типов-меток
template<class Iter>
void DisplayIterCat(Iter it)
{
DisplayIterCat(typename Iter::iterator_category());
}
Обратим внимание на использование ключевого слова typename
. Оно необходимо для того, чтобы iterator_category
трактовался как внутренний тип для Iter
.
В C++ с помощью шаблонов очень легко превратить целочисленную константу, известную на этапе компиляции, в тип. В стандартной библиотеке для этого есть специальный шаблон:
template<typename T, T val>
struct integral_constant;
При конкретизации этого шаблона для каждого целочисленного типа T
и значения типа T
, известного на стадии компиляции, получается уникальный тип. Чаще всего используются:
typedef integral_constant<bool, true> true_type;
typedef integral_constant<bool, false> false_type;
Ну а там, где появляются разные типы, можно использовать перегрузку.
Вот еще один прием, используемый в метапрограммировании. Рассмотрим выражение:
sizeof(Foo(expr))
Это выражение вычисляется во время компиляции. При этом expr
не вычисляется, определяется только его тип. После этого выполняется разрешение перегрузки, но сама Foo
не вызывается, определяется только возвращаемый тип и поэтому определение Foo
не нужно. Таким образом перегрузка используется для отображения типа на числовое значение.
В C++17 появилась инструкция if constexpr ()
, которая ощутимо упрощает метапрограммирование, делает его более привычным и удобным. Но огромное количество кода (в том числе в стандартной библиотеке) не использует эту инструкцию и вряд ли его станут переписывать.
4. Итоги
Перегрузка — это мощный инструмент, но пользоваться им надо продуманно и аккуратно. В перегрузке немало подводных камней, надо трезво оценить свои силы и не искать лишний раз приключений на свою голову.
Не стоит использовать перегрузку только потому, что компилятор это позволяет. Увлечение перегрузкой может снизить читаемость кода, сделать его безликим. Во многих случаях лучше дать название, отражающее специфику операции.
Старайтесь избегать использования перегруженных функций и шаблонов, требующих сложных и не до конца понятных алгоритмов разрешения перегрузки.
Не надо объявлять одноименные функции во вложенных областях видимости — это не перегрузка.
Приложения
Приложение А. Двойная диспетчеризация и паттерн Visitor
Пусть у нас две полиморфные иерархии классов в одной из которых базовый класс A
, а в другой V
и функция
void DoDblDispatchOper(A* a, V* v);
Переменные a
и v
могут фактически указывать на объект любого производного класса из соответствующей иерархии и необходимо, чтобы функция динамически обеспечила операцию, зависящую от фактического типа как a
, так и v
, то есть осуществила выбор из некоторой матрицы операций. Это и называется двойной диспетчеризацией. Виртуальные функции обеспечивают одинарную диспетчеризацию, перегрузка является статической операцией, поэтому реализация двойной диспетчеризации требует дополнительных усилий. Решение этой задачи предложено в паттерне Visitor — одном из паттернов Банды Четырех [GoF].
Пусть у нас есть две полиморфных иерархии классов, первая базируется на интерфейсном классе IAcceptor
и вторая на интерфейсном классе IVisitor
.
class IVisitor;
class IAcceptor
{
public:
virtual void Accept(IVisitor* visitor) = 0;
// ...
};
class A1;
class A2;
// ...
class IVisitor
{
public:
virtual void Visit(A1* a) = 0;
virtual void Visit(A2* a) = 0;
// ...
};
class A1 : public IAcceptor
{
void Accept(IVisitor* visitor) override
{
visitor->Visit(this);
}
// ...
};
class A2 : public IAcceptor
{
void Accept(IVisitor* visitor) override
{
visitor->Visit(this);
}
// ...
};
// ...
class V1 : public IVisitor {/* ... */};
class V2 : public IVisitor {/* ... */};
// ...
Интерфейсный класс IVisitor
перегружает виртуальную функцию Visit()
для каждого класса иерархии, базирующегося на IAcceptor
. Переопределенные в производных классах виртуальные функции Visit()
и образуют матрицу операций, из которой надо выбрать элемент.
Виртуальная функция
void IAcceptor::Accept(IVisitor* visitor);
во всех производных классах переопределяется одинаково, тело состоит из одной инструкции:
visitor->Visit(this);
Так как в каждом классе this
имеет разный тип, то осуществляется разрешение перегрузки и выбирается соответствующая версия Visit()
. Ну а дальше работает стандартный механизм выбора виртуальной функции и выбирается Visit()
, переопределенная для фактического типа visitor
.
Ну и наконец
void DoDblDispatchOper(IAcceptor* acceptor, IVisitor* visitor)
{
acceptor->Accept(visitor);
}
Voila. Двойная диспетчеризация готова.
Приложение Б. Подмена стандартных функций пользовательскими версиями
Иногда возникает необходимость замены функций из стандартной библиотеки какими-то пользовательскими вариантами. Наиболее известный пример — это функция (точнее шаблон функции) обмена состояниями двух объектов.
template<typename T>
void swap(T& a, T& b);
Эта функция используется достаточно широко, например, ее применяют многие алгоритмы стандартной библиотеки. Но стандартная реализация может оказаться для некоторых типов весьма неэффективной и тогда естественно возникает вопрос о ее замене на более оптимальный пользовательский вариант. Это становится возможным, когда std::swap()
используется в шаблонах, которые компилируются каждый раз при конкретизации, и ключевую роль в данном случае будет играть механизм перегрузки. Для того, чтобы шаблоны при компиляции использовали не std::swap()
, а другую функцию, специально определенную для класса, необходимо выполнить несколько шагов. Рассмотрим сначала случай обычного класса, не шаблона.
1. Определить в классе функцию-член Swap()
(имя не принципиально), реализующую обмен состояниями.
class X
{
public:
void Swap(X& other) noexcept;
// ...
};
Необходимо гарантировать, чтобы эта функция не выбрасывала исключения, в C++11 такие функции надо объявлять как noexcept
.
2. В том же пространстве имен, что и класс X
(обычно в том же заголовочном файле, а иногда и в теле класса), определить свободную (не-член) функцию swap()
следующим образом (имя и сигнатура принципиальны):
inline void swap(X& a, X& b) noexcept { a.Swap(b); }
После этого, благодаря ADL, эта функция сможет участвовать в разрешении перегрузки вместе с std::swap()
и в этом случае будет выбрана как имеющая лучшее соответствие.
3. Определить полную специализацию std::swap()
для X
namespace std
{
template<>
void swap<X>(X& a, X& b) noexcept { a.Swap(b); }
};
Стандартом запрещено добавлять в пространство имен std
функции, шаблоны или что-нибудь еще. Исключение составляют полные специализации шаблонов из std
.
Спрашивается, а зачем вообще нужен третий шаг? Ответ такой — он подстраховывает от некоторых ошибок. Рассмотрим случай, когда в пользовательском пространстве имен реализуется некоторый шаблон, который использует функцию обмена состояниями.
namespace N
{
template<typename T>
// ...
T x, y;
// ...
swap(x, y);
// ...
Естественно рассчитывать, что если T
определяет swap()
, то будет использоваться эта функция обмена состояниями, иначе стандартная. Но в приведенном варианте при разрешении перегрузки std::swap()
не будет рассматриваться, она будет скрыта, поэтому это совсем ошибочный вариант.
Пусть теперь обмен состояниями делается так:
std::swap(x, y);
В этом варианте пользовательская swap()
не будет рассматриваться и вот тут и придет на помощь полная специализация std::swap()
— будет выбрана она. Но это тоже не вполне правильный вариант.
А совсем правильный вариант такой:
using std::swap;
swap(x, y);
Этот вариант гарантирует вызов пользовательской функции обмена состояниями, если она определена. В противном случае будет вызвана полная специализация std::swap()
, если она определена. В остальных случаях будет вызвана конкретизация общего шаблона std::swap()
. В данном варианте полная специализация std::swap()
уже не нужна.
Но полная специализация поможет, если в стандартной библиотеке (то есть в пространстве имен std
) по ошибке используется
std::swap(x, y);
вместо правильного
swap(x, y);
Скотт Мейерс [Meyers1] утверждает, что в стандартной библиотеке такую ошибку полностью исключить нельзя.
Рассмотрим теперь случай, когда функцию обмена состояниями надо определить для шаблона класса.
template<typename T>
class X
{
// ...
public:
void Swap(X& other) noexcept;
};
template<typename T>
void swap(X<T>& a, X<T>& b) noexcept { a.Swap(b); }
А вот специализацию std::swap()
мы уже сделать не можем, для этого надо было бы добавить в пространство имен std
шаблон функции, а это стандартом запрещено, так, что подстраховки уже не будет, ошибочный код может работать неправильно.
С помощью ключевого слова friend
определение swap()
можно перенести внутрь шаблона:
template<typename T>
class X
{
// ...
void Swap(X& other) noexcept;
friend void swap(X& a, X& b) noexcept { a.Swap(b); }
};
Определение становится более лаконичным, а функция-член Swap()
при этом может быть закрытой или защищенной.
Список литературы
[GoF]
Гамма Э., Хелм Р., Джонсон Р., Влиссидес Дж. Приемы объектно-ориентированного проектирования. Паттерны проектирования.: Пер. с англ. — СПб.: Питер, 2001.
[Dewhurst]
Дьюхэрст, Стефан К. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2012.
[Meyers1]
Мейерс, Скотт. Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ.: Пер. с англ. — М.: ДМК Пресс, 2014.
[Meyers2]
Мейерс, Скотт. Эффективный и современный C++: 42 рекомендации по использованию C++11 и C ++14.: Пер. с англ. — М.: ООО «И.Д. Вильямс», 2016.
[Sutter1]
Саттер, Герб. Решение сложных задач на C++.: Пер. с англ. — М: ООО «И.Д. Вильямс», 2015.
[Sutter2]
Саттер, Герб. Новые сложные задачи на C++.: Пер. с англ. — М: ООО «И.Д. Вильямс», 2015.
Автор: dm_frox