Дружественные функции и перегрузка операций.(5 час.)

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

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

Объявление дружественной функции представляет собой прототип функции, объявление переопределяемой операции или имя класса, которым разрешается доступ, с ключевым словом friend впереди. Общая схема объявления такова:

class A{

int x; // Личная часть класса

...

friend class B; // Функции класса B дружественны A

// (имеют доступ к приватной части A)

friend void C::fun(A&);// Элемент-функция fun класса C имеет

// доступ к приватной части A

friend void xxx(A&,int);// Функция xxx дружественна классу A

friend void C::operator+(А&);

// Переопределяемая в классе C операция

}; // <объект C>+<объект A> дружественна

// классу A

class B // Необходим доступ к личной части A

{

public: int fun1(A&);

void fun2(A&);

};

class C

{

public: void fun(A&);

void operator+(A&);

};

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

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

Можно описывать функции, определяющие значения следующих операций:

+ - * / % ^ & | ~!

= < > += -= *= /= %= ^= &=

|= << >> >>= <<= ==!= <= >= &&

|| ++ -- [] () new delete

Последние четыре - это индексирование, вызов функции, выделение свободной памяти и освобождение свободной памяти. Изменить приоритеты перечисленных операций невозможно, как невозможно изменить и синтаксис выражений. Нельзя, например, определить унарную операцию % или бинарную!. Невозможно определить новые лексические символы операций, но в тех случаях, когда множество операций недостаточно, вы можете использовать запись вызова функции. Используйте например, не **, а pow(). Эти ограничения могут показаться драконовскими, но более гибкие правила могут очень легко привести к неоднозначностям. Например, на первый взгляд определение операции **, означающей возведение в степень, может показаться очевидной и простой задачей, но подумайте еще раз. Должна ли ** связываться влево (как в Фортране) или вправо (как в Алголе)? Выражение a**p должно интерпретироваться как a*(*p) или как (a)**(p)? Имя функции операции есть ключевое слово operator (то есть, операция), за которым следует сама операция, например, operator<<. Функция операция описывается и может вызываться так же, как любая другая функция. Использование операции - это лишь сокращенная запись явного вызова функции операции. Например:

void f(complex a, complex b)

{

complex c = a + b; // сокращенная запись

complex d = operator+(a,b); // явный вызов

}

Для переопределения операции используется особая форма функции-элемента с заголовком такого вида:

operator операция(список_параметров-операндов)

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

Имеется два способа описания функции, соответствующей переопределяемой операции:

· если функция задается как обычная функция-элемент класса, то первым операндом операции является объект класса, указатель на который передается неявным параметром this;

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

Естественно, что полное имя такой функции не содержит имени класса.

Бинарная операция может быть определена или как функция член, получающая один параметр, или как функция друг, получающая два параметра. Таким образом, для любой бинарной операции @ aa@bb может интерпретироваться или как aa.operator@(bb), или как operator@(aa,bb). Если определены обе, то aa@bb является ошибкой. Унарная операция, префиксная или постфиксная, может быть определена или как функция член, не получающая параметров, или как функция друг, получающая один параметр. Таким образом, для любой унарной операции @ aa@ или @aa может интерпретироваться или как aa.operator@(), или как operator@(aa). Если определена и то, и другое, то и aa@ и @aa являются ошибками. Рассмотрим следующие примеры:

class X {

// друзья

friend X operator-(X); // унарный минус

friend X operator-(X,X); // бинарный минус

friend X operator-(); // ошибка: нет операндов

friend X operator-(X,X,X); // ошибка: тернарная

// члены (с неявным первым параметром: this)

X* operator&(); // унарное & (взятие адреса)

X operator&(X); // бинарное & (операция И)

X operator&(X,X); // ошибка: тернарное

};

Необходимо отметить также и тот факт, что для каждой комбинации типов операндов в переопределяемой операции необходимо ввести отдельную функцию, то есть транслятор не может производить перестановку операндов местами, даже если базовая операция допускает это. Например, при переопределении операции сложения объекта класса dat с целым необходимо две функции dat+int и int+dat.

В качестве примера рассмотрим доопределение стандартных операций над датами:

static int days[]={ 0,31,28,31,30,31,30,31,31,30,31,30,31};

class dat {

int day,month,year;

public:

void next(); // Элемент-функция вычисления следующего дня

dat operator++(); // Операция ++

dat operator+(int);// Операция "дата + целое" с передачей

// первого операнда через this

friend dat operator+(int,dat);// Операция с явной передачей всех

//аргументов по значению

dat(int=0,int=0,int=0);

dat(char *); //

~dat(); //

}; //

//------ Функция вычисления следующего дня -----------------

// Используется ссылка на текущий объект this,

// который изменяется в процессе операции

void dat::next()

{

day++;

if (day > days[month])

{

if ((month==2) && (day==29) && (year%4==0)) return;

day=1; month++;

if (month==13)

{

month=1; year++;

}

}

}

//------ Операция инкремента даты -------------------------

// 1. Первый операнд по указателю this

// 2. Возвращает копию входного объекта (операнда)

// до увеличения

// 3. Соответствует операции dat++ (увеличение после

// использования)

// 4. Замечание: для унарных операций типа -- или ++

// использование их до или после операнда не имеет

// значения (вызывается одна и та же функция).

dat dat::operator++()

{ // Создается временный объект

dat x = *this; // В него копируется текущий объект

dat::next(); // Увеличивается значение текущего объекта

return(x); // Возвращается временный объект по

} // значению

//------ Операция "дата + целое" --------------------------

// 1. Первый операнд по указателю this

// 2. Входной объект не меняется, результат возвращается

// в виде значения автоматического объекта x

dat dat::operator+(int n)

{

dat x;

x = *this; // Копирование текущего объекта в x

while (n--!=0) x.next();// Вызов функции next для объекта x

return(x); // Возврат объекта x по значению

}

//------ Операция "целое + дата" -------------------------

// 1. Дружественная функция с полным списком операндов

// 2. Второй операнд класса dat - передается по значению,

// поэтому может модифицироваться без изменения исходного

// объекта

dat operator+(int n, dat p)

{

while (n--!=0) p.next(); // Вызов функции next для p

return(p); // Возврат копии объекта p

}

void main()

{

int i;

dat a, b(17,12,1990), c(12,7), d(3), e;

dat *p = new dat[10];

e = a++;

d = b+15;

for (i=0; i<10; i++) p[i] = p[i] + i;

delete[10] p;

}

Для многих переопределяемых операций тип результата совпадает с типом одного из операндов. Это позволяет выполнить подряд несколько операций в одном выражении. Возможны различные варианты реализации в соответствии со способами передачи параметров и результата: по значению или по ссылке. Отметим наиболее важные из них:

//------ Операция "дата + целое" --------------------------

// 1. Функция с неявным первым операндом по указателю this

// 2. Меняется значение текущего объекта

// 3. Результат - ссылка на текущий объект

dat& dat::operator+(int n)

{

while (n--!=0) next(); // Вызов next с текущим объектом

return(*this); // Возврат ссылки на объект this

}

//------ Операция "дата + целое" -------------------------

// 1. Дружественная функция с полным списком аргументов

// 2. Первый операнд класса dat - ссылка, меняется при

// выполнении операции

// 3. Результат - ссылка на операнд

dat& operator+(dat& p,int n)

{

while (n--!=0) p.next(); // Вызов next для объекта p,

// заданного ссылкой

return(p); // Возврат ссылки на p

}

//----- Операция "целое + дата" --------------------------

// 1. Дружественная функция с полным списком аргументов

// 2. Второй операнд класса dat - ссылка, меняется при

// выполнении операции

// 3. Результат - ссылка на операнд

//--------------------------------------------------------

dat& operator+(int n, dat& p)

{

while (n--!=0) p.next(); // Вызов next для объекта p,

// заданного ссылкой

return(p); // Возврат ссылки на p

}

void main()

{

dat a,b; // "Арифметические" эквиваленты

a + 2 + 3; // a = a + 2; a = a + 3;

5 + b + 4; // b = 5 + b; b = b + 4;

}

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

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

//------ Операция "дата + целое" --------------------------

// 1. Функция с неявным первым операндом по указателю this

// 2. Изменяется автоматический объект - копия операнда

// 3. Результат - значение автоматического объекта

dat dat::operator+(int n)

{

dat tmp = *this; // Объект - копия операнда

while (n--!=0) tmp.next();// Вызов next с объектом tmp

return(tmp); // Возврат значения объекта tmp

}

//------ Операция "дата + целое" -------------------------

// 1. Дружественная функция с полным списком аргументов

// 2. Первый параметр класса dat передается по значению,

// является копией первого операнда и меняется при

// выполнении операции

// 3. Результат - значение формального параметра

dat operator+(dat p,int n)

{

while (n--!=0) p.next(); // Вызов next для объекта p,

// копии операнда

return(p); // Возврат значения

} // формального параметра

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

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

class string

{

char *Str;

int size;

public:

string &operator =(string&);

};

string &string::operator=(string& right)

{

if (Str!=NULL) delete Str;// Освободить динамическую память

size = Str.right.size; // Резервировать память

Str = new char[size];

strcpy(Str,right->Str); // Копировать строки

}

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

//------Переопределение операций [] и ()

#include <string.h>

class string // Строка переменной длины

{

char *Str; // Динамический массив символов

int size; // Длина строки

public:

string operator()(int,int); // Операция выделения подстроки

char operator[](int); // Операция выделения символа

int operator[](char*); // Операция поиска подстроки

};

//------ Операция выделения подстроки -------------------

string string::operator()(int n1, int n2) {

string tmp = *this;

delete tmp.Str;

tmp.Str = new char[n2-n1+1];

strncpy(tmp.Str, Str+n1, n2-n1); }

Пример переопределения операции инкремента приведен выше. Переопределение декремента производится аналогично. Заметим только, что когда операции ++ и -- перегружены, префиксное использование и постфиксное в классическом С++ различить невозможно. В современной версии языка (Microsoft Visual C++ 6.0) принято соглашение, что перегрузка префиксных операций ++ и -- ничем не отличаются от перегрузки других унарных операций, то есть дружественные функции operator++() и operator--() с одним параметром некоторого класса определяют префиксные операции ++ и --. Операции - члены класса без параметров определяют те же префиксные операции. При расширении действия постфиксных операций ++ и – операции-функции должны иметь еще один дополнительный параметр типа int. Если для перегрузки используется операция - член класса, то она должна иметь один параметр типа int. Если операция определена как дружественная функция, то ее первый параметр должен иметь тип класса, а второй - тип int. Когда в программе используется соответствующее постфиксное выражение, то операция - функция вызывается с нулевым целым параметром.

Рассмотрим пример применения разных операций - функций для постфиксной и префиксной операций ++ и --.

class pair // класс «пара чисел»

{ int N; // целое

double x; // вещественное

friend pair& operator ++ (pair&); //дружественная для префикса

friend pair& operator ++(pair&,int);//дружественная для постфикса

public:

pair (int n, double xn) //конструктор

{ N = n; x = xn; }

void display () //вывод значения

{ printf (”N = % d x = % f\n”, N, x); }

pair & operator –- () //член для префикса

{ N /= 10; x /= 10; return *this; }

pair & operator –- (int k) //член для постфикса

{ N /= 2; x /= 2; return *this; }

};

pair & operator ++ (pair & P) // дружественная для префикса

{ P.N *= 10; P.x *= 10; return P; }

pair & operator ++ (pair & P, int k)// дружественная для постфикса

{ P.N = P.N * 2 + k; P.x = P.x * 2 + k; return P; }

void main ()

{ pair Z (10, 20.0); //вызов конструктора

Z.display(); //N = 10 x = 20

++Z; Z.display(); //N = 100 x = 200

--Z; Z.display(); //N = 10 x = 20

Z++; Z.display(); //N = 20 x = 40

Z--; Z.display(); //N = 10 x = 20

}

Для демонстрации полной независимости смысла перегруженной операции от ее традиционного значения в операциях - функциях для префиксных операций ++ соответствует увеличению в 10 раз, а –- уменьшению в 10 раз. Для постфиксных операций ++ определена как увеличение в 2 раза, а -- уменьшение в 2 раза. Попытки использовать в постфиксных операциях значение дополнительного параметра int k подтверждают его равенство 0.

Перегрузка new, delete. Операции создания и уничтожения объектов в динамической памяти могут быть переопределены следующим образом

void *operator new(size_t size);

void operator delete (void *);

где void * - указатель на область памяти, выделяемую под объект, size - размер объекта в байтах, size_t - тип размерности области памяти, int или long.

Переопределение этих операций позволяет написать собственное распределение памяти для объектов класса.

Операции, не допускающие перегрузки. В С++ существует несколько операций, не допускающих перегрузки:

. прямой выбор члена объекта класса;

.* обращение к члену через указатель на него;

?: условная операция;

:: операция указания области видимости;

sizeof операция вычисления размера в байтах;

# препроцессорная операция.

Преобразование типов, определяемых пользователем с помощью конструкторов и операций преобразования. При работе со стандартными типами данных в Си имеют место явные и неявные преобразования их типов. По аналогии для классов также могут быть определены такие операции - они ассоциируются с конструированием объектов класса. Так, если в программе встречается преобразование типа (класса) "yyy" к типу (классу) "xxx", то для его осуществления в классе "xxx" необходим конструктор вида xxx(yyy &);

Сами преобразования типов происходят в тех же самых случаях, что и обычные преобразования базовых типов данных:

· при использовании операции явного преобразования типов;

· при выполнении операции присваивания, если она не переопределена в виде "xxx=yyy" (транслятором создается временный объект класса "xxx", для которого вызывается указанный конструктор и который затем используется в правой части операции присваивания);

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

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

· при определении объекта класса "xxx" одновременно с его инициализацией объектом класса "yyy" (вместо конструктора копирования)

yyy b;

xxx a = b;

При конструировании объекта класса "xxx" с использованием объекта класса "yyy" естественно должна быть обеспечена доступность необходимых данных последнего (например, через дружественность).

В качестве примера рассмотрим обратное преобразование базового типа long к типу dat - количество дней от начала летоисчисления преобразуется к дате. Здесь же рассмотрим другой класс - man, в котором одним из элементов личной части является дата. Значение этого объекта копируется при преобразовании типа man в тип dat.

static int days[]={0,31,28,31,30,31,30,31,31,30,31,30,31};

class man;

class dat

{

int day,month,year;

public:

dat(long); // Преобразование long в dat

dat(man&); // Преобразование man в dat

dat() {}

};

class man

{

friend class dat;

dat WasBorn; // объект класса dat в объекте класса man

public:

man(dat&); // Преобразование dat в man

};

dat::dat(man& p)

{ *this = p.WasBorn; }

man::man(dat& p)

{ WasBorn = p; }

dat::dat(long p)

{

year = p / 365.25; // Число лет с учетом високосных

p-=(year-1)*365L - year/4; // Остаток дней в текущем году

year++; // Начальный год - 0001

for (month=1; p > 0; month++)

{ // Вычитание дней по месяцам

p -= days[month];

if (month == 2 && year % 4 == 0) p--;

}

month--; // Восстановление последнего

p += days[month]; // месяца

if (month == 2 && year % 4 == 0) p++;

day = p + 1;

}

Преобразование объектов класса имеет место при выполнении операций явного приведения типов, присваивания, а также при определении объектов с инициализацией их объектами приводимого класса

long l=1000;

dat a = l, b; // Вызов конструктора dat(long)

man c = a; // Вызов конструктора man(dat&)

man f(man a)

{ return(a); }

void main()

{

a = 2000L; // Вызов конструктора dat(long)

(dat)3000L; // Вызов конструктора dat(long)

c = b; // Вызов конструктора man(dat&)

b = f(b); // Вызов конструктора dat(man&)

}

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

Неявное преобразование типов. Рассмотрим неявное преобразование объекта класса dat к базовым типам данных int и long. Сущность его заключается в вычислении полного количества дней в дате, заданной входным объектом (long) и количества дней в текущем году в этой же дате (int). Для задания этих операции необходимо переопределить в классе dat одноименные операции int и long. Переопределяемые операции задаются соответствующими функциями-элементами без параметров. Тип результата совпадает с базовым типом, к которому осуществляется приведение и поэтому не указывается:

#include <stdio.h>

static int days[]={0,31,28,31,30,31,30,31,31,30,31,30,31};

class dat

{

int day,month,year;

public:

operator int(); // Преобразование dat в int

operator long(); // Преобразование dat в long

long operator -(dat &p); // Операция dat-dat вычисляет

dat(); // разность дат в днях

dat(char *);

};

dat::operator int()

{

int r; // Текущий результат

int i; // Счетчик месяцев

for (r=0, i=1; i<month; i++) // Число дней в прошедших

r += days[month]; // месяцах

if ((month>2) && (year%4==0)) r++; // Високосный год

r += day; // Дней в текущем месяце

return(r);

}

dat::operator long()

{

long r; // Текущий результат

r = 365 * (year-1); // Дней в предыдущих полных годах

r += year / 4; // Високосные года

r += (int)(*this); // Дней в текущем году

return(r);

}

long dat::operator-(dat& p)

{return((long)(*this) - (long)p);}

void main()

{

dat a("12-05-1990"); // Дата, заданная строкой

dat b; // Текущая дата

int c;

long d;

printf("С 12-05-1990 прошло %4ld дней\n",(long)b-(long)a);

printf("В этом году прошло %3d дней\n",(int)b);

c = b;

d = b - a; // Операция dat-dat

printf("С 12-05-1990 прошло %4ld дней\n",d);

printf("В этом году прошло %3d дней\n",c);

}

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

class X {

//...

X(int);

int m();

friend int f(X&);

};

Внешне не видно никаких причин делать f(X&) другом дополнительно к члену X::m() (или наоборот), чтобы реализовать действия над классом X. Однако член X::m() можно вызывать только для "настоящего объекта", в то время как друг f() может вызываться для объекта, созданного с помощью неявного преобразования типа. Например:

void g()

{

1.m(); // ошибка

f(1); // f(x(1));

}

Поэтому операция, изменяющее состояние объекта, должно быть членом, а не другом. Для определяемых пользователем типов операции, требующие в случае фундаментальных типов операнд lvalue (=, *=, ++ и т.д.), наиболее естественно определяются как члены. И наоборот, если нужно иметь неявное преобразование для всех операндов операции, то реализующая ее функция должна быть другом, а не членом. Это часто имеет место для функций, которые реализуют операции, не требующие при применении к фундаментальным типам lvalue в качестве операндов (+, -, || и т.д.).

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

При прочих равных условиях выбирайте, чтобы функция была членом: никто не знает, вдруг когда-нибудь кто-то определит операцию преобразования. Невозможно предсказать, потребуют ли будущие изменения изменить статус объекта. Синтаксис вызова функции члена ясно указывает пользователю, что объект можно изменить; ссылочный параметр является далеко не столь очевидным. Кроме того, выражения в члене могут быть заметно короче выражений в друге. В функции друге надо использовать явный параметр, тогда как в члене можно использовать неявный this. Если только не применяется перегрузка, имена членов обычно короче имен друзей.


Понравилась статья? Добавь ее в закладку (CTRL+D) и не забудь поделиться с друзьями:  



double arrow
Сейчас читают про: