12.4  Полиморфизм

В переводе с греческого polymorphos – многообразный. Если один и тот же объект может по-разному использоваться, в зависимости от обстоятельств, то он обладает полиморфизмом. В какой-то степени свойствами полиморфизма обладают, например, автомобили-амфибии – они используются для передвижения и по суше, и по воде.

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

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

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

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

Если функция с одним именем в программе объявлена более чем один раз, то возможны следующие случаи:

· если возвращаемые значения и сигнатуры (тип и число параметров) нескольких функций совпадают, то второе и последующие объявления трактуются компилятором как переобъявление первого;

· если сигнатуры нескольких функций совпадают, но возвращаемые значения различны, тогда второе и последующие объявления трактуются как ошибочные и во время компиляции выдается сообщение об ошибке;

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

Например, функцию, вычисляющую площадь прямоугольника

double sqr (double x, double y){return x*y;}

можно дополнить одноименной, но вычисляющей площадь квадрата, имеющую отличный тип у аргумента x и меньшее число параметров:

int sqr (int x){return x*x;}  // перегружаемая функция

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

double sqr (double x){return x*x;};

и

int sqr (double x){return x*x;} // ошибка

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

Тип_возврата operator @ (список_аргументов) { тело оператора};

где @ – знак перегружаемой операции. Использование знака перегруженной операции – это лишь сокращенная форма записи явного вызова функции-операции.

Язык позволяет перегружать следующие операции:

+

*

/

%

^

&

|

~

!

,

=

= =

!=

<

>

<=

>=

++

<<

>>

&&

||

+=

– =

*=

/=

%=

^=

&=

|=

<<=

>>=

[ ]

( )

->

->*

delete

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

.*

?:

::

sizeof()

Рассмотрим некоторые правила перегрузки операторов.

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

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

int operator +(int, int);

3. Нельзя определить дополнительную операцию для встроен­ных типов. Нельзя, например, определить операцию сложения массивов. Это можно сделать, определив класс, реализующий по­нятие массива, и операцию сложения в нем.

4. Нельзя переопределить приоритет операции. Например:

х = = у + z;

всегда сначала выполняется operator+, а затем operator= =; однако помощью скобок порядок можно изменить. Вне зависимости от типов данных приоритет операции побито­вого сложения выше, чем приоритет операции присваивания или сравнения.

Нельзя изменить синтаксис операции в выражении. Если некоторая операция определена в языке как унарная (на­пример, ~), то ее нельзя перегрузить как бинарную. Для встроенных типов четыре предопределенных оператора ("+", "-", "*" и "&") используются либо как унарные, либо как бинарные. В любом из этих качеств они могут быть перегружены. Для всех перегруженных операторов, за исключением operator (), недопустимы аргументы по умолчанию.  Если для опе­рации используется

1. префиксная форма записи, то ее нельзя переопределить в постфиксную. Например, если определить операцию отрицания:

void operator !();

то можно писать !а, но нельзя а!.

6. При перегрузке операций «++» и «» не сохраняется различие между префиксной и постфиксной формами записи.

7. Так как перегружать можно только операции, для которых, по крайней мере, один аргумент представляет тип данных, опреде­ленный пользователем, то функция-операция должна быть опреде­лена либо как член-функция этого типа, либо как внешняя функ­ция, но дружественная этому типу. Например:  

class String

{

public:

String operator +(String &);

};

или

class String

…                  

public:

friend String operator +(String &, String &);

}; 

8. При перегрузке унарной операции она не должна иметь аргументов, если является членом класса, и должна иметь один аргумент (ссылку на объект), если является внешней функ­цией. При перегрузке унарной операции как член-функции ей передается неявный аргумент – указатель this на текущий объект.

9. При перегрузке бинарной операции она должна иметь один аргумент (ссылку на объект), если является членом класса, и два аргумента (ссылки на объекты), если является внешней функ­цией. Бинарная операция, перегружаемая как член-функция, по­лучает один неявный аргумент (первый), а именно указатель this на текущий объект. Бинарные арифметические операции (+   –   *   / )  должны возвращать объект класса, для которого они используются. Если левый операнд перегружаемой бинарной операции пред­ставляет не пользовательский тип, а один из встроенных типов, то тогда такая операция не может быть перегружена как член-функ­ция. В этом случае операция должна быть определена как внешняя функция, у которой первый аргумент представляет один из встро­енных типов, а второй — пользовательский тип.

10. Следующие операции  =     [ ]   ()   ->   должны перегружаться только, как члены класса.

Приведем пример перегрузки оператора +. Предположим, имеется класс A, предназначенный для хранения и обработки денежных сумм.

Пример 3

// Defines the entry point for the console application.

class A

{

private:

long Dollars;

int Cents;

public:

A ()  //  Конструктор по умолчанию

{

Dollars = Cents = 0;

}

A (long Dol, int Cen)  //  Конструктор инициализирующий

{

Dollars = DolCents = Cen;

}

A operator + (A Curr)  // Попробуем написать перегрузку оператора так

{

A Temp (Dollars + Curr.Dollars, Cents + Curr.Cents);

return Temp;

}

// другие объявления

};

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

A Amount1 (12, 95);

A Amount2 (4, 38);

A Total;

Total = Amount1 + Amount2;

Компилятор языка С++ интерпретирует выражение Amount1 + Amount2 как Amount1.operator+ (Amount2);

Функция operator+ создает временный объект Temp класса A, содержащий размер денежной суммы, полученной в результате сложения двух объектов. Затем она возвращает временный объект и присваивает его экземпляру Total класса A. Такой способ присваивания делает возможным поэлементное копирование компилятором переменных-членов одного класса в другой.

Подобно стандартной операции сложения, выражение, содержащее более одного перегруженного оператора "+", вычисляется слева направо. Например, следующая программа использует перегруженный оператор для сложения величин, хранимых в трех объектах класса A:

void main ()

{

A Advertising (235, 42);

A Rent (823, 68);

A Entertainment (1024, 32)

A Overhead;

Overhead = Advertising + Rent + Entertainment;

Overhead. PrintAmount () ; // Эту функцию нужно сделать членом класса

}

Функцию-член класса A operator+ можно упростить, заменив локальный временный объект класса A неявным временным объектом:

A operator+ (A Curr)

{

return A (Dollars + Curr.Dollars, Cents + Curr.Cents);

}

При вызове конструктора класса компилятор из выражения создает временный объект класса. Функция operator+ непосредственно возвращает содержимое этого временного объекта.

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

A  operator+ (const A &Curr)

{

     return A (Dollars + Curr.Dollars, Cents + Curr.Cents);

}

Использование спецификатора const при объявлении параметра – гарантия того, что функция не изменит значение параметра.

Операции присваивания (=), взятия адреса (&) и «запятая» (,) имеют определенный смысл, если операндами являются объекты типа класса. Но их можно и перегружать. Семантика всех остальных операторов, когда они применяются к таким операндам, должна быть явно задана разработчиком класса. Начинать следует с определения его открытого интерфейса. Набор открытых функций-членов формируется с учетом операций, которые класс должен предоставлять пользователям. Затем принимается решение, какие функции стоит реализовать в виде перегруженных операторов.

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

Примером неправильного использования перегрузки операторов является определение operator + ( ) как операции вычитания, что бессмысленно: не согласующаяся с интуицией семантика опасна. Такой оператор одинаково хорошо поддерживает несколько различных интерпретаций. Безупречно четкое и обоснованное объяснение того, что делает operator + ( ), вряд ли устроит пользователей класса String, полагающих, что он служит для конкатенации строк. Если семантика перегруженного оператора не очевидна, то лучше его не перегружать.

Эквивалентность семантики составного оператора и соответствующей последовательности простых операторов для встроенных типов (например, эквивалентность оператора +, за которым следует =, и составного оператора +=) должна быть явно поддержана и для класса.

Предположим, для класса String определены как operator +( ), так и operator = ( ) для поддержки операций конкатенации и почленного копирования:

String s1( "C");

String s2( "++" );

s1 = s1 + s2;    // s1 = = "C++"

Но этого недостаточно для поддержки составного оператора присваивания:

s1 += s2;

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