Программа курса← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Перегрузка и разрешение вызова

Перегрузка знакома по модулю о функциях: несколько функций с одним именем и разными параметрами. В классах она встречается почти в каждом типе, и правила выбора стоит знать точно – в чужом коде неочевидный выбор перегрузки объясняет добрую половину «почему оно так себя ведёт».

Что входит в сигнатуру

Для перегрузки различают:

  • число параметров;
  • их типы;
  • константность (const) самого метода;
  • ссылочность (&/&& у метода, C++11).

И не различают:

  • возвращаемый тип;
  • имена параметров;
  • значения по умолчанию;
  • const у параметра, переданного по значению.
int  GetValue();
double GetValue();          // ошибка: сигнатуры не различаются

void Foo(int x);
void Foo(const int x);      // ошибка: то же самое, const у копии ни на что не влияет

void Bar(int& x);
void Bar(const int& x);     // а вот это разные функции

Как выбирается перегрузка

Процесс из трёх шагов. Компилятор:

  1. Ищет имя. Просматривает области видимости снизу вверх и останавливается на первой, где имя нашлось.
  2. Собирает кандидатов. Все найденные перегрузки, подходящие по числу аргументов.
  3. Ранжирует. Для каждого аргумента оценивает, насколько «дорого» преобразование.

Порядок предпочтений от лучшего к худшему:

Ранг Что это Пример
1 Точное совпадение int → int
2 Продвижение типа char → int, float → double
3 Стандартное преобразование int → double, Derived* → Base*
4 Пользовательское преобразование конструктор или operator T()
5 Многоточие ... последний вариант

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

void Print(int value);
void Print(double value);

Print(42);      // точное совпадение с int
Print(3.14);    // точное совпадение с double
Print('a');     // продвижение char → int, побеждает Print(int)
Print(5L);      // ошибка: long одинаково далёк от обоих

Шаг 1 важнее, чем кажется: поиск останавливается на первой области, где имя встретилось. Метод наследника скрывает все одноимённые методы базы, а функция в области видимости класса – одноимённые свободные функции. «Почему не выбралась очевидно подходящая перегрузка» чаще всего означает, что до неё просто не дошли: её не было среди кандидатов.

Перегрузка по константности

Самый частый случай в классах: два варианта одного метода, различающиеся только const.

class String {
public:
    char& At(size_t idx)       { return str_[idx]; }
    char  At(size_t idx) const { return str_[idx]; }

private:
    char* str_;
};

String s("Hello");
char c1 = s.At(0);          // неконстантная версия: вернула ссылку, её прочитали

const String cs("Hello");
char c2 = cs.At(0);         // константная версия
// cs.At(0) = 'x';          // ошибка: возвращена копия, присваивать некуда

Выбор делает компилятор по константности объекта. Так устроены operator[], at(), front(), back(), begin() у всех стандартных контейнеров.

Чтобы не дублировать тело, константную версию иногда пишут первой, а неконстантную выражают через неё: return const_cast<char&>(static_cast<const String&>(*this).At(idx));. Для двух строк это лекарство хуже болезни, но в методе на тридцать строк оно окупается. В C++23 для этого появилось «дедуцирующее this», позволяющее написать метод один раз.

Перегрузка и наследование

Как обсуждалось в предыдущем уроке, метод наследника скрывает все одноимённые методы базы:

class Base {
public:
    void Handle(int value);
    void Handle(const std::string& text);
};

class Derived : public Base {
public:
    void Handle(double value);
};

Derived d;
d.Handle("text");     // ошибка: Base::Handle(const string&) скрыт
d.Handle(42);         // вызовется Handle(double): int преобразуется в double

Второй вызов особенно неприятен – он компилируется и делает не то. Лечится using:

class Derived : public Base {
public:
    using Base::Handle;
    void Handle(double value);
};

Перегрузка и преобразования указателей

Приведение Derived* к Base* – стандартное преобразование, и оно участвует в ранжировании:

void Process(Base* b);
void Process(Derived* d);

Derived* d = new Derived;
Process(d);        // Process(Derived*): точное совпадение лучше преобразования

Если бы перегрузки для Derived* не было, вызвалась бы версия с Base*. Но выбор происходит на этапе компиляции по статическому типу выражения:

Base* b = new Derived;
Process(b);        // Process(Base*) – хотя объект на самом деле Derived

Перегрузка ничего не знает о том, что лежит по указателю во время работы. Выбор по фактическому типу объекта – совсем другой механизм, и он называется виртуальными методами. О нём – следующий урок.

Отсюда полезное разграничение. Перегрузка – это статический полиморфизм: решение принимается компилятором, стоит ноль во время выполнения, но опирается только на объявленные типы. Виртуальные методы – динамический полиморфизм: решение принимается во время работы по фактическому типу объекта, ценой одного косвенного вызова.