Статья
Версия для печати
Обсудить на форуме
Что такое перегрузка функций?


Часть 2. "Разборки" на уровне компилятора. Как грузить правильно?



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

Компилятор находит соответствие автоматически, сравнивая фактические параметры, используемые в запросе, с параметрами, предлагаемыми каждой функцией из набора перегруженных функций. Тут возможны три результата:
  • компилятор находит единственную функцию, которая является полным соответствием для фактических параметров и генерирует код, чтобы вызвать эту функцию.
  • компилятор не находит вообще никакой функции с параметрами, которые соответствуют параметрам в запросе, тогда компилятор указывает на ошибку.
  • компилятор находит несколько функций, которые соответствуют заданным параметрам, но ни одна из них не является "явно лучшей". В этом случае компилятор также указывает на ошибку - запрос неоднозначен.
Рассмотрим набор функций и один вызов функции:

Код: (C++)
void calc();
void calc(int);
void calc(int, int);
void calc(double, double=1.2345);

calc(5.4321);  //вызвана будет функция void calc(double, double)

Как в этом случае компилятор ведет поиск соответствия функции?

Для начала определяются функции-кандидаты на проверку соответствия. Функция-кандидат должна иметь то же имя, что и функция, которую вызывают и ее объявление должно быть видимым в точке запроса. В этом примере есть четыре функции-кандидата.
Затем определяются "жизнеспособные" функции. Чтобы быть "жизнеспособной", функция должна пройти два теста. Для начала, функция должна иметь то же самое количество параметров что и в запросе. Далее, тип каждого параметра должен соответствовать параметру в запросе (или быть конвертируемым типом).
Для нашего запроса calc(5.4321), мы можем сразу выкинуть две функции-кандидата, как абсолютно "нежизнеспособные". Это функции calc() и calc(int, int). Запрос имеет только один параметр, а эти функции имеют нуль и два параметра, соответственно. calc(int) - вполне "жизнеспособная" функция, потому что можно конвертировать тип параметра double к типу int. calc(double, double) тоже "жизнеспособная" функция, потому что заданный по умолчанию параметр обеспечивает "якобы недостающий" второй параметр функции, а первый параметр имеет тип double, который точно соответствует типу параметра в вызове.
Потом компилятор определяет, какая из найденных "жизнеспособных" функций имеет "явно лучшее" соответствие фактическим параметрам в запросе. Что понимать под "явно лучшим"? Идея состоит в том, что чем ближе типы параметров друг к другу, тем лучше соответствие. То есть, точное соответствие типа лучше чем соответствие, которое требует преобразования типа.
В нашем запросе calc(5.4321) - только один явный параметр, и он имеет тип double. Чтобы вызвать функцию calc(int), параметр должен быть преобразован в int. Функция calc(double, double) является более точным соответствием для этого параметра. И именно эту функцию использует компилятор.

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

Код: (C++)
calc(123, 5.4321);

Ну, с функциями-кандидатами все ясно - набор у нас не изменился. Посмотрим на наличие "жизнеспособных" функций. Правила нам уже известны. Компилятор выбирает те функции, которые имеют требуемое количество параметров и для которых типы параметров соответствуют параметрам вызова. "Жизнеспособных" функций опять две :) Это calc(int, int) и calc(double, double).
Далее компилятор проверяет параметр за параметром. Соответствие считается найденным, если есть ОДНА И ТОЛЬКО ОДНА функция для которой:
  • соответствие для каждого параметра - не хуже чем соответствие, требуемое любой другой "жизнеспособной" функцией;
  • есть не менее одного параметра, для которого соответствие лучше, чем соответствие, обеспеченное любой другой "жизнеспособной" функцией.
Если после рассмотрения всех параметров не найдется полностью соответствующей функции, компилятор будет жаловаться, что запрос неоднозначен.
Смотрим на первый параметр в вызове - ага! - видим что функция calc(int, int) точно соответствует по типу первому параметру. Чтобы соответствовать второй функции calc(double, double), int параметр "123" в вызове должен быть преобразован в double. Соответствие через такое преобразование "менее хорошо" :) Делаем вывод - calc(int, int) имеет лучшее соответствие.
Однако, давайте теперь посмотрим на второй параметр. Тогда получится, что функция calc(double, double) имеет точное соответствие параметру "5.4321". Теперь для функции calc(int, int) потребуется преобразование из double в int. А мы уже знаем, что соответствие через такое преобразование "менее хорошо" :) Делаем вывод - calc(double, double) имеет лучшее соответствие.
Дежавю... Где-то, вот буквально только что, мы уже получали такой результат... :)
Вот поэтому этот запрос и неоднозначен - обе функции имеют соответствие запросу по одному из параметров. Компилятор сгенерирует ошибку.

Мы могли бы сами создать соответствие, используя явное приведение типов одного из параметров в вызове функции:

Код: (C++)
calc(static_cast<double>(123), 5.4321);  // будет вызвана функция calc(double, double)

или так:

Код: (C++)
calc(123, static_cast<int>(5.4321));     // будет вызвана функция calc(int, int)

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

Теперь давайте посмотрим такой пример:

Код: (C++)
void func(int);
void func(short int);

func('a');  // будет вызвана func(int)

Почему не func(short int)? Это можно назвать проблемой "близких типов" :) Хотя, на самом деле, это вовсе и не проблема :) Вспоминаем правила стандартных преобразований операндов в арифметических операциях (кто забыл - срочно идет читать). Согласно правилам, короткие целые типы преобразуются в типы не меньшей длины (если кто не помнит - там, в правилах, даже таблицы преобразований есть), т.е. в тип int. Таким образом, функция func(int) является лучшим соответствием, хотя на первый взгляд кажется, что char лучше преобразовать в short int.
Подобное преобразование (близких типов) является более предпочтительным, или приоритетным, чем другие стандартные преобразования. То есть, если предыдущий пример слегка изменить:

Код: (C++)
void func(int);
void func(double);

func('a');  // все равно будет вызвана func(int)

И это несмотря на то, что char можно преобразовать как в int, так и в double.

Другие стандартные преобразования считаются эквивалентными. Как в этом примере:

Код: (C++)
void func(long);
void func(float);

func(3.1415);  //ошибка - вызов неоднозначен

Поскольку есть два возможных стандартных преобразования (double в long, или double во float) с одинаковыми приоритетами, запрос неоднозначен.

И даже в таком примере тоже будет неоднозначность:

Код: (C++)
void func(unsigned char);
void func(double);

func('a');  //ошибка - вызов неоднозначен

Преобразование из char в unsigned char не имеет приоритета перед преобразованием char в double.

Так, не расслабляемся!.. :) Осталось еще немного.

Рассмотрим, как перегружаются функции, когда параметр - ссылка или указатель, да еще и с const.

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

Код: (C++)
int func(int &);
int func(const int &);

const int a=10;
int b;

func(a);   // вызывается func(const int &)
func(b);   // вызывается func(int &)

Если мы передаем константый объект, то единственная функция, которая является "жизнеспособной" - версия, которая использует ссылку на константу. Если же мы передаем неконстантый объект, любая из двух функций будет "жизнеспособной". Однако, для функции func(const int &) потребуется преобразование в const, тогда как инициализация параметра-неконстанты будет точным соответствием.

Параметры-указатели работают подобным же образом.

Стоит отметить только, что мы не сможем перегрузить функцию, основанную на том, является ли сам указатель константой:

Код: (C++)
func(int *);
func(int * const); // переобъявление

Здесь константа относится к указателю, а не типу, на который он указывает. В обоих случаях указатель просто копируется - и не важно, является ли сам указатель константой. Но, об этом мы уже говорили в первой части.

Напоследок несколько рекомендаций - когда следует пользоваться перегрузкой, а когда лучше предпочесть другие средства.

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

Перегрузка функций - это не изыск, это необходимость. Перегрузка просто незаменима при работе с классами. Без перегрузки функций были бы немыслимы такие понятия, как множественные конструкторы классов и оператор-функции. Но конструкторы и перегрузка операторов - это уже совсем другая история :) А ведь есть еще и перегрузка шаблонов...
Возможно об этом мы поговорим как-нибудь в другой раз...

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

Кстати - main() - это ведь тоже функция. Вот у этой функции может быть только один образец в любой программе. Основная функция не может быть перегружена.


Если у вас есть вопросы – пишите, будем разбираться.

Сергей Малышев (aka Михалыч).
Версия для печати
Обсудить на форуме