Глава 14
перегрузка функций
Почему следует использовать перегрузку
Перегрузка функций - это один из типов полиморфизма, обеспечиваемого C++. В C++ несколько функций могут иметь одно и то же имя. В этом случае функция, идентифицируемая этим именем, называется перегруженной. Перегрузить можно только функции, которые отличаются либо типом, либо числом своих аргументов. Перегрузить функции, которые отличаются только типом возвращаемого значения, нельзя. Возникает вопрос: зачем нужно использовать перегрузку? Ответ прост: перегружаемые функции дают возможность упростить программы, допуская обращение к одному имени для выполнения близких по смыслу действий.
Чтобы перегрузить некоторую функцию, нужно просто объявить, а затем определить все требуемые варианты ее вызова. Компилятор автоматически выберет правильный вариант вызова на основании числа и типа используемых аргументов. Пример:
#include <iostream.h>
//Перегрузка функции abs()
int abs(int n);
long abs(long 1);
double abs(double d);
main()
{
int n=255; long l=-25L; double d=-15.0L;
cout « "Абсолютная величина " « n «" равна: " « abs(n) « "\n";
cout « "Абсолютная величина " « l «" равна: " « abs(l) « "\n";
cout « "Абсолютная величина” « d «" равна: " « abs(d) « "\n";
return 0;
}
int abs(int n)
{
return n<0? -n:n;
}
long abs(long l)
{
return l<0? -l:l;
}
double abs(double d)
{
return d<0? -d:d;
}
Как известно, библиотека времени выполнения C++ использует три разных функции abs (), labs () и fabs () для вычисления абсолютного значения аргумента. Использование той или другой из них зависит от типа аргумента. Так, в данном примере осуществляется перегрузка функции abs (), что упрощает программу. В зависимости от переданного аргумента вызывается нужный вариант функции.
Этот пример наглядно демонстрирует, как использование перегрузки может упростить код, предоставляя программисту возможность пользоваться одной функцией, которой передаются аргументы различного типа. Разумеется, возможность использования перегрузки предъявляет повышенные требования к компилятору.
Чтобы реализовать концепцию перегрузки, разработчикам компиляторов C++ пришлось ввести декорирование имен. Последнее означает, что все функции в коде программы получают от компилятора имена, основываясь на имени, заданном программистом, и количестве и типах аргументов. Различные компиляторы делают это несколько отличным друг от друга образом. Здесь мы опишем, как это делает компилятор фирмы Inprise (ранее - фирма Borland): вначале идет имя класса (если речь идет о функции-члене класса), предваряемое символом "@", затем имя функции, снова предваряемое символом "@". У всех идентификаторов различается регистр букв. Затем следует последовательность символов "@q", начиная с которой идут кодированные обозначения параметров функции. Для обозначения указателей и ссылок к кодам встроенных типов добавляются буквы "р" и "r", соответственно. Например, если дано такое определение класса:
class AnyClass
{
public:
void SetVal() ;
void SetVal(int);
void SetVal(int, int);
void SetVal(int, int, int);
void SetVal(int&, int, int);
};
компилятор Borland C++ сгенерирует такие имена функций:
@AnyClass@SetVal@qv
@AnyClass@SetVal@qi
@AnyClass@SetVal@qii
@AnyClass@SetVal@qiii
@AnyClass@SetVal@qriii
Подробнее о декорировании имен, в частности о кодировании имен конструкторов и деструкторов, а также имен, указанных через операцию разрешения области видимости, можно прочесть в документации, поставляемой с компилятором.
Отсюда видно, что в декорировании имен возвращаемое функцией значение не участвует. Именно по этой причине нельзя перегружать функции, которые отличаются только типом возвращаемого значения. Разобраться с тем, как декорирует имена конкретный компилятор достаточно легко. Достаточно потребовать от него выдать ассемблерный эквивалент текста программы.
Замечание. Декорирование имен порождает и определенные проблемы. Если нужно написать код, который будет вызываться из программы, написанной на другом языке или даже на другом компиляторе C++, нужно отключить декорирование имен, иначе нужная функция не будет найдена. Это делается с помощью ключевого слова extern в форме extern "С" {} Буква "С" здесь напоминает о языке С, в котором декорирования имен не было.
Перегрузка функций
Перегрузка - это практика предоставления более чем одного определения для данного имени функции в одной и той же области видимости. Возможность выбрать соответствующую версию функции, основываясь на типах и числе аргументов, с которыми она вызывается, предоставляется компилятору. При этом два типа данных считаются различными, если для них используются различные инициализаторы. Поэтому аргумент данного типа и ссылка на этот тип рассматриваются как одно и то же с точки зрения перегрузки. Например, объявление двух таких функций
int func(int, int)
и
int func(int&, int&)
приведет к ошибке, так как с точки зрения перегрузки они считаются одинаковыми. По той же причине аргументы функции, относящиеся к некоторому типу, модифицированные const или volatile, не рассматриваются как отличные от базового типа с точки зрения перегрузки. Указатели на const- и volatile-объекты также не рассматриваются как отличные от указателей на базовый тип с точки зрения перегрузки. Однако механизм перегрузки может различать ссылки, которые имеют модификаторы const и volatile, и ссылки на базовый тип. Например, следующий код вполне допустим:
#include <iostream.h>
class AnyClass
{
public:
AnyClass()
{cout «"Конструктор по умолчанию "«"для AnyClass" « "\n";} AnyClass(AnyClassS ob)
{cout «"Конструктор копирования для " « "AnyClass" « "\n";}
AnyClass(const AnyClassS ob)
{cout «"Конструктор копирования для" « "AnyClass" « "\n";}
};
main()
{
AnyClass ob1;
AnyClass оb2(ob1);
const AnyClass оb3;
AnyClass оb4(оb3);
}
Вместе с тем на перегруженные функции накладываются несколько ограничений:
• любые две перегруженные функции должны иметь различные списки параметров;
• перегрузка функций с совпадающими списками аргументов на основе лишь типа возвращаемых ими значений недопустима;
• функции-члены не могут быть перегружены исключительно на основе того, что одна из них является статической, а другая - нет;
• typedef-определения не влияют на механизм перегрузки, так как они не вводят новых типов данных, а определяют лишь синонимы для существующих типов. Например, следующее определение
typedef char* PSTR;
не позволит компилятору рассматривать две приведенные ниже функции –
void SetVal(char* sz) ;
и
void SetVal(PSTR sz);
как различные. Поэтому их одновременное объявление в классе вызовет ошибку;
• все enum-типы данных рассматриваются как различные и могут использоваться для различения перегруженных функций;
• типы "массив (чего-то)" и "указатель (на что-то)" рассматриваются как идентичные с точки зрения перегрузки. Это верно только для одномерных массивов. Поэтому в случае перегрузки следующих функций будет выдана ошибка:
void SetVal(char sx);
void SetVal(char* psz);
Для многомерных массивов вторая и последующие размерности рассматриваются как часть типа данных. Поэтому они могут использоваться для различения перегруженных функций. Например, вполне допустимы следующие определения:
void SetVal(char szStr[]);
void SetVal(char szStr[][4]);
При объявлении перегруженных функций компилятор отслеживает объявления, данные в пределах одной и той же области видимости. Поэтому, если в производном классе объявлена функция с тем же именем, что и функция в базовом классе, функция из производного класса скрывает функцию базового класса вместо того, чтобы осуществлять перегрузку (так как их области видимости различны). Короче говоря, нельзя путать переопределение функций с перегрузкой. Точно также компилятор отслеживает и другие области видимости. Поэтому, так как функция, объявленная в области видимости файла, находится не в той же области видимости, что и функция, объявленная в блоке, даже если они имеют одинаковые имена, компилятор не рассматривает их как перегруженные. Локально объявленная функция просто скрывает глобально объявленную. Например:
#include <iostream.h>
void func(int i)
{
cout « "Вызов глобально объявленной "«"функции:"« i « endl;
}
void func(char* str)
{
cout «"Вызов локально объявленной " « "функции:" « str « endl;
}
main ()
{
extern void func(char*);
//Следующий вызов функции вызовет ошибку
func(100);
//А это - верно
func("переопределение!");
return 0;
}
Перегруженные функции-члены могут быть объявлены с различными спецификаторами доступа. Поскольку область видимости у них по-прежнему одна, компилятор рассматривает функции-члены класса с одинаковыми именами, даже если они объявлены в разделах класса с разными спецификаторами доступа, как перегруженные. Например:
class AnyClass
{
public:
AnyClass();
double MembFunc(double, char*);
private:
int MembFunc(int);
};
double
AnyClass:: MembFunc(double, char*)
{
//Тело функции
}
int
AnyClass:: MembFunc(int)
{
//Тело функции
}
main()
{
AnyClass* pCl = new AnyClass;
//Следующая инструкция вызовет ошибку, т.к. она содержит вызов private-функции
рСl-> MembFunc(104);
//А здесь - все верно, т.к. вызывается publiс-функция
рСl-> MembFunc(104.22, "строковый аргумент");
delete pCl;
return 0;
}
Концептуально можно перегружать и статические функции-члены класса, хотя это не имеет большого смысла. Ниже приведен пример перегруженной статической функции-члена.
class AnyClass
{
public:
static void SetVal(int);
static void SetVal(double);
private:
static int x;
static double y;
};
int AnyClass::x = 0;
double AnyClass::у = 0;
void AnyClass::SetVal(int _x){x = _x;}
void AnyClass::SetVal(double _y){y = _y;}
main ()
{
AnyClass::SetVal(10);
AnyClass::SetVal(10.4567);
return 0;
}
Перегрузка конструкторов
Чаще всего перегрузка применяется при создании перегруженных конструкторов (перегружать деструктор нельзя!). Целью этой перегрузки является желание предоставить пользователю как можно больше вариантов создания представителей класса. На самом деле мы уже неоднократно встречались с перегрузкой конструкторов, хотя и не говорили об этом. Если класс предоставляет конструктор с параметрами и конструктор по умолчанию, мы уже имеем дело с перегрузкой конструкторов. Как мы уже знаем, конструктор по умолчанию необходим при выделении динамической памяти массиву объектов (ибо динамический массив объектов не может быть инициализирован).
Другой случай, когда возникает необходимость в перегрузке конструкторов, - желание обеспечить пользователя конструкторами преобразования типа. Рассмотрим функцию, которой передается аргумент, являющийся ссылкой на объект класса AnyClass:
void func(AnyClass& rOb);
Любая попытка вызвать эту функцию с аргументом другого типа приведет к ошибке компиляции, потому что компилятор не знает, как осуществить нужное преобразование типа. Чтобы указать компилятору, как осуществить преобразование какого-то типа Т в тип данных AnyClass, нужно просто задать в соответствующем классе конструктор, принимающий единственный параметр типа т. При этом будет использовано уже отмечавшееся нами ранее свойство компилятора трактовать конструкцию
AnyClass ob(t);
как
AnyClass ob = AnyClass(t);
если в классе определен конструктор
AnyClass(T t) ;
В рассматриваемом случае это означает, что вызов функции с аргументом t типа Т, то есть
func(t);
трактуется как
func(AnyClass(t));
При этом осуществляется неявное преобразование типа данных. Неявное потому, что исходный код не вызывает явно конструктор для преобразования. С этой целью компилятор каждый раз будет создавать временный объект класса AnyClass, которому и будет передавать параметр, переданный в функцию.
Следующий пример это демонстрирует.
class AnyClass
{
int x;
public:
//Конструктор по умолчанию
AnyClass () ;
//Конструктор преобразования типа int
AnyClass(int);
//Конструктор преобразования типа long
AnyClass(long);
//Конструктор преобразования типа double
AnyClass(double);
};
AnyClass::AnyClass() {x = 0;}
AnyClass::AnyClass(int _x) {x = _x;}
AnyClass::AnyClass(long _x) {x = int(_x);}
AnyClass::AnyClass(double _x) {x = _x;}
void func(AnyClass& rOb)
{
//Тело функции
}
main()
{
func(10); //Преобразование int в AnyClass
func(10L); //Преобразование long в AnyClass
//Преобразование double в AnyClass
func(10.0);
AnyClass ob;
func(ob); //Преобразование не нужно
return 0;
}
Этот же способ преобразования типа данных может быть использован и для преобразования из одного класса в другой. Приведенный ниже пример демонстрирует конструктор преобразования из одного класса в другой.
class AnyClass
{
int x;
public:
AnyClass(){x =0;}
int GetX(){return x;}
};
class OtherClass
{
int z ;
public:
//Конструктор по умолчанию
OtherClass(){z =0;}
//Конструктор преобразования
OtherClass(AnyClass& cl) ;
};
//Определение конструктора преобразования
OtherClass::OtherClass(AnyClass& cl)
{
z = cl.GetX() ;
}
void func(OtherClass& Ob)
{
//Тело функции
}
main ()
{
AnyClass ob;
//Преобразование AnyClass в Othlass
func(ob);
return 0;
}
Несмотря на то, что данный пример работает, преобразование типа, которое он осуществляет, не имеет большого смысла. Ответственность за осуществляемое преобразование типа, как всегда, лежит на программисте.
Определенные пользователем преобразования типа применяются компилятором в следующих ситуациях:
• при инициализации объектов;
• при вызове функций;
• при возврате функцией значений.
Разумеется, их можно применять и для явного преобразования типа. Например:
main ()
{
AnyClass ob;
//Явное преобразование типа
OtherClass newOb = (OtherClass) ob;
}
Рассмотрим теперь пример, который демонстрирует пользу перегрузки другого конструктора - конструктора копирования. Пусть дан класс, который предназначен для использования в программе прямоугольных областей:
class Rect
{
public:
Rect() ;
Rect(int, int);
Rect(int, int, int, int);
Rect(const Rect&);
Rect(const Rect&, int =0, int =0);
private:
int x, y, w, h;
};
Rect:: Rect()
{
x=y = w = h = 0;
}
Rect:: Rect(int _x, int _y)
{
x = _x;
y = _y;
w = h = 100;
}
Rect:: Rect(int _x, int _y, int _w, int _h)
{
x = _x;
у = _y;
w = _w;
h = _h;
}
Rect:: Rect(const Rect& rc)
{
x = rc.x;
у = rc.у;
w = rc.w;
h = rc.h;
}
Rect:: Rect(const Rect& rc, int _x, int _y)
{
x = _x;
у = _у;
w = rc.w;
h = rc.h;
}
main()
{
Rect rc(5,10,10,200);
Rect newrc(rc, 15, 40);
return 0;
}
Данный класс содержит несколько конструкторов, которые должны предоставить пользователю класса достаточную гибкость в создании нужных ему прямоугольных областей. Среди прочих данный класс содержит перегруженный конструктор копирования
Rect(const Rect&, int =0, int =0);
Приведенный код перегруженного конструктора копирования копирует некоторые из данных-членов объекта, указанного ссылкой, но какие-то данные-члены нового объекта инициализирует по-другому. Данный конструктор можно квалифицировать как конструктор копирования благодаря тому, что первый его параметр представляет собой (константную) ссылку на объект класса, а остальные - это аргументы по умолчанию. Таким образом, вполне возможно создавать конструкторы копирования, содержащие дополнительные параметры. Это предоставляет чрезвычайную гибкость при создании разнообразных конструкторов копирования.
Создание и использование конструкторов копирования
Ранее мы уже встречались с этим понятием. Здесь мы подробно рассмотрим, как и зачем используются конструкторы копирования. Вначале рассмотрим вопрос: зачем понадобилось вводить такое понятие, как конструктор копирования.
В случае, если в функцию в качестве аргумента передается объект (то есть происходит передача аргумента по значению), делается точная (или побитовая) копия этого объекта, которая и передается параметру функции. Однако, если объект содержит указатель на выделенную область памяти, то в копии указатель будет указывать на ту же самую область памяти, на которую указывает исходный указатель. Поэтому, если в функции будет использован указатель из копии, - будет изменена область памяти, связанная с исходным объектом. Кроме того, когда функция завершается, - вызывается деструктор, который уничтожает копию объекта. Опять это приведет к изменению содержимого области памяти, связанной с исходным объектом.
Если объект является возвращаемым значением функции, то перед возвратом из функции компилятор генерирует временный объект для хранения, возвращаемого функцией значения. Когда происходит возврат из функции, этот временный объект выходит из области видимости. Поэтому компилятор вызывает для него деструктор. Однако если деструктор вызывает освобождение динамической области памяти, выделенной самому объекту или одному из содержащихся в нем объектов, это приведет к ошибке.
В основе этих проблем лежит использование сгенерированного компилятором конструктора копирования по умолчанию. В случае, если программист предоставляет конструктор копирования, именно он будет использоваться компилятором при инициализации одного объекта другим (и, в частности, при передаче объекта в функцию).
Замечание. Следует отличать конструктор копирования от оператора присваивания. Например,
AnyClass ob1;
AnyClass func(); //объявление функции
//оb2 инициализируется оb1 с помощью конструктора копирования
AnyClass ob2(ob1);
//Следующий оператор эквивалентен предыдущему
AnyClass оb2 = оb1;
//Возврат объекта из функции с помощью оператора копирования
оb1 = func();
//Напротив, следующая инструкция использует оператор присваивания оb2 = оb1;
Таким образом, несмотря на наличие в инструкции знака присваивания, она не всегда означает выполнение присваивания. Инструкция
AnyClass оb2 = оb1 ;
представляет собой просто другую форму записи конструктора копирования.
Устаревшее ключевое слово overload
В старых версиях C++ для создания перегруженных функций требовалось ключевое слово overload. Синтаксис его использования был следующим:
overload <имя_функции>;
Упомянутая в overload функция должна была объявляться до использования этого оператора. Таким образом, ключевое слово overload сообщало компилятору о намерении перегрузить упомянутую после него функцию. Хотя ключевое слово overload в современных компиляторах больше не поддерживается, его все еще можно встретить в старых программах, поэтому полезно знать как и для чего оно использовалось.
Перегрузка и неоднозначность. Ключевое слово explicit
Перегруженные функции выбираются по принципу наилучшего соответствия объявлений функций в текущей области видимости аргументам, предоставленным в вызове функции. Если подходящая функция найдена, эта функция и вызывается. Слово "подходящая" в этом контексте означает одно из следующего (в порядке ухудшения соответствия):
• найдено точное соответствие;
• выполнено тривиальное преобразование;
• выполнено преобразование целочисленных типов;
• существует стандартное преобразование к желаемому типу аргумента;
• существует определенное программистом преобразование (оператор преобразования или конструктор) к требуемому типу аргумента;
• были найдены аргументы, представленные многоточием.
Компилятор создает совокупность функций-кандидатов на соответствие для каждого аргумента. Затем для каждого аргумента строится совокупность функций, соответствующих наилучшим образом. Наконец, определяется функция, которая наилучшим образом соответствует вызову, как пересечение всех этих совокупностей. Если это пересечение содержит несколько функций, - перегрузка неоднозначна, и компилятор генерирует сообщение об ошибке.
Еще одно важное замечание. Неоднозначность перегруженных функций не может быть определена, пока не встретится вызов функции.
Функция с n аргументами по умолчанию, с точки зрения соответствия аргументов, рассматривается как совокупность из п+1 функций, каждая из которых отличается от предыдущей заданием одного дополнительного аргумента. Многоточие (...) действует как произвольный символ: оно соответствует любому заданному аргументу. Это может служить источником неоднозначности при выборе перегруженной функции.
Все сказанное выше относилось ко всем перегруженным функциям, безотносительно к тому, являются ли они функциями-членами или нет. Рассмотрим теперь специфику перегруженных Функций-членов.
Функции-члены класса рассматриваются различным образом в зависимости от того, объявлены ли они статическими или нет, потому что нестатические функции имеют неявный аргумент, через который передается указатель this. При определении функции, которая наилучшим образом соответствует вызову, для нестатических функций-членов рассматриваются только те перегруженные функции-члены, у которых скрытый указатель this соответствует типу объекта, который передан функции при вызове. В отличие от других аргументов при попытке установить соответствие аргумента с указателем this никакие преобразования не производятся.
В отношении перегруженных конструкторов язык C++ предоставляет дополнительную возможность по управлению процессом поиска соответствия вызываемого конструктора. Ключевое слово explicit представляет собой спецификатор объявления, который может применяться только в объявлениях конструкторов (но не в определениях конструкторов вне класса). Например:
class Т
{
public:
explicit Т (int);
explicit T(double)
{
//тело конструктора
}
};
Т::Т (int)
{
// …
}
Конструктор, объявленный со спецификатором explicit, не принимает участия в неявных преобразованиях типа. Он может быть использован только в том случае, когда никакого преобразования типа аргументов не требуется. Например, для класса, объявленного выше:
void f(T) {}; //Определение функции
void g(int i)
{
//Следующая инструкция вызовет ошибку, т.к. неявное преобразование типа int в тип Т запрещено.
f(i);
}
void h()
{
Т ob(10); // А это - верно.
}
Замечание. Нет смысла применять ключевое слово explicit к конструкторам с несколькими параметрами, так как такие конструкторы не принимают участия в неявных преобразованиях типа.
Определение адреса перегруженной функции
Рассмотрим теперь, как найти адрес перегруженной функции. Адрес функции можно найти, если поместить имя этой функции без аргументов и круглых скобок в правой части оператора присваивания. В левой части оператора присваивания должен находиться указатель на функцию соответствующего типа. Например,
//Первая версия функции
int Func(int i, int j);
int Func(long 1); //Вторая версия функции
…
//Определение адреса первой версии функции
int (*pFunc) (int, int) = Func;
//Вызов функции
pFunc(10,20);
Компилятор определяет, какую версию функции нужно выбрать путем отыскания функции, которая в точности соответствует заданному указателем типу. Если точное соответствие не найдено, выражение, которое получает адрес функции, неоднозначно. В этом случае генерируется ошибка.
Выше мы рассмотрели случай, когда перегружалась глобальная функция, однако те же правила применимы и к перегруженным функциям-членам.