всегда, когда вносится какое-либо изменение в описание класса. В
идеале такое изменение никак не должно отражаться на пользователях
класса. К сожалению, это не так. Для размещения переменной
классового типа компилятор должен знать размер объекта класса. Если
размер этих объектов меняется, то файлы, в которых класс
используется, нужно компилировать заново. Можно написать такую
программу (и она уже написана), которая определяет множество
(минимальное) файлов, которое необходимо компилировать заново после
изменения описания класса, но пока что широкого распространения она
не получила.
Почему, можете вы спросить, C++ разработан так, что после
изменения закрытой части необходима новая компиляция пользователей
класса? И действительно, почему вообще закрытая часть должна быть
представлена в описании класса? Другими словами, раз пользователям
класса не разрешается обращаться к закрытым членам, почему их
описания должны приводиться в заголовочных файлах, которые, как
предполагается, пользователь читает? Ответ - эффективность. Во
многих системах и процесс компиляции, и последовательность
операций, реализующих вызов функции, проще, когда размер
автоматических объектов (объектов в стеке) известен во время
компиляции.
Этой сложности можно избежать, представив каждый объект класса
как указатель на "настоящий" объект. Так как все эти указатели
будут иметь одинаковый размер, а размещение "настоящих" объектов
можно определить в файле, где доступна закрытая часть, то это может
решить проблему. Однако решение подразумевает дополнительные ссылки
по памяти при обращении к членам класса, а также, что еще хуже,
каждый вызов функции с автоматическим объектом класса включает по
меньшей мере один вызов программ выделения и освобождения свободной
памяти. Это сделало бы также невозможным реализацию inline-функций
- стр 153 -
членов, которые обращаются к данным закрытой части. Более того,
такое изменение сделает невозможным совместную компоновку C и C++
программ (поскольку C компилятор обрабатывает struct не так, как
это будет делать C++ компилятор). Для C++ это было сочтено
неприемлемым.
5.3.2 Законченный Класс
Программирование без скрытия данных (с применением структур)
требует меньшей продуманности, чем программирование со скрытием
данных (с использованием классов). Структуру можно определить не
слишком задумываясь о том, как ее предполагается использовать. А
когда определяется класс, все внимание сосредотачивается на
обеспечении нового типа полным множеством операций; это важное
смещение акцента. Время, потраченное на разработку нового типа,
обычно многократно окупается при разработке и тестировании
программы.
Вот пример законченного типа intset, который реализует понятие
"множество целых":
class intset {
int cursize, maxsize;
int *x;
public:
intset(int m, int n); // самое большее, m int'ов в 1..n
~intset();
int member(int t); // является ли t элементом?
void insert(int t); // добавить "t" в множество
void iterate(int& i) { i = 0; }
int ok(int& i) { return i
void error(char* s)
{
cerr << "set: " << s << "\n";
exit(1);
}
Класс intset используется в main(), которая предполагает два
целых параметра. Первый параметр задает число случайных чисел,
которые нужно сгенерировать. Второй параметр указывает диапазон, в
котором должны лежать случайные целые:
- стр 154 -
main(int argc, char* argv[])
{
if (argc != 3) error("ожидается два параметра");
int count = 0;
int m = atoi(argv[1]); // число элементов множества
int n = atoi(argv[2]); // в диапазоне 1..n
intset s(m,n);
while (count maxsize) error("слищком много элементов");
int i = cursize-1;
x[i] = t;
while (i>0 && x[i-1]>x[i]) {
int t = x[i]; // переставить x[i] и [i-1]
x[i] = x[i-1];
x[i-1] = t;
i--;
}
}
Для нахождения членов используется просто двоичный поиск:
int intset::member(int t) // двоичный поиск
{
int l = 0;
int u = cursize-1;
while (l <= u) {
int m = (l+u)/2;
if (t < x[m])
u = m-1;
else if (t > x[m])
l = m+1;
else
return 1; // найдено
}
return 0; // не найдено
}
И, наконец, нам нужно обеспечить множество операций, чтобы
пользователь мог осуществлять цикл по множеству в некотором
порядке, поскольку представление intset от пользователя скрыто.
Множество внутренней упорядоченности не имеет, поэтому мы не можем
просто дать возможность обращаться к вектору (завтра я, наверное,
реализую intset по-другому, в виде связанного списка).
Дается три функции: iterate() для инициализации итерации, ok()
для проверки, есть ли следующий элемент, и next() для того, чтобы
взять следующий элемент:
class intset {
// ...
void iterate(int& i) { i = 0; }
int ok(int& i) { return iiterate(var);
while (set->ok(var)) cout << set->next(var) << "\n";
}
Другой способ задать итератор приводится в #6.8.
Глава 6
Перегрузка Операций
Здесь водятся Драконы!
- старинная карта
В этой главе описывается аппарат, предоставляемый в C++ для
перегрузки операций. Программист может определять смысл операций
при их применении к объектам определенного класса. Кроме
арифметических, можно определять еще и логические операции,
операции сравнения, вызова () и индексирования [], а также можно
переопределять присваивание и инициализацию. Можно определить явное
и неявное преобразование между определяемыми пользователем и
основными типами. Показано, как определить класс, объект которого
не может быть никак иначе скопирован или уничтожен кроме как
специальными определенными пользователем функциями.
6.1 Введение
Часто программы работают с объектами, которые фвляются
конкретными представлениями абстрактных понятий. Например, тип
данных int в C++ вместе с операциями +, -, *, / и т.д.
предоставляет реализацию (ограниченную) математического понятия
целых чисел. Такие понятия обычно включают в себя множество
операций, которые кратко, удобно и привычно представляют основные
действия над объектами. К сожалению, язык программирования может
непосредственно поддерживать лишь очень малое число таких понятий.
Например, такие понятия, как комплексная арифметика, матричная
алгебра, логические сигналы и строки не получили прямой поддержки в
C++. Классы дают средство спецификации в C++ представления
неэлементарных объектов вместе с множеством действий, которые могут
над этими объектами выполняться. Иногда определение того, как
действуют операции на объекты классов, позволяет программисту
обеспечить более общепринятую и удобную запись для манипуляции
объектами классов, чем та, которую можно достичь используя лишь
основную функциональную запись. Например:
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). Теперь есть возможность приблизить общепринятую
интерпретацию комплексных выражений. Например:
- стр 177 -
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 оба инициализатора
являются синонимиами.
- стр 178 -
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 Предопределенные Значения Операций