|
Часть 8
Глава 6 Перегрузка Операций
Здесь водятся Драконы!
- старинная карта
В этой главе описывается аппарат, предоставляемый в С++ для пе-
регрузки операций. Программист может определять смысл операций при
их применении к объектам определенного класса. Кроме арифмети-
ческих, можно определять еще и логические операции, операции срав-
нения, вызова () и индексирования [], а также можно переопределять
присваивание и инициализацию. Можно определить явное и неявное пре-
образование между определяемыми пользователем и основными типами.
Показано, как определить класс, объект которого не может быть никак
иначе скопирован или уничтожен кроме как специальными определенными
пользователем функциями.
6.1 Введение
Часто программы работают с объектами, которые являются конкретны-
ми представлениями абстрактных понятий. Например, тип данных int в
С++ вместе с операциями +, -, *, / и т.д. предоставляет реализацию
(ограниченную) математического понятия целых чисел. Такие понятия
обычно включают в себя множество операций, которые кратко, удобно и
привычно представляют основные действия над объектами. К сожалению,
язык программирования может непосредственно поддерживать лишь очень
малое число таких понятий. Например, такие понятия, как комплексная
арифметика, матричная алгебра, логические сигналы и строки не полу-
чили прямой поддержки в С++. Классы дают средство спецификации в
С++ представления неэлементарных объектов вместе с множеством
действий, которые могут над этими объектами выполняться. Иногда оп-
ределение того, как действуют операции на объекты классов, позволя-
ет программисту обеспечить более общепринятую и удобную запись для
манипуляции объектами классов, чем та, которую можно достичь
используя лишь основную функциональную запись. Например:
class complex { double re, im;
public:
complex(double r, double i) { re=r; im=i; } friend complex
operator+(complex, complex); friend complex
operator*(complex, complex);
};
определяет простую реализацию понятия комплексного числа, в которой
число представляется парой чисел с плавающей точкой двойной точ-
ности, работа с которыми осуществляется посредством операций + и *
(и только). Программист задает смысл операций + и * с помощью опре-
деления функций с именами operator+ и operator*. Если, например,
даны b и c типа complex, то b+c означает (по определению)
operator+(b,c). Теперь есть возможность приблизить общепринятую ин-
терпретацию комплексных выражений. Например:
void f() {
complex a = complex(1, 3.1); complex b = complex(1.2, 2);
complex c = b;
a = b+c; b = b+c*a; c = a*b+complex(1,2);
}
Выполняются обычные правила приоритетов, поэтому второй оператор
означает b=b+(c*a), а не b=(b+c)*a.
6.2 Функции Операции
Можно описывать функции, определяющие значения следующих опера-
ций:
+ - * / % ^ & | ~ !
= < > += -= *= /= %= ^= &=
|= << >> >>= <<= == != <= >= &&
|| ++ -- [] () new delete
Последние четыре - это индексирование (#6.7), вызов функции
(#6.8), выделение свободной памяти и освобождение свободной памяти
(#3.2.6). Изменить приоритеты перечисленных операций невозможно,
как невозможно изменить и синтаксис выражений. Нельзя, например,
определить унарную операцию % или бинарную !. Невозможно определить
новые лексические символы операций, но в тех случаях, когда мно-
жество операций недостаточно, вы можете использовать запись вызова
функции. Используйте например, не **, а pow(). Эти ограничения мо-
гут показаться драконовскими, но более гибкие правила могут очень
легко привести к неоднозначностям. Например, на первый взгляд опре-
деление операции **, означающей возведение в степень, может пока-
заться очевидной и простой задачей, но подумайте еще раз. Должна ли
** связываться влево (как в Фортране) или вправо (как в Алголе)?
Выражение a**p должно интерпретироваться как a*(*p) или как
(a)**(p)?
Имя функции операции есть ключевое слово operator (то есть, опе-
рация), за которым следует сама операция, например, operator<<.
Функция операция описывается и может вызываться так же, как любая
другая функция. Использование операции - это лишь сокращенная за-
пись явного вызова функции операции. Например:
void f(complex a, complex b) {
complex c = a + b; // сокращенная запись complex d =
operator+(a,b); // явный вызов
}
При наличии предыдущего описания complex оба инициализатора явля-
ются синонимами.
6.2.1 Бинарные и Унарные Операции
Бинарная операция может быть определена или как функция член, по-
лучающая один параметр, или как функция друг, получающая два пара-
метра. Таким образом, для любой бинарной операции @ 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); // ошибка: тер-
нарное
};
Когда операции ++ и -- перегружены, префиксное использование и
постфиксное различить невозможно.
6.2.2 Предопределенный Смысл Операций
Относительно смысла операций, определяемых пользователем, не де-
лается никаких предположений. В частности, поскольку не предполага-
ется, что перегруженное = реализует присваивание ее первому операн-
ду, не делается никакой проверки, чтобы удостовериться, является ли
этот операнд lvalue (#с.6).
Значения некоторых встроенных операций определены как равносиль-
ные определенным комбинациям других операций над теми же аргумента-
ми. Например, если a является int, то ++a означает a+=1, что в свою
очередь означает a=a+1. Такие соотношения для определяемых пользо-
вателем операций не выполняются, если только не случилось так, что
пользователь сам определил их таким образом. Например, определение
operator+= () для типа complex не может быть выведено из определе-
ний complex::operator+() и complex::operator=().
По историческому совпадению операции = и & имеют определенный
смысл для объектов классов. Никакого элегантного способа "не опре-
делить" эти две операции не существует. Их можно, однако, сделать
недееспособными для класса X. Можно, например, описать
X::operator&(), не задав ее определения. Если где-либо будет
браться адрес объекта класса X, то компоновщик обнаружит отсутствие
определения*. Или, другой способ, можно определить X::operator&()
так, чтобы она вызывала ошибку во время выполнения.
____________________
* В некоторых системах компоновщик настолько "умен", что ругается,
даже если неопределена неиспользуемая функция. В таких системах
этим методом воспользоваться нельзя. (прим автора)
6.2.3 Операции и Определяемые Пользователем Типы
Функция операция должна или быть членом, или получать в качестве
параметра по меньшей мере один объект класса (функциям, которые пе-
реопределяют операции new и delete, это делать необязательно). Это
правило гарантирует, что пользователь не может изменить смысл ника-
кого выражения, не включающего в себя определенного пользователем
типа. В частности, невозможно определить функцию, которая действует
исключительно на указатели.
Функция операция, первым параметром которой предполагается основ-
ной встроенный тип, не может быть функцией членом. Рассмотрим, нап-
ример, сложение комплексной переменной aa с целым 2: aa+2, при под-
ходящим образом описанной функции члене, может быть проинтерпрети-
ровано как aa.operator+(2), но с 2+aa это не может быть сделано,
потому что нет такого класса int, для которого можно было бы опре-
делить + так, чтобы это означало 2.operator+(aa). Даже если бы та-
кой тип был, то для того, чтобы обработать и 2+aa и aa+2, понадоби-
лось бы две различных функции члена. Так как компилятор не знает
смысла +, определяемого пользователем, то не может предполагать,
что он коммутативен, и интерпретировать 2+aa как aa+2. С этим при-
мером могут легко справиться функции друзья.
Все функции операции по определению перегружены. Функция операция
задает новый смысл операции в дополнение к встроенному определению,
и может существовать несколько функций операций с одним и тем же
именем, если в типах их параметров имеются отличия, различимые для
компилятора, чтобы он мог различать их при обращении (см. #4.6.7).
6.3 Определяемое Пользователем Преобразование Типа
Приведенная во введении реализация комплексных чисел слишком ог-
раничена, чтобы она могла устроить кого-либо, поэтому ее нужно
расширить. Это будет в основном повторением описанных выше методов.
Например:
class complex { double re, im;
public:
complex(double r, double i) { re=r; im=i; }
friend complex operator+(complex, complex); friend complex
operator+(complex, double); friend complex operator+(double,
complex);
friend complex operator-(complex, complex); friend complex
operator-(complex, double); friend complex operator-(double,
complex); complex operator-() // унарный -
friend complex operator*(complex, complex); friend complex
operator*(complex, double); friend complex operator*(double,
complex);
// ...
};
Теперь, имея описание complex, мы можем написать:
void f() {
complex a(1,1), b(2,2), c(3,3), d(4,4), e(5,5); a = -b-c; b =
c*2.0*c; c = (d+e)*a;
}
Но писать функцию для каждого сочетания complex и double, как это
делалось выше для operator+(), невыносимо нудно. Кроме того, близ-
кие к реальности средства комплексной арифметики должны предостав-
лять по меньшей мере дюжину таких функций. Посмотрите, например, на
тип complex, описанный в .
6.3.1 Конструкторы
Альтернативу использованию нескольких функций (перегруженных)
составляет описание конструктора, который по заданному double соз-
дает complex. Например:
class complex {
// ...
complex(double r) { re=r; im=0; }
};
Конструктор, требующий только один параметр, необязательно вызы-
вать явно:
complex z1 = complex(23); complex z2 = 23;
И z1, и z2 будут инициализированы вызовом complex(23).
Конструктор - это предписание, как создавать значение данного ти-
па. Когда требуется значение типа, и когда такое значение может
быть создано конструктором, тогда, если такое значение дается для
присваивания, вызывается конструктор. Например, класс complex можно
было бы описать так:
class complex { double re, im;
public:
complex(double r, double i = 0) { re=r; im=i; }
friend complex operator+(complex, complex); friend complex
operator*(complex, complex);
};
и действия, в которые будут входить переменные complex и целые
константы, стали бы допустимы. Целая константа будет интерпретиро-
ваться как complex с нулевой мнимой частью. Например, a=b*2 означа-
ет:
a=operator*( b, complex( double(2), double(0) ) )
Определенное пользователем преобразование типа применяется неявно
только тогда, когда оно является единственным.
Объект, сконструированный с помощью явного или неявного вызова
конструктора, является автоматическим и будет уничтожен при первой
возможности, обычно сразу же после оператора, в котором он был соз-
дан.
6.3.2 Операции Преобразования
Использование конструктора для задания преобразования типа явля-
ется удобным, но имеет следствия, которые могут оказаться нежела-
тельными:
[1] Не может быть неявного преобразования из определенного поль-
зователем типа в основной тип (поскольку основные типы не яв-
ляются классами)
[2] Невозможно задать преобразование из нового типа в старый, не
изменяя описание старого
[3] Невозможно иметь конструктор с одним параметром, не имея при
этом преобразования.
Последнее не является серьезной проблемой, а с первыми двумя мож-
но справиться, определив для исходного типа операцию преобразова-
ния. Функция член X::operator T(), где T - имя типа, определяет
преобразование из X в T. Например, можно определить тип tiny (кро-
шечный), который может иметь значение только в диапазоне 0...63, но
все равно может свободно сочетаться в целыми в арифметических опе-
рациях:
class tiny { char v; int assign(int i) { return v = (i&~63) ?
(error("ошибка диапазона"),0) : i; }
public:
tiny(int i) { assign(i); } tiny(tiny& i) { v = t.v; } int
operator=(tiny& i) { return v = t.v; } int operator=(int i) {
return assign(i); } operator int() { return v; }
}
Диапазон значения проверяется всегда, когда tiny инициализируется
int, и всегда, когда ему присваивается int. Одно tiny может присва-
иваться другому без проверки диапазона. Чтобы разрешить выполнять
над переменными tiny обычные целые операции, определяется
tiny::operator int(), неявное преобразование из tiny в int. Всегда,
когда в том месте, где требуется int, появляется tiny, используется
соответствующее ему int. Например:
void main() {
tiny c1 = 2; tiny c2 = 62; tiny c3 = c2 - c1; // c3 = 60 tiny
c4 = c3; // нет проверки диапазона (необязательна) int i = c1
+ c2; // i = 64 c1 = c2 + 2 * c1; // ошибка диапазона: c1 = 0
(а не 66) c2 = c1 -i; // ошибка диапазона: c2 = 0 c3 = c2; //
нет проверки диапазона (необязательна)
}
Тип вектор из tiny может оказаться более полезным, поскольку он
экономит пространство. Чтобы сделать этот тип более удобным в обра-
щении, можно использовать операцию индексирования.
Другое применение определяемых операций преобразования - это ти-
пы, которые предоставляют нестандартные представления чисел (ариф-
метика по основанию 100, арифметика, арифметика с фиксированной
точкой, двоично-десятичное представление и т.п.). При этом обычно
переопределяются такие операции, как + и *.
Функции преобразования оказываются особенно полезными для работы
со структурами данных, когда чтение (реализованное посредством опе-
рации преобразования) тривиально, в то время как присваивание и
инициализация заметно более сложны.
Типы istream и ostream опираются на функцию преобразования, чтобы
сделать возможными такие операторы, как
while (cin>>x) cout<>x выше возвращает istream&. Это значение не-
явно преобразуется к значению, которое указывает состояние cin, а
уже это значение может проверяться оператором while (см. #8.4.2).
Однако определять преобразование из оного типа в другой так, что
при этом теряется информация, обычно не стоит.
6.3.3 Неоднозначности
Присваивание объекту (или инициализация объекта) класса X явля-
ется допустимым, если или присваиваемое значение является X, или
существует единственное преобразование присваиваемого значения в
тип X.
В некоторых случаях значение нужного типа может быть построено с
помощью нескольких применений конструкторов или операций преобразо-
вания. Это должно делаться явно; допустим только один уровень неяв-
ных преобразований, определенных пользователем. Иногда значение
нужного типа может быть построено более чем одним способом. Такие
случаи являются недопустимыми. Например:
class x { /* ... */ x(int); x(char*); }; class y { /* ... */
y(int); }; class z { /* ... */ z(x); };
overload f; x f(x); y f(y);
z g(z);
f(1); // недопустимо: неоднозначность f(x(1)) или f(y(1))
f(x(1)); f(y(1)); g("asdf"); // недопустимо: g(z(x("asdf"))) не
пробуется g(z("asdf"));
Определяемые пользователем преобразования рассматриваются только
в том случае, если без них вызов разрешить нельзя. Например:
class x { /* ... */ x(int); } overload h(double), h(x); h(1);
Вызов мог бы быть проинтерпретирован или как h(double(1)), или
как h(x(1)), и был бы недопустим по правилу единственности. Но пер-
вая интерпретация использует только стандартное преобразование и
она будет выбрана по правилам, приведенным в #4.6.7.
Правила преобразования не являются ни самыми простыми для реали-
зации и документации, ни наиболее общими из тех, которые можно было
бы разработать. Возьмем требование единственности преобразования.
Более общий подход разрешил бы компилятору применять любое преобра-
зование, которое он сможет найти; таким образом, не нужно было бы
рассматривать все возможные преобразования перед тем, как объявить
выражение допустимым. К сожалению, это означало бы, что смысл прог-
раммы зависит от того, какое преобразование было найдено. В резуль-
тате смысл программы неким образом зависел бы от порядка описания
преобразований. Поскольку они часто находятся в разных исходных
файлах (написанных разными людьми), смысл программы будет зависеть
от порядка компоновки этих частей вместе. Есть другой вариант -
запретить все неявные преобразования. Нет ничего проще, но такое
правило приведет либо к неэлегантным пользовательским интерфейсам,
либо к бурному росту перегруженных функций, как это было в предыду-
щем разделе с complex.
Самый общий подход учитывал бы всю имеющуюся информацию о типах и
рассматривал бы все возможные преобразования. Например, если
использовать предыдущее описание, то можно было бы обработать
aa=f(1), так как тип aa определяет единственность толкования. Если
aa является x, то единственное, дающее в результате x, который тре-
буется присваиванием, - это f(x(1)), а если aa - это y, то вместо
этого будет использоваться f(y(1)). Самый общий подход справился бы
и с g("asdf"), поскольку единственной интерпретацией этого может
быть g(z(x("asdf"))). Сложность этого подхода в том, что он требует
расширенного анализа всего выражения для того, чтобы определить ин-
терпретацию каждой операции и вызова функции. Это приведет к замед-
лению компиляции, а также к вызывающим удивление интерпретациям и
сообщениям об ошибках, если компилятор рассмотрит преобразования,
определенные в библиотеках и т.п. При таком подходе компилятор бу-
дет принимать во внимание больше, чем, как можно ожидать, знает пи-
шущий программу программист!
6.4 Константы
Константы классового типа определить невозможно в том смысле, в
каком 1.2 и 12e3 являются константами типа double. Вместо них, од-
нако, часто можно использовать константы основных типов, если их
реализация обеспечивается с помощью функций членов. Общий аппарат
для этого дают конструкторы, получающие один параметр. Когда
конструкторы просты и подставляются inline, имеет смысл рассмотреть
в качестве константы вызов конструктора. Если, например, в
есть описание класса comlpex, то выражение
zz1*3+zz2*comlpex(1,2) даст два вызова функций, а не пять. К двум
вызовам функций приведут две операции *, а операция + и конструк-
тор, к которому обращаются для создания comlpex(3) и comlpex(1,2),
будут расширены inline.
6.5 Большие Объекты
При каждом применении для comlpex бинарных операций, описанных
выше, в функцию, которая реализует операцию, как параметр переда-
ется копия каждого операнда. Расходы на копирование каждого double
заметны, но с ними вполне можно примириться. К сожалению, не все
классы имеют небольшое и удобное представление. Чтобы избежать не-
нужного копирования, можно описать функции таким образом, чтобы они
получали ссылочные параметры. Например:
class matrix { double m[4][4];
public:
matrix(); friend matrix operator+(matrix&, matrix&); friend
matrix operator*(matrix&, matrix&);
};
Ссылки позволяют использовать выражения, содержащие обычные ариф-
метические операции над большими объектами, без ненужного копирова-
ния. Указатели применять нельзя, потому что невозможно для примене-
ния к указателю смысл операции переопределить невозможно. Операцию
плюс можно определить так:
matrix operator+(matrix&, matrix&); {
matrix sum; for (int i=0; i<4; i++)
for (int j=0; j<4; j++) sum.m[i][j] = arg1.m[i][j] +
arg2.m[i][j];
return sum;
}
Эта operator+() обращается к операндам + через ссылки, но возвра-
щает значение объекта. Возврат ссылки может оказаться более эффек-
тивным:
class matrix {
// ...
friend matrix& operator+(matrix&, matrix&); friend matrix&
operator*(matrix&, matrix&);
};
Это является допустимым, но приводит к сложности с выделением па-
мяти. Поскольку ссылка на результат будет передаваться из функции
как ссылка на возвращаемое значение, оно не может быть автомати-
ческой переменной. Поскольку часто операция используется в выраже-
нии больше одного раза, результат не может быть и статической пере-
менной. Как правило, его размещают в свободной памяти. Часто копи-
рование возвращаемого значения оказывается дешевле (по времени вы-
полнения, объему кода и объему данных) и проще программируется.
6.6 Присваивание и Инициализация
Рассмотрим очень простой класс строк string:
struct string { char* p; int size; // размер вектора, на который
указывает p
string(int sz) { p = new char[size=sz]; }
~string() { delete p; }
};
Строка - это структура данных, состоящая из вектора символов и
длины этого вектора. Вектор создается конструктором и уничтожается
деструктором. Однако, как показано в #5.10, это может привести к
неприятностям. Например:
void f() {
string s1(10); string s2(20); s1 = s2;
}
будет размещать два вектора символов, а присваивание s1=s2 будет
портить указатель на один из них и дублировать другой. На выходе из
f() для s1 и s2 будет вызываться деструктор и уничтожать один и тот
же вектор с непредсказуемо разрушительными последствиями. Решение
этой проблемы состоит в том, чтобы соответствующим образом опреде-
лить присваивание объектов типа string:
struct string { char* p; int size; // размер вектора, на который
указывает p
string(int sz) { p = new char[size=sz]; }
~string() { delete p; }
void operator=(string&)
};
void string::operator=(string& a) {
if (this == &a) return; // остерегаться s=s; delete p; p=new
char[size=a.size]; strcpy(p,a.p);
}
Это определение string гарантирует,и что предыдущий пример будет
работать как предполагалось. Однако небольшое изменение f() приве-
дет к появлению той же проблемы в новом облике:
void f() {
string s1(10); s2 = s1;
}
Теперь создается только одна строка, а уничтожается две. К неини-
циализированному объекту определяемая пользователем операция
присваивания не применяется. Беглый взгляд на string::operator=()
объясняет, почему было бы неразумно так делать: указатель p будет
содержать неопределенное и совершенно случайное значение. Часто
операция присваивания полагается на то, что ее аргументы инициали-
зированы. Для такой инициализации, как здесь, это не так по опреде-
лению. Следовательно, нужно определить похожую, но другую, функцию,
чтобы обрабатывать инициализацию:
struct string { char* p; int size; // размер вектора, на который
указывает p
string(int sz) { p = new char[size=sz]; }
~string() { delete p; }
void operator=(string&); string(string&);
};
void string::string(string& a) {
p=new char[size=a.size]; strcpy(p,a.p);
}
Для типа X инициализацию тем же типом X обрабатывает конструктор
X(X&). Нельзя не подчеркнуть еще раз, что присваивание и инициали-
зация - разные действия. Это особенно существенно при описании
деструктора. Если класс X имеет конструктор X(X&), выполняющий нет-
ривиальную работу вроде освобождения памяти, то скорее всего потре-
буется полный комплект функций, чтобы полностью избежать побитового
копирования объектов:
class X {
// ...
X(something); // конструктор: создает объект X(&X); //
конструктор: копирует в инициализации operator=(X&); //
присваивание: чистит и копирует ~X(); // деструктор: чистит
};
Есть еще два случая, когда объект копируется: как параметр функ-
ции и как возвращаемое значение. Когда передается параметр, инициа-
лизируется неинициализированная до этого переменная - формальный
параметр. Семантика идентична семантике инициализации. То же самое
происходит при возврате из функции, хотя это менее очевидно. В обо-
их случаях будет применен X(X&), если он определен:
string g(string arg) {
return arg;
}
main() {
string s = "asdf"; s = g(s);
}
Ясно, что после вызова g() значение s обязано быть "asdf". Копи-
рование значения s в параметр arg сложности не представляет: для
этого надо взывать string(string&). Для взятия копии этого значения
из g() требуется еще один вызов string(string&); на этот раз иници-
ализируемой является временная переменная, которая затем присваива-
ется s. Такие переменные, естественно, уничтожаются как положено с
помощью string::~string() при первой возможности.
6.7 Индексирование
Чтобы задать смысл индексов для объектов класса, используется
функция operator[]. Второй параметр (индекс) функции operator[] мо-
жет быть любого типа. Это позволяет определять ассоциативные масси-
вы и т.п. В качестве примера давайте перепишем пример из #2.3.10,
где при написании небольшой программы для подсчета числа вхождений
слов в файле применялся ассоциативный массив. Там использовалась
функция. Здесь определяется надлежащий тип ассоциативного массива:
struct pair { char* name; int val;
};
class assoc { pair* vec; int max; int free;
public:
assoc(int); int& operator[](char*); void print_all();
};
В assoc хранится вектор пар pair длины max. Индекс первого не-
использованного элемента вектора находится в free. Конструктор выг-
лядит так:
assoc::assoc(int s) {
max = (s<16) ? s : 16; free = 0; vec = new pair[max];
}
При реализации применяется все тот же простой и неэффективный ме-
тод поиска, что использовался в #2.3.10. Однако при переполнении
assoc увеличивается:
#include
int assoc::operator[](char* p)
/*
работа с множеством пар "pair":
поиск p, возврат ссылки на целую часть его "pair" делает но-
вую "pair", если p не встречалось
*/
{ register pair* pp;
for (pp=&vec[free-1]; vec<=pp; pp--) if
(strcmp(p,pp->name)==0) return pp->val;
if (free==max) { // переполнение: вектор увеличивается pair*
nvec = new pair[max*2]; for ( int i=0; iname = new char[strlen(p)+1];
strcpy(pp->name,p); pp->val = 0; // начальное значение: 0
return pp->val;
}
Поскольку представление assoc скрыто, нам нужен способ его печа-
ти. В следующем разделе будет показано, как определить подходящий
итератор, а здесь мы используем простую функцию печати:
vouid assoc::print_all() {
for (int i = 0; i>buf) vec[buf]++;
vec.print_all();
}
6.8 Вызов Функции
Вызов функции, то есть запись выражение(список_выражений), можно
проинтерпретировать как бинарную операцию, и операцию вызова можно
перегружать так же, как и другие операции. Список параметров функ-
ции operator() вычисляется и проверяется в соответствие с обычными
правилами передачи параметров. Перегружающая функция может ока-
заться полезной главным образом для определения типов с единствен-
ной операцией и для типов, у которых одна операция настолько преоб-
ладает, что другие в большинстве ситуаций можно не принимать во
внимание.
Для типа ассоциативного массива assoc мы не определили итератор.
Это можно сделать, определив класс assoc_iterator, работа которого
состоит в том, чтобы в определенном порядке поставлять элементы из
assoc. Итератору нужен доступ к данным, которые хранятся в assoc,
поэтому он сделан другом:
class assoc { friend class assoc_iterator;
pair* vec; int max; int free;
public:
assoc(int); int& operator[](char*);
};
Итератор определяется как
class assoc_iterator{ assoc* cs; // текущий массив assoc int i;
// текущий индекс
public:
assoc_iterator(assoc& s) { cs = &s; i = 0; } pair*
operator()()
{ return (ifree)? &cs->vec[i++] : 0; }
};
Надо инициализировать assoc_iterator для массива assoc, после че-
го он будет возвращать указатель на новую pair из этого массива
всякий раз, когда его будут активизировать операцией (). По дости-
жении конца массива он возвращает 0:
main() // считает вхождения каждого слова во вводе {
const MAX = 256; // больше самого большого слова char
buf[MAX]; assoc vec(512); while (cin>>buf) vec[buf]++;
assoc_iterator next(vec); pair* p; while ( p = next() )
cout << p->name << ": " << p->val << "\n";
}
Итераторный тип вроде этого имеет преимущество перед набором
функций, которые выполняют ту же работу: у него есть собственные
закрытые данные для хранения хода итерации. К тому же обычно су-
щественно, чтобы одновременно могли работать много итераторов этого
типа.
Конечно, такое применение объектов для представления итераторов
никак особенно с перегрузкой операций не связано. Многие любят
использовать итераторы с такими операциями, как first(), next() и
last() (первый, следующий и последний).
6.9 Класс String
Вот довольно реалистичный пример класса строк string. В нем про-
изводится учет ссылок на строку с целью минимизировать копирование
и в качестве констант применяются стандартные символьные строки
С++.
#include
#include
class string { struct srep {
char* s; // указатель на данные int n; // счетчик ссылок
};
srep *p;
public:
string(char *); // string x = "abc" string(); // string x;
string(string &); // string x = string ... string&
operator=(char *); string& operator=(string &); ~string();
char& operator[](int i);
friend ostream& operator<<(ostream&, string&); friend
istream& operator>>(istream&, string&);
friend int operator==(string& x, char* s) {return
strcmp(x.p->s, s) == 0; }
friend int operator==(string& x, string& y) {return
strcmp(x.p->s, y.p->s) == 0; }
friend int operator!=(string& x, char* s) {return
strcmp(x.p->s, s) != 0; }
friend int operator!=(string& x, string& y) {return
strcmp(x.p->s, y.p->s) != 0; }
};
Конструкторы и деструкторы просты (как обычно):
string::string() {
p = new srep; p->s = 0; p->n = 1;
}
string::string(char* s) {
p = new srep; p->s = new char[ strlen(s)+1 ]; strcpy(p->s,
s); p->n = 1;
}
string::string(string& x) {
x.p->n++; p = x.p;
}
string::~string() {
if (--p->n == 0) { delete p->s; delete p;
}
}
Как обычно, операции присваивания очень похожи на конструкторы.
Они должны обрабатывать очистку своего первого (левого) операнда:
string& string::operator=(char* s) {
if (p->n > 1) { // разъединить себя p->n--; p = new srep;
}
else if (p->n == 1) delete p->s;
p->s = new char[ strlen(s)+1 ]; strcpy(p->s, s); p->n = 1;
return *this;
}
Благоразумно обеспечить, чтобы присваивание объекта самому себе
работало правильно:
string& string::operator=(string& x) {
x.p->n++; if (--p->n == 0) {
delete p->s; delete p;
}
p = x.p; return *this;
}
Операция вывода задумана так, чтобы продемонстрировать применение
учета ссылок. Она повторяет каждую вводимую строку (с помощью опе-
рации <<, которая определяется позднее):
ostream& operator<<(ostream& s, string& x) {
return s << x.p->s << " [" << x.p->n << "]\n";
}
Операция ввода использует стандартную функцию ввода символьной
строки (#8.4.1).
istream& operator>>(istream& s, string& x) {
char buf[256]; s >> buf; x = buf; cout << "echo: " << x <<
"\n"; return s;
}
Для доступа к отдельным символам предоставлена операция индекси-
рования. Осуществляется проверка индекса:
void error(char* p) {
cerr << p << "\n"; exit(1);
}
char& string::operator[](int i) {
if (i<0 || strlen(p->s)s[i];
}
Головная программа просто немного опробует действия над строками.
Она читает слова со ввода в строки, а потом эти строки печатает.
Она продолжает это делать до тех пор, пока не распознает строку
done, которая завершает сохранение слов в строках, или пока не
встретит конец файла. После этого она печатает строки в обратном
порядке и завершается.
main() {
string x[100]; int n;
cout << "отсюда начнем\n"; for (n = 0; cin>>x[n]; n++) {
string y; if (n==100) error("слишком много строк"); cout
<< (y = x[n]); if (y=="done") break;
}
cout << "отсюда мы пройдем обратно\n"; for (int i=n-1; 0<=i;
i--) cout << x[i];
}
6.10 Друзья и Члены
Теперь, наконец, можно обсудить, в каких случаях для доступа к
закрытой части определяемого пользователем типа использовать члены,
а в каких - друзей. Некоторые операции должны быть членами:
конструкторы, деструкторы и виртуальные функции (см. следующую гла-
ву), но обычно это зависит от выбора.
Рассмотрим простой класс 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. Если только не применяется перегрузка, имена
членов обычно короче имен друзей.
6.11 Предостережение
Как и большую часть возможностей в языках программирования, пе-
регрузку операций можно использовать как правильно, так и непра-
вильно. В частности, можно так воспользоваться возможностью опреде-
лять новые значения старых операций, что они станут почти совсем
непостижимы. Представьте, например, с какими сложностями столкнется
человек, читающий программу, в которой операция + была переопреде-
лена для обозначения вычитания.
Изложенный аппарат должен уберечь программиста/читателя от худших
крайностей применения перегрузки, потому что программист предохра-
нен от изменения значения операций для основных типов данных вроде
int, а также потому, что синтаксис выражений и приоритеты операций
сохраняются.
Может быть, разумно применять перегрузку операций главным образом
так, чтобы подражать общепринятому применению операций. В тех слу-
чаях, когда нет общепринятой операции или имеющееся в С++ множество
операций не подходит для имитации общепринятого применения, можно
использовать запись вызова функции.
6.12 Упражнения
1. (*2) Определите итератор для класса string. Определите опера-
цию конкатенации + и операцию "добавить в конец" +=. Какие еще
операции над string вы хотели бы иметь возможность осущест-
влять?
2. (*1.5) Задайте с помощью перегрузки () операцию выделения
подстроки для класса строк.
3. (*3) Постройте класс string так, чтобы операция выделения
подстроки могла использоваться в левой части присваивания. На-
пишите сначала версию, в которой строка может присваиваться
подстроке той же длины, а потом версию, где эти длины могут
быть разными.
4. (*2) Постройте класс string так, чтобы для присваивания, пере-
дачи параметров и т.п. он имел семантику по значению, то есть,
когда копируется строковое представление, а не просто управля-
ющая структура данных класса sring.
5. (*3) Модифицируйте класс string из предыдущего примера таким
образом, чтобы строка копировалась только когда это необходи-
мо. То есть, храните совместно используемое представление двух
строк, пока одна из этих строк не будет изменена. Не пытайтесь
одновременно с этим иметь операцию выделения подстроки, кото-
рая может использоваться в левой части.
6. (*4) Разработайте класс string с семантикой по значению, копи-
рованием с задержкой и операцией подстроки, которая может сто-
ять в левой части.
7. (*2) Какие преобразования используются в каждом выражении сле-
дующей программы:
struct X { int i; X(int); operator+(int);
};
struct Y { int i; Y(X); operator+(X); operator int();
};
X operator* (X,Y); int f(X);
X x = 1; Y y = x; int i = 2;
main() {
i + 10; y + 10; y + 10 * y; x + y + i; x * x + i; f(7);
f(y); y + y; 106 + y;
}
Определите X и Y так, чтобы они оба были целыми типами. Изме-
ните программу так, чтобы она работала и печатала значения
всех допустимых выражений.
8. (*2) Определите класс INT, который ведет себя в точности как
int. Подсказка: определите INT::operator int().
9. (*1) Определите класс RINT, который ведет себя в точности как
int за исключением того, что единственные возможные операции -
это + (унарный и бинарный), - (унарный и бинарный), *, /, %.
Подсказка: не определяйте INT::operator int().
10. (*3) Определите класс LINT, ведущий себя как RINT, за исклю-
чением того, что имеет точность не менее 64 бит.
11. (*4) Определите класс, который реализует арифметику с произ-
вольной точностью. Подсказка: вам надо управлять памятью ана-
логично тому, как это делалось для класса string.
12. (*2) Напишите программу, доведенную до нечитаемого состояния
с помощью макросов и перегрузки операций. Вот идея: определите
для INT + так, чтобы он означал -, и наоборот, а потом с по-
мощью макроопределения определите int как INT. Переопределение
часто употребляемых функций, использование параметров ссылоч-
ного типа и несколько вводящих в заблуждение комментариев по-
могут устроить полную неразбериху.
13. (*3) Поменяйтесь со своим другом программами, которые у вас
получились в предыдущем упражнении. Не запуская ее попытайтесь
понять, что делает программа вашего друга. После выполнения
этого упражнения вы будете знать, чего следует избегать.
14. (*2) Перепишите примеры с comlpex (#6.3.1), tiny (#6.3.2) и
string (#6.9) не используя friend функций. Используйте только
функции члены. Протестируйте каждую из новых версий. Сравните
их с версиями, в которых используются функции друзья. Еще раз
посмотрите Упражнение 5.3.
15. (*2) Определите тип vec4 как вектор их четырех float. Опреде-
лите operator[] для vec4. Определите операции +, -, *, /, =,
+=, -=, *=, /= для сочетаний векторов и чисел с плавающей точ-
кой.
16. (*3) Определите класс mat4 как вектор из четырех vec4. Опре-
делите для mat4 operator[], возвращающий vec4. Определите для
этого типа обычные операции над матрицами. Определите функцию,
выполняющие для mat4 исключение Гаусса.
17. (*2) Определите класс vector, аналогичный vec4, но с длиной,
которая задается как параметр конструктора
vector::vector(int).
18. (*3) Определите класс matrix, аналогичный mat4, но с размер-
ностью, задаваемой параметрами конструктора
matrix::matrix(int,int).
|
|