Страница:
параметров, что и все остальные функции ($$4.6.6). Пока конструкторы
достаточно различаются по типам своих параметров, транслятор
способен правильно выбрать конструктор:
date today(4);
date july4("July 4, 1983");
date guy("5 Nov");
date now; // инициализация стандартным значением
Размножение конструкторов в примере c date типично. При разработке
класса всегда есть соблазн добавить еще одну возможность, - а вдруг
она кому-нибудь пригодится. Чтобы определить действительно нужные
возможности, надо поразмышлять, но зато в результате, как правило,
получается более компактная и понятная программа. Сократить число
сходных функций можно с помощью стандартного значения параметра.
В примере с date для каждого параметра можно задать стандартное
значение, что означает: "взять значение из текущей даты".
class date {
int month, day, year;
public:
// ...
date(int d =0, int m =0, y=0);
// ...
};
date::date(int d, int m, int y)
{
day = d ? d : today.day;
month = m ? m : today.month;
year = y ? y : today.year;
// проверка правильности даты
// ...
}
Когда используется стандартное значение параметра, оно должно
отличаться от всех допустимых значений параметра. В случае месяца и
дня очевидно, что при значении нуль - это так, но неочевидно,
что нуль подходит для значения года. К счастью, в европейском
календаре нет нулевого года, т.к. сразу после 1 г. до р.х.
(year==-1) идет 1 г. р.х. (year==1). Однако для обычной программы
это, возможно, слишком тонкий момент.
Объект класса без конструктора может инициализироваться
присваиванием ему другого объекта этого же класса. Это незапрещено и
в том случае, когда конструкторы описаны:
date d = today; // инициализация присваиванием
На самом деле, имеется стандартный конструктор копирования,
определенный как поэлементное копирование объектов одного класса.
Если такой конструктор для класса X не нужен, можно переопределить
его как конструктор копирования X::X(const X&). Подробнее поговорим
об этом в $$7.6.
Пользовательские типы чаще имеют, чем не имеют, конструкторы, которые
проводят надлежащую инициализацию. Для многих типов требуется и
обратная операция - деструктор, гарантирующая правильное удаление
объектов этого типа. Деструктор класса X обозначается ~X ("дополнение
конструктора"). В частности, для многих классов используется
свободная память (см. $$3.2.6), выделяемая конструктором и
освобождаемая деструктором. Вот, например, традиционное определение
типа стек, из которого для краткости полностью выброшена обработка
ошибок:
class char_stack {
int size;
char* top;
char* s;
public:
char_stack(int sz) { top=s=new char[size=sz]; }
~char_stack() { delete[] s; } // деструктор
void push(char c) { *top++ = c; }
void pop() { return *--top; }
};
Когда объект типа char_stack выходит из текущей области видимости,
вызывается деструктор:
void f()
{
char_stack s1(100);
char_stack s2(200);
s1.push('a');
s2.push(s1.pop());
char ch = s2.pop();
cout << ch << '\n';
}
Когда начинает выполняться f(), вызывается конструктор char_stack,
который размещает массив из 100 символов s1 и массив из 200
символов s2. При возврате из f() память, которая была занята обоими
массивами, будет освобождена.
Программирование с классами предполагает, что в программе появится
множество маленьких функций. По сути, всюду, где в программе с
традиционной организацией стояло бы обычное обращение к структуре
данных, используется функция. То, что было соглашением, стало
стандартом, проверяемым транслятором. В результате программа
может стать крайне неэффективной. Хотя вызов функции в C++
и не столь дорогостоящая операция по сравнению с другими
языками, все-таки цена ее много выше, чем у пары обращений к памяти,
составляющих тело тривиальной функции.
Преодолеть эту трудность помогают функции-подстановки (inline).
Если в описании класса функция-член определена, а не только описана,
то она считается подстановкой. Это значит, например, что при
трансляции функций, использующих char_stack из предыдущего примера,
не будет использоваться никаких операций вызова функций, кроме
реализации операций вывода! Другими словами, при разработке такого
класса не нужно принимать во внимание затраты на вызов функций.
Любое, даже самое маленькое действие, можно смело определять как
функцию без потери эффективности. Это замечание
снимает наиболее часто приводимый довод в пользу общих членов
данных.
Функцию-член можно описать со спецификацией inline и вне описания
класса:
class char_stack {
int size;
char* top;
char* s;
public:
char pop();
// ...
};
inline char char_stack::pop()
{
return *--top;
}
Отметим, что недопустимо описывать разные определения функции-члена,
являющейся подстановкой, в различных исходных файлах ($$R.7.1.2).
Это нарушило бы понятие о классе как о цельном типе.
Что представляет собой хороший класс? Это нечто, обладающее хорошо
определенным множеством операций. Нечто, рассматриваемое как
"черный ящик", управлять которым можно только посредством этих
операций. Нечто, чье фактическое представление можно изменить любым
мыслимым способом, но не изменяя при этом способа использования
операций. Нечто, что может потребоваться в нескольких экземплярах.
Очевидные примеры хороших классов дают контейнеры разных видов:
таблицы, множества, списки, вектора, словари и т.д. Такой
класс имеет операцию занесения в контейнер. Обычно имеется и
операция проверки: был ли данный член занесен в контейнер?
Могут быть операции упорядочивания всех членов и просмотра их
в определенном порядке. Наконец, может быть операция удаления
члена. Обычно контейнерные классы имеют конструкторы и деструкторы.
Пока описание общей части класса и функций-членов остается неизменным,
можно, не влияя на пользователей класса, менять его реализацию.
В подтверждение этого рассмотрим таблицу имен из программы
калькулятора, приведенной в главе 3. Структура ее такова:
struct name {
char* string;
name* next;
double value;
};
А вот вариант класса table (таблица имен):
// файл table.h
class table {
name* tbl;
public:
table() { tbl = 0; }
name* look(char*, int = 0);
name* insert(char* s) { return look(s,1); }
};
Эта таблица отличается от определенной в главе 3 тем, что это
настоящий тип. Можно описать несколько таблиц, завести указатель
на таблицу и т.д. Например:
#include "table.h"
table globals;
table keywords;
table* locals;
main()
{
locals = new table;
// ...
}
Приведем реализацию функции table::look(), в которой используется
линейный поиск в списке имен таблицы:
#include <string.h>
name* table::look(char* p, int ins)
{
for (name* n = tbl; n; n=n->next)
if (strcmp(p,n->string) == 0) return n;
if (ins == 0) error("имя не найдено");
name* nn = new name;
nn->string = new char[strlen(p)+1];
strcpy(nn->string,p);
nn->value = 1;
nn->next = tbl;
tbl = nn;
return nn;
}
Теперь усовершенствуем класс table так, чтобы поиск имени шел
по ключу (хэш-функции от имени), как это и было сделано в примере
с калькулятором. Сделать это труднее, если соблюдать ограничение,
требующее, чтобы не все программы, использующие приведенную версию
класса table, надо было изменять:
class table {
name** tbl;
int size;
public:
table(int sz = 15);
~table();
name* look(char*, int = 0);
name* insert(char* s) { return look(s,1); }
};
Изменения в структуре данных и конструкторе произошли потому,
что для хэширования таблица должна иметь определенный размер.
Задание конструктора со стандартным значением параметра гарантирует,
что старые программы, в которых не использовался размер таблицы,
останутся верными. Стандартные значения параметров полезны
в таких случаях, когда нужно изменить класс, не влияя на программы
пользователей класса. Теперь конструктор и деструктор создают и
уничтожают хэшированные таблицы:
table::table(int sz)
{
if (sz < 0) error("размер таблицы отрицателен");
tbl = new name*[size = sz];
for ( int i = 0; i<sz; i++) tbl[i] = 0;
}
table::~table()
{
for (int i = 0; i<size; i++) {
name* nx;
for (name* n = tbl[i]; n; n=nx) {
nx = n->next;
delete n->string;
delete n;
}
}
delete tbl;
}
Описав деструктор для класса name, можно получить более ясный и
простой вариант table::~table(). Функция поиска практически
совпадает с приведенной в примере калькулятора ($$3.13):
name* table::look(const char* p, int ins)
{
int ii = 0;
char* pp = p;
while (*pp) ii = ii<<1 ^ *pp++;
if (ii < 0) ii = -ii;
ii %= size;
for (name* n=tbl[ii]; n; n=n->next)
if (strcmp(p,n->string) == 0) return n;
name* nn = new name;
nn->string = new char[strlen(p)+1];
strcpy(nn->string,p);
nn->value = 1;
nn->next = tbl[ii];
tbl[ii] = nn;
return nn;
}
Очевидно, что функции-члены класса должны перетранслироваться всякий
раз, когда в описание класса вносится какое-либо изменение. В идеале
такое изменение никак не должно отражаться на пользователях класса.
К сожалению, обычно бывает не так. Для размещения переменной, имеющей
тип класса, транслятор должен знать размер объекта класса. Если
размер объекта изменится, нужно перетранслировать файлы, в которых
использовался класс. Можно написать системную программу (и она даже
уже написана), которая будет определять минимальное множество файлов,
подлежащих перетрансляции после изменения класса. Но такая программа
еще не получила широкого распространения.
Возможен вопрос: почему С++ был спроектирован таким образом,
что после изменения частной части класса требуется перетрансляция
программ пользователя? Почему вообще частная часть класса
присутствует в описании класса? Иными словами, почему описания
частных членов присутствуют в заголовочных файлах, доступных
пользователю, если все равно недоступны для него в программе?
Ответ один - эффективность. Во многих системах программирования
процесс трансляции и последовательность команд, производящая
вызов функции, будет проще, если размер автоматических (т.е.
размещаемых в стеке) объектов известен на стадии трансляции.
Можно не знать определения всего класса, если представлять каждый
объект как указатель на "настоящий" объект. Это позволяет решить
задачу, поскольку все указатели будут иметь одинаковый размер, а
размещение настоящих объектов будет проводиться только в одном файле,
в котором доступны частные части классов. Однако, такое решение
приводит к дополнительному расходу памяти на каждый объект и
дополнительному обращению к памяти при каждом использовании члена.
Еще хуже, что каждый вызов функции с автоматическим объектом
класса требует вызовов функций выделения и освобождения памяти.
К тому же становится невозможной реализация подстановкой
функций-членов, работающих с частными членами класса. Наконец,
такое изменение сделает невозможным связывание программ на С++ и на
С, поскольку транслятор С будет по другому обрабатывать структуры
(struct). Поэтому такое решение было сочтено неприемлемым для С++.
С другой стороны, С++ предоставляет средство для создания
абстрактных типов, в которых связь между интерфейсом пользователя
и реализацией довольно слабая. В главе 6 вводятся производные
классы и описываются абстрактные базовые классы, а в $$13.3 поясняется,
как с помощью этих средств реализовать абстрактные типы. Цель этого -
дать возможность определять пользовательские типы столь же эффективные
и конкретные, как и стандартные, и дать основные средства определения
более гибких вариантов типов, которые могут оказаться и не столь
эффективными.
Программирование без упрятывания данных (в расчете на структуры)
требует меньшего предварительного обдумывания задачи, чем
программирование с упрятыванием данных (в расчете на классы).
Структуру можно определить не очень задумываясь о том, как ее
будут использовать. Когда определяется класс, внимание концентрируется
на том, чтобы обеспечить для нового типа полный набор операций.
Это важное смещение акцента в проектировании программ. Обычно
время, затраченное на разработку нового типа, многократно окупается
в процессе отладки и развития программы.
Вот пример законченного определения типа intset, представляющего
понятие "множество целых":
class intset {
int cursize, maxsize;
int *x;
public:
intset(int m, int n); // не более m целых из 1..n
~intset();
int member(int t) const; // является ли t членом?
void insert(int t); // добавить к множеству t
void start(int& i) const { i = 0; }
void ok(int& i) const { return i<cursize; }
void next(int& i) const { return x[i++]; }
};
Для проверки этого класса вначале создадим, а затем распечатаем
множество случайных целых чисел. Это простое множество целых
можно использовать для проверки, есть ли повторения в их
последовательности. Но для большинства задач нужен, конечно,
более развитый тип множества. Как всегда возможны ошибки, поэтому
нужна функция:
#include <iostream.h>
void error(const char *s)
{
cerr << "set: " << s << '\n';
exit(1);
}
Класс intset используется в функции main(), для которой должно
быть задано два параметра: первый определяет число создаваемых
случайных чисел, а второй - диапазон их значений:
int 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<m) {
int t = randint(n);
if (s.member(t)==0) {
s.insert(t);
count++;
}
}
print_in_order(&s);
}
Значение счетчика параметров программы argc равно 3, хотя
программа имеет только два параметра. Дело в том, что в argv[0]
всегда передается дополнительный параметр, содержащий имя программы.
Функция
extern "C" int atoi(const char*)
является стандартной библиотечной функцией, преобразующей целое из
строкового представления во внутреннюю двоичную форму. Как обычно,
если вы не хотите иметь такое описание в своей программе, то вам
надо включить в нее соответствующий заголовочный файл, содержащий
описания стандартных библиотечных функций. Случайные числа
генерируются с помощью стандартной функции rand:
extern "C" int rand(); // будьте осторожны:
// числа не совсем случайные
int randint(int u) // диапазон 1..u
{
int r = rand();
if (r < 0) r = -r;
return 1 + r%u;
}
Подробности реализации класса мало интересны для пользователя,
но в любом случае будут использоваться функции-члены.
Конструктор размещает массив целых с размером, равным заданному
максимальному размеру множества, а деструктор удаляет этот массив:
intset::intset(int m, int n) // не более m целых в 1..n
{
if (m<1 || n<m) error("недопустимый размер intset");
cursize = 0;
maxsize = m;
x = new int[maxsize];
}
intset::~intset()
{
delete x;
}
Целые добавляются таким образом, что они хранятся во множестве
в возрастающем порядке:
void intset::insert(int t)
{
if (++cursize > maxsize) error("слишком много элементов");
int i = cursize-1;
x[i] = t;
while (i>0 && x[i-1]>x[i]) {
int t = x[i]; // поменять местами x[i] и x[i-1]
x[i] = x[i-1];
x[i-1] = t;
i--;
}
}
Чтобы найти элемент, используется простой двоичный поиск:
int intset::member(int t) const // двоичный поиск
{
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 будет реализовано в виде связанного списка?).
Пользователь получает три функции: start() - для инициализации
итерации, ok() - для проверки, есть ли следующий элемент, и next() -
для получения следующего элемента:
class intset {
// ...
void start(int& i) const { i = 0; }
int ok(int& i) const { return i<cursize; }
int next(int& i) const { return x[i++]; }
};
Чтобы обеспечить совместную работу этих трех операций, надо запоминать
тот элемент, на котором остановилась итерация. Для этого пользователь
должен задавать целый параметр. Поскольку наше представление множества
упорядоченное, реализация этих операций тривиальна. Теперь
можно определить функцию print_in_order:
void print_in_order(intset* set)
{
int var;
set->sart(var);
while (set->ok(var)) cout << set->next(var) << '\n';
}
Другой способ построения итератора по множеству приведен в $$7.8.
В этом разделе описаны дополнительные свойства класса. Описан
способ обеспечить доступ к частным членам в функциях, не являющихся
членами ($$5.4.1). Описано, как разрешить коллизии имен членов
($$5.4.2) и как сделать описания классов вложенными ($$5.4.3), но
при этом избежать нежелательной вложенности ($$5.4.4). Вводится понятие
статических членов (static), которые используются для представления
операций и данных, относящихся к самому классу, а не к отдельным
его объектам ($$5.4.5). Раздел завершается примером, показывающим,
как можно построить дискриминирующее (надежное) объединение ($$5.4.6).
Пусть определены два класса: vector (вектор) и matrix (матрица).
Каждый из них скрывает свое представление, но дает полный набор операций
для работы с объектами его типа. Допустим, надо определить функцию,
умножающую матрицу на вектор. Для простоты предположим, что
вектор имеет четыре элемента с индексами от 0 до 3, а в матрице
четыре вектора тоже с индексами от 0 до 3. Доступ к элементам
вектора обеспечивается функцией elem(), и аналогичная функция есть
для матрицы. Можно определить глобальную функцию multiply
(умножить) следующим образом:
vector multiply(const matrix& m, const vector& v);
{
vector r;
for (int i = 0; i<3; i++) { // r[i] = m[i] * v;
r.elem(i) = 0;
for (int j = 0; j<3; j++)
r.elem(i) +=m.elem(i,j) * v.elem(j);
}
return r;
}
Это вполне естественное решение, но оно может оказаться очень
неэффективным. При каждом вызове multiply() функция elem() будет
вызываться 4*(1+4*3) раз. Если в elem() проводится настоящий
контроль границ массива, то на такой контроль будет потрачено
значительно больше времени, чем на выполнение самой функции, и в
результате она окажется непригодной для пользователей. С другой
стороны, если elem() есть некий специальный вариант доступа без
контроля, то тем самым мы засоряем интерфейс с вектором и матрицей
особой функцией доступа, которая нужна только для обхода контроля.
Если можно было бы сделать multiply членом обоих классов
vector и matrix, мы могли бы обойтись без контроля индекса при
обращении к элементу матрицы, но в то же время не вводить специальной
функции elem(). Однако, функция не может быть членом двух классов.
Надо иметь в языке возможность предоставлять функции, не являющейся
членом, право доступа к частным членам класса. Функция - не член
класса, - имеющая доступ к его закрытой части, называется другом
этого класса. Функция может стать другом класса, если в его
описании она описана как friend (друг). Например:
class matrix;
class vector {
float v[4];
// ...
friend vector multiply(const matrix&, const vector&);
};
class matrix {
vector v[4];
// ...
friend vector multiply(const matrix&, const vector&);
};
Функция-друг не имеет никаких особенностей, за исключением права
доступа к закрытой части класса. В частности, в такой функции
нельзя использовать указатель this, если только она действительно
не является членом класса. Описание friend является настоящим
описанием. Оно вводит имя функции в область видимости класса,
в котором она была описана, и при этом происходят обычные проверки
на наличие других описаний такого же имени в этой области
видимости. Описание friend может находится как в общей, так и в
частной частях класса, это не имеет значения.
Теперь можно написать функцию multiply, используя элементы
вектора и матрицы непосредственно:
vector multiply(const matrix& m, const vector& v)
{
vector r;
for (int i = 0; i<3; i++) { // r[i] = m[i] * v;
r.v[i] = 0;
for ( int j = 0; j<3; j++)
r.v[i] +=m.v[i][j] * v.v[j];
}
return r;
}
Отметим, что подобно функции-члену дружественная функция
явно описывается в описании класса, с которым дружит. Поэтому она
является неотъемлемой частью интерфейса класса наравне с
функцией-членом.
Функция-член одного класса может быть другом другого класса:
class x {
// ...
void f();
};
class y {
// ...
friend void x::f();
};
Вполне возможно, что все функции одного класса являются друзьями
другого класса. Для этого есть краткая форма записи:
class x {
friend class y;
// ...
};
В результате такого описания все функции-члены y становятся друзьями
класса x.
Иногда полезно делать явное различие между именами членов классов
и прочими именами. Для этого используется операция :: (разрешения
области видимости):
class X {
int m;
public:
int readm() const { return m; }
void setm(int m) { X::m = m; }
};
В функции X::setm() параметр m скрывает член m, поэтому к члену
можно обращаться, только используя уточненное имя X::m. Правый
операнд операции :: должен быть именем класса.
Начинающееся с :: имя должно быть глобальным именем. Это особенно
полезно при использовании таких распространенных имен как read, put,
open, которыми можно обозначать функции-члены, не теряя возможности
обозначать ими же функции, не являющиеся членами.
Например:
class my_file {
// ...
public:
int open(const char*, const char*);
};
int my_file::jpen(const char* name, const char* spec)
{
// ...
if (::open(name,flag)) { // используется open() из UNIX(2)
// ...
}
// ...
}
Описание класса может быть вложенным. Например:
class set {
struct setmem {
int mem;
setmem* next;
setmem(int m, setmem* n) { mem=m; next=n; }
};
setmem* first;
public:
set() { first=0; }
insert(int m) { first = new setmem(m,first); }
// ...
};
Доступность вложенного класса ограничивается областью видимости
лексически объемлющего класса:
setmem m1(1,0); // ошибка: setmem не находится
// в глобальной области видимости
Если только описание вложенного класса не является совсем простым,
то лучше описывать этот класс отдельно, поскольку вложенные описания
могут стать очень запутанными:
class setmem {
friend class set; // доступно только для членов set
int mem;
setmem* next;
setmem(int m, setmem* n) { mem=m; next=n; }
// много других полезных членов
};
class set {
setmem* first;
public:
set() { first=0; }
insert(int m) { first = new setmem(m,first); }
// ...
};
Полезное свойство вложенности - это сокращение числа глобальных имен,
а недостаток его в том, что оно нарушает свободу использования
вложенных типов (см. $$12.3).
Имя класса-члена (вложенного класса) можно использовать вне
описания объемлющего его класса так же, как имя любого другого
члена:
class X {
struct M1 { int m; };
public:
struct M2 { int m; };
M1 f(M2);
};
void f()
{ M1 a; // ошибка: имя `M1' вне области видимости
M2 b; // ошибка: имя `M1' вне области видимости
X::M1 c; // ошибка: X::M1 частный член
X::M2 d; // нормально
}
Отметим, что контроль доступа происходит и для имен вложенных
классов.
В функции-члене область видимости класса начинается после
уточнения X:: и простирается до конца описания функции. Например:
M1 X::f(M2 a) // ошибка: имя `M1' вне области видимости
{ /* ... */ }
X::M1 X::f(M2 a) // нормально
{ /* ... */ }
X::M1 X::f(X::M2 a) // нормально, но третье уточнение X:: излишне
{ /* ... */ }
Класс - это тип, а не некоторое данное, и для каждого объекта
класса создается своя копия членов, представляющих данные. Однако,
наиболее удачная реализация некоторых типов требует, чтобы все
объекты этого типа имели некоторые общие данные. Лучше, если эти
данные можно описать как часть класса. Например, в операционных
системах или при моделировании управления задачами часто нужен
список задач:
class task {
// ...
static task* chain;
// ...
};
Описав член chain как статический, мы получаем гарантию, что
он будет создан в единственном числе, т.е. не будет создаваться
для каждого объекта task. Но он находится в области видимости
класса task, и может быть доступен вне этой области, если только
описан в общей части. В этом случае имя члена должно уточняться
именем класса:
if (task::chain == 0) // какие-то операторы
В функции-члене его можно обозначать просто chain. Использование
статических членов класса может заметно сократить потребность в
глобальных переменных.
Описывая член как статический, мы ограничиваем его область
видимости и делаем его независимым от отдельных объектов его
класса. Это свойство полезно как для функций-членов, так и для
членов, представляющих данные:
class task {
// ...
static task* task_chain;
static void shedule(int);
// ...
};
Но описание статического члена - это только описание, и где-то
в программе должно быть единственное определение для описываемого
объекта или функции, например, такое:
task* task::task_chain = 0;
void task::shedule(int p) { /* ... */ }
Естественно, что и частные члены могут определяться подобным образом.
Отметим, что служебное слово static не нужно и даже нельзя
использовать в определении статического члена класса. Если бы оно
присутствовало, возникла бы неоднозначность: указывает ли оно на то,
что член класса является статическим, или используется для
достаточно различаются по типам своих параметров, транслятор
способен правильно выбрать конструктор:
date today(4);
date july4("July 4, 1983");
date guy("5 Nov");
date now; // инициализация стандартным значением
Размножение конструкторов в примере c date типично. При разработке
класса всегда есть соблазн добавить еще одну возможность, - а вдруг
она кому-нибудь пригодится. Чтобы определить действительно нужные
возможности, надо поразмышлять, но зато в результате, как правило,
получается более компактная и понятная программа. Сократить число
сходных функций можно с помощью стандартного значения параметра.
В примере с date для каждого параметра можно задать стандартное
значение, что означает: "взять значение из текущей даты".
class date {
int month, day, year;
public:
// ...
date(int d =0, int m =0, y=0);
// ...
};
date::date(int d, int m, int y)
{
day = d ? d : today.day;
month = m ? m : today.month;
year = y ? y : today.year;
// проверка правильности даты
// ...
}
Когда используется стандартное значение параметра, оно должно
отличаться от всех допустимых значений параметра. В случае месяца и
дня очевидно, что при значении нуль - это так, но неочевидно,
что нуль подходит для значения года. К счастью, в европейском
календаре нет нулевого года, т.к. сразу после 1 г. до р.х.
(year==-1) идет 1 г. р.х. (year==1). Однако для обычной программы
это, возможно, слишком тонкий момент.
Объект класса без конструктора может инициализироваться
присваиванием ему другого объекта этого же класса. Это незапрещено и
в том случае, когда конструкторы описаны:
date d = today; // инициализация присваиванием
На самом деле, имеется стандартный конструктор копирования,
определенный как поэлементное копирование объектов одного класса.
Если такой конструктор для класса X не нужен, можно переопределить
его как конструктор копирования X::X(const X&). Подробнее поговорим
об этом в $$7.6.
Пользовательские типы чаще имеют, чем не имеют, конструкторы, которые
проводят надлежащую инициализацию. Для многих типов требуется и
обратная операция - деструктор, гарантирующая правильное удаление
объектов этого типа. Деструктор класса X обозначается ~X ("дополнение
конструктора"). В частности, для многих классов используется
свободная память (см. $$3.2.6), выделяемая конструктором и
освобождаемая деструктором. Вот, например, традиционное определение
типа стек, из которого для краткости полностью выброшена обработка
ошибок:
class char_stack {
int size;
char* top;
char* s;
public:
char_stack(int sz) { top=s=new char[size=sz]; }
~char_stack() { delete[] s; } // деструктор
void push(char c) { *top++ = c; }
void pop() { return *--top; }
};
Когда объект типа char_stack выходит из текущей области видимости,
вызывается деструктор:
void f()
{
char_stack s1(100);
char_stack s2(200);
s1.push('a');
s2.push(s1.pop());
char ch = s2.pop();
cout << ch << '\n';
}
Когда начинает выполняться f(), вызывается конструктор char_stack,
который размещает массив из 100 символов s1 и массив из 200
символов s2. При возврате из f() память, которая была занята обоими
массивами, будет освобождена.
Программирование с классами предполагает, что в программе появится
множество маленьких функций. По сути, всюду, где в программе с
традиционной организацией стояло бы обычное обращение к структуре
данных, используется функция. То, что было соглашением, стало
стандартом, проверяемым транслятором. В результате программа
может стать крайне неэффективной. Хотя вызов функции в C++
и не столь дорогостоящая операция по сравнению с другими
языками, все-таки цена ее много выше, чем у пары обращений к памяти,
составляющих тело тривиальной функции.
Преодолеть эту трудность помогают функции-подстановки (inline).
Если в описании класса функция-член определена, а не только описана,
то она считается подстановкой. Это значит, например, что при
трансляции функций, использующих char_stack из предыдущего примера,
не будет использоваться никаких операций вызова функций, кроме
реализации операций вывода! Другими словами, при разработке такого
класса не нужно принимать во внимание затраты на вызов функций.
Любое, даже самое маленькое действие, можно смело определять как
функцию без потери эффективности. Это замечание
снимает наиболее часто приводимый довод в пользу общих членов
данных.
Функцию-член можно описать со спецификацией inline и вне описания
класса:
class char_stack {
int size;
char* top;
char* s;
public:
char pop();
// ...
};
inline char char_stack::pop()
{
return *--top;
}
Отметим, что недопустимо описывать разные определения функции-члена,
являющейся подстановкой, в различных исходных файлах ($$R.7.1.2).
Это нарушило бы понятие о классе как о цельном типе.
Что представляет собой хороший класс? Это нечто, обладающее хорошо
определенным множеством операций. Нечто, рассматриваемое как
"черный ящик", управлять которым можно только посредством этих
операций. Нечто, чье фактическое представление можно изменить любым
мыслимым способом, но не изменяя при этом способа использования
операций. Нечто, что может потребоваться в нескольких экземплярах.
Очевидные примеры хороших классов дают контейнеры разных видов:
таблицы, множества, списки, вектора, словари и т.д. Такой
класс имеет операцию занесения в контейнер. Обычно имеется и
операция проверки: был ли данный член занесен в контейнер?
Могут быть операции упорядочивания всех членов и просмотра их
в определенном порядке. Наконец, может быть операция удаления
члена. Обычно контейнерные классы имеют конструкторы и деструкторы.
Пока описание общей части класса и функций-членов остается неизменным,
можно, не влияя на пользователей класса, менять его реализацию.
В подтверждение этого рассмотрим таблицу имен из программы
калькулятора, приведенной в главе 3. Структура ее такова:
struct name {
char* string;
name* next;
double value;
};
А вот вариант класса table (таблица имен):
// файл table.h
class table {
name* tbl;
public:
table() { tbl = 0; }
name* look(char*, int = 0);
name* insert(char* s) { return look(s,1); }
};
Эта таблица отличается от определенной в главе 3 тем, что это
настоящий тип. Можно описать несколько таблиц, завести указатель
на таблицу и т.д. Например:
#include "table.h"
table globals;
table keywords;
table* locals;
main()
{
locals = new table;
// ...
}
Приведем реализацию функции table::look(), в которой используется
линейный поиск в списке имен таблицы:
#include <string.h>
name* table::look(char* p, int ins)
{
for (name* n = tbl; n; n=n->next)
if (strcmp(p,n->string) == 0) return n;
if (ins == 0) error("имя не найдено");
name* nn = new name;
nn->string = new char[strlen(p)+1];
strcpy(nn->string,p);
nn->value = 1;
nn->next = tbl;
tbl = nn;
return nn;
}
Теперь усовершенствуем класс table так, чтобы поиск имени шел
по ключу (хэш-функции от имени), как это и было сделано в примере
с калькулятором. Сделать это труднее, если соблюдать ограничение,
требующее, чтобы не все программы, использующие приведенную версию
класса table, надо было изменять:
class table {
name** tbl;
int size;
public:
table(int sz = 15);
~table();
name* look(char*, int = 0);
name* insert(char* s) { return look(s,1); }
};
Изменения в структуре данных и конструкторе произошли потому,
что для хэширования таблица должна иметь определенный размер.
Задание конструктора со стандартным значением параметра гарантирует,
что старые программы, в которых не использовался размер таблицы,
останутся верными. Стандартные значения параметров полезны
в таких случаях, когда нужно изменить класс, не влияя на программы
пользователей класса. Теперь конструктор и деструктор создают и
уничтожают хэшированные таблицы:
table::table(int sz)
{
if (sz < 0) error("размер таблицы отрицателен");
tbl = new name*[size = sz];
for ( int i = 0; i<sz; i++) tbl[i] = 0;
}
table::~table()
{
for (int i = 0; i<size; i++) {
name* nx;
for (name* n = tbl[i]; n; n=nx) {
nx = n->next;
delete n->string;
delete n;
}
}
delete tbl;
}
Описав деструктор для класса name, можно получить более ясный и
простой вариант table::~table(). Функция поиска практически
совпадает с приведенной в примере калькулятора ($$3.13):
name* table::look(const char* p, int ins)
{
int ii = 0;
char* pp = p;
while (*pp) ii = ii<<1 ^ *pp++;
if (ii < 0) ii = -ii;
ii %= size;
for (name* n=tbl[ii]; n; n=n->next)
if (strcmp(p,n->string) == 0) return n;
name* nn = new name;
nn->string = new char[strlen(p)+1];
strcpy(nn->string,p);
nn->value = 1;
nn->next = tbl[ii];
tbl[ii] = nn;
return nn;
}
Очевидно, что функции-члены класса должны перетранслироваться всякий
раз, когда в описание класса вносится какое-либо изменение. В идеале
такое изменение никак не должно отражаться на пользователях класса.
К сожалению, обычно бывает не так. Для размещения переменной, имеющей
тип класса, транслятор должен знать размер объекта класса. Если
размер объекта изменится, нужно перетранслировать файлы, в которых
использовался класс. Можно написать системную программу (и она даже
уже написана), которая будет определять минимальное множество файлов,
подлежащих перетрансляции после изменения класса. Но такая программа
еще не получила широкого распространения.
Возможен вопрос: почему С++ был спроектирован таким образом,
что после изменения частной части класса требуется перетрансляция
программ пользователя? Почему вообще частная часть класса
присутствует в описании класса? Иными словами, почему описания
частных членов присутствуют в заголовочных файлах, доступных
пользователю, если все равно недоступны для него в программе?
Ответ один - эффективность. Во многих системах программирования
процесс трансляции и последовательность команд, производящая
вызов функции, будет проще, если размер автоматических (т.е.
размещаемых в стеке) объектов известен на стадии трансляции.
Можно не знать определения всего класса, если представлять каждый
объект как указатель на "настоящий" объект. Это позволяет решить
задачу, поскольку все указатели будут иметь одинаковый размер, а
размещение настоящих объектов будет проводиться только в одном файле,
в котором доступны частные части классов. Однако, такое решение
приводит к дополнительному расходу памяти на каждый объект и
дополнительному обращению к памяти при каждом использовании члена.
Еще хуже, что каждый вызов функции с автоматическим объектом
класса требует вызовов функций выделения и освобождения памяти.
К тому же становится невозможной реализация подстановкой
функций-членов, работающих с частными членами класса. Наконец,
такое изменение сделает невозможным связывание программ на С++ и на
С, поскольку транслятор С будет по другому обрабатывать структуры
(struct). Поэтому такое решение было сочтено неприемлемым для С++.
С другой стороны, С++ предоставляет средство для создания
абстрактных типов, в которых связь между интерфейсом пользователя
и реализацией довольно слабая. В главе 6 вводятся производные
классы и описываются абстрактные базовые классы, а в $$13.3 поясняется,
как с помощью этих средств реализовать абстрактные типы. Цель этого -
дать возможность определять пользовательские типы столь же эффективные
и конкретные, как и стандартные, и дать основные средства определения
более гибких вариантов типов, которые могут оказаться и не столь
эффективными.
Программирование без упрятывания данных (в расчете на структуры)
требует меньшего предварительного обдумывания задачи, чем
программирование с упрятыванием данных (в расчете на классы).
Структуру можно определить не очень задумываясь о том, как ее
будут использовать. Когда определяется класс, внимание концентрируется
на том, чтобы обеспечить для нового типа полный набор операций.
Это важное смещение акцента в проектировании программ. Обычно
время, затраченное на разработку нового типа, многократно окупается
в процессе отладки и развития программы.
Вот пример законченного определения типа intset, представляющего
понятие "множество целых":
class intset {
int cursize, maxsize;
int *x;
public:
intset(int m, int n); // не более m целых из 1..n
~intset();
int member(int t) const; // является ли t членом?
void insert(int t); // добавить к множеству t
void start(int& i) const { i = 0; }
void ok(int& i) const { return i<cursize; }
void next(int& i) const { return x[i++]; }
};
Для проверки этого класса вначале создадим, а затем распечатаем
множество случайных целых чисел. Это простое множество целых
можно использовать для проверки, есть ли повторения в их
последовательности. Но для большинства задач нужен, конечно,
более развитый тип множества. Как всегда возможны ошибки, поэтому
нужна функция:
#include <iostream.h>
void error(const char *s)
{
cerr << "set: " << s << '\n';
exit(1);
}
Класс intset используется в функции main(), для которой должно
быть задано два параметра: первый определяет число создаваемых
случайных чисел, а второй - диапазон их значений:
int 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<m) {
int t = randint(n);
if (s.member(t)==0) {
s.insert(t);
count++;
}
}
print_in_order(&s);
}
Значение счетчика параметров программы argc равно 3, хотя
программа имеет только два параметра. Дело в том, что в argv[0]
всегда передается дополнительный параметр, содержащий имя программы.
Функция
extern "C" int atoi(const char*)
является стандартной библиотечной функцией, преобразующей целое из
строкового представления во внутреннюю двоичную форму. Как обычно,
если вы не хотите иметь такое описание в своей программе, то вам
надо включить в нее соответствующий заголовочный файл, содержащий
описания стандартных библиотечных функций. Случайные числа
генерируются с помощью стандартной функции rand:
extern "C" int rand(); // будьте осторожны:
// числа не совсем случайные
int randint(int u) // диапазон 1..u
{
int r = rand();
if (r < 0) r = -r;
return 1 + r%u;
}
Подробности реализации класса мало интересны для пользователя,
но в любом случае будут использоваться функции-члены.
Конструктор размещает массив целых с размером, равным заданному
максимальному размеру множества, а деструктор удаляет этот массив:
intset::intset(int m, int n) // не более m целых в 1..n
{
if (m<1 || n<m) error("недопустимый размер intset");
cursize = 0;
maxsize = m;
x = new int[maxsize];
}
intset::~intset()
{
delete x;
}
Целые добавляются таким образом, что они хранятся во множестве
в возрастающем порядке:
void intset::insert(int t)
{
if (++cursize > maxsize) error("слишком много элементов");
int i = cursize-1;
x[i] = t;
while (i>0 && x[i-1]>x[i]) {
int t = x[i]; // поменять местами x[i] и x[i-1]
x[i] = x[i-1];
x[i-1] = t;
i--;
}
}
Чтобы найти элемент, используется простой двоичный поиск:
int intset::member(int t) const // двоичный поиск
{
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 будет реализовано в виде связанного списка?).
Пользователь получает три функции: start() - для инициализации
итерации, ok() - для проверки, есть ли следующий элемент, и next() -
для получения следующего элемента:
class intset {
// ...
void start(int& i) const { i = 0; }
int ok(int& i) const { return i<cursize; }
int next(int& i) const { return x[i++]; }
};
Чтобы обеспечить совместную работу этих трех операций, надо запоминать
тот элемент, на котором остановилась итерация. Для этого пользователь
должен задавать целый параметр. Поскольку наше представление множества
упорядоченное, реализация этих операций тривиальна. Теперь
можно определить функцию print_in_order:
void print_in_order(intset* set)
{
int var;
set->sart(var);
while (set->ok(var)) cout << set->next(var) << '\n';
}
Другой способ построения итератора по множеству приведен в $$7.8.
В этом разделе описаны дополнительные свойства класса. Описан
способ обеспечить доступ к частным членам в функциях, не являющихся
членами ($$5.4.1). Описано, как разрешить коллизии имен членов
($$5.4.2) и как сделать описания классов вложенными ($$5.4.3), но
при этом избежать нежелательной вложенности ($$5.4.4). Вводится понятие
статических членов (static), которые используются для представления
операций и данных, относящихся к самому классу, а не к отдельным
его объектам ($$5.4.5). Раздел завершается примером, показывающим,
как можно построить дискриминирующее (надежное) объединение ($$5.4.6).
Пусть определены два класса: vector (вектор) и matrix (матрица).
Каждый из них скрывает свое представление, но дает полный набор операций
для работы с объектами его типа. Допустим, надо определить функцию,
умножающую матрицу на вектор. Для простоты предположим, что
вектор имеет четыре элемента с индексами от 0 до 3, а в матрице
четыре вектора тоже с индексами от 0 до 3. Доступ к элементам
вектора обеспечивается функцией elem(), и аналогичная функция есть
для матрицы. Можно определить глобальную функцию multiply
(умножить) следующим образом:
vector multiply(const matrix& m, const vector& v);
{
vector r;
for (int i = 0; i<3; i++) { // r[i] = m[i] * v;
r.elem(i) = 0;
for (int j = 0; j<3; j++)
r.elem(i) +=m.elem(i,j) * v.elem(j);
}
return r;
}
Это вполне естественное решение, но оно может оказаться очень
неэффективным. При каждом вызове multiply() функция elem() будет
вызываться 4*(1+4*3) раз. Если в elem() проводится настоящий
контроль границ массива, то на такой контроль будет потрачено
значительно больше времени, чем на выполнение самой функции, и в
результате она окажется непригодной для пользователей. С другой
стороны, если elem() есть некий специальный вариант доступа без
контроля, то тем самым мы засоряем интерфейс с вектором и матрицей
особой функцией доступа, которая нужна только для обхода контроля.
Если можно было бы сделать multiply членом обоих классов
vector и matrix, мы могли бы обойтись без контроля индекса при
обращении к элементу матрицы, но в то же время не вводить специальной
функции elem(). Однако, функция не может быть членом двух классов.
Надо иметь в языке возможность предоставлять функции, не являющейся
членом, право доступа к частным членам класса. Функция - не член
класса, - имеющая доступ к его закрытой части, называется другом
этого класса. Функция может стать другом класса, если в его
описании она описана как friend (друг). Например:
class matrix;
class vector {
float v[4];
// ...
friend vector multiply(const matrix&, const vector&);
};
class matrix {
vector v[4];
// ...
friend vector multiply(const matrix&, const vector&);
};
Функция-друг не имеет никаких особенностей, за исключением права
доступа к закрытой части класса. В частности, в такой функции
нельзя использовать указатель this, если только она действительно
не является членом класса. Описание friend является настоящим
описанием. Оно вводит имя функции в область видимости класса,
в котором она была описана, и при этом происходят обычные проверки
на наличие других описаний такого же имени в этой области
видимости. Описание friend может находится как в общей, так и в
частной частях класса, это не имеет значения.
Теперь можно написать функцию multiply, используя элементы
вектора и матрицы непосредственно:
vector multiply(const matrix& m, const vector& v)
{
vector r;
for (int i = 0; i<3; i++) { // r[i] = m[i] * v;
r.v[i] = 0;
for ( int j = 0; j<3; j++)
r.v[i] +=m.v[i][j] * v.v[j];
}
return r;
}
Отметим, что подобно функции-члену дружественная функция
явно описывается в описании класса, с которым дружит. Поэтому она
является неотъемлемой частью интерфейса класса наравне с
функцией-членом.
Функция-член одного класса может быть другом другого класса:
class x {
// ...
void f();
};
class y {
// ...
friend void x::f();
};
Вполне возможно, что все функции одного класса являются друзьями
другого класса. Для этого есть краткая форма записи:
class x {
friend class y;
// ...
};
В результате такого описания все функции-члены y становятся друзьями
класса x.
Иногда полезно делать явное различие между именами членов классов
и прочими именами. Для этого используется операция :: (разрешения
области видимости):
class X {
int m;
public:
int readm() const { return m; }
void setm(int m) { X::m = m; }
};
В функции X::setm() параметр m скрывает член m, поэтому к члену
можно обращаться, только используя уточненное имя X::m. Правый
операнд операции :: должен быть именем класса.
Начинающееся с :: имя должно быть глобальным именем. Это особенно
полезно при использовании таких распространенных имен как read, put,
open, которыми можно обозначать функции-члены, не теряя возможности
обозначать ими же функции, не являющиеся членами.
Например:
class my_file {
// ...
public:
int open(const char*, const char*);
};
int my_file::jpen(const char* name, const char* spec)
{
// ...
if (::open(name,flag)) { // используется open() из UNIX(2)
// ...
}
// ...
}
Описание класса может быть вложенным. Например:
class set {
struct setmem {
int mem;
setmem* next;
setmem(int m, setmem* n) { mem=m; next=n; }
};
setmem* first;
public:
set() { first=0; }
insert(int m) { first = new setmem(m,first); }
// ...
};
Доступность вложенного класса ограничивается областью видимости
лексически объемлющего класса:
setmem m1(1,0); // ошибка: setmem не находится
// в глобальной области видимости
Если только описание вложенного класса не является совсем простым,
то лучше описывать этот класс отдельно, поскольку вложенные описания
могут стать очень запутанными:
class setmem {
friend class set; // доступно только для членов set
int mem;
setmem* next;
setmem(int m, setmem* n) { mem=m; next=n; }
// много других полезных членов
};
class set {
setmem* first;
public:
set() { first=0; }
insert(int m) { first = new setmem(m,first); }
// ...
};
Полезное свойство вложенности - это сокращение числа глобальных имен,
а недостаток его в том, что оно нарушает свободу использования
вложенных типов (см. $$12.3).
Имя класса-члена (вложенного класса) можно использовать вне
описания объемлющего его класса так же, как имя любого другого
члена:
class X {
struct M1 { int m; };
public:
struct M2 { int m; };
M1 f(M2);
};
void f()
{ M1 a; // ошибка: имя `M1' вне области видимости
M2 b; // ошибка: имя `M1' вне области видимости
X::M1 c; // ошибка: X::M1 частный член
X::M2 d; // нормально
}
Отметим, что контроль доступа происходит и для имен вложенных
классов.
В функции-члене область видимости класса начинается после
уточнения X:: и простирается до конца описания функции. Например:
M1 X::f(M2 a) // ошибка: имя `M1' вне области видимости
{ /* ... */ }
X::M1 X::f(M2 a) // нормально
{ /* ... */ }
X::M1 X::f(X::M2 a) // нормально, но третье уточнение X:: излишне
{ /* ... */ }
Класс - это тип, а не некоторое данное, и для каждого объекта
класса создается своя копия членов, представляющих данные. Однако,
наиболее удачная реализация некоторых типов требует, чтобы все
объекты этого типа имели некоторые общие данные. Лучше, если эти
данные можно описать как часть класса. Например, в операционных
системах или при моделировании управления задачами часто нужен
список задач:
class task {
// ...
static task* chain;
// ...
};
Описав член chain как статический, мы получаем гарантию, что
он будет создан в единственном числе, т.е. не будет создаваться
для каждого объекта task. Но он находится в области видимости
класса task, и может быть доступен вне этой области, если только
описан в общей части. В этом случае имя члена должно уточняться
именем класса:
if (task::chain == 0) // какие-то операторы
В функции-члене его можно обозначать просто chain. Использование
статических членов класса может заметно сократить потребность в
глобальных переменных.
Описывая член как статический, мы ограничиваем его область
видимости и делаем его независимым от отдельных объектов его
класса. Это свойство полезно как для функций-членов, так и для
членов, представляющих данные:
class task {
// ...
static task* task_chain;
static void shedule(int);
// ...
};
Но описание статического члена - это только описание, и где-то
в программе должно быть единственное определение для описываемого
объекта или функции, например, такое:
task* task::task_chain = 0;
void task::shedule(int p) { /* ... */ }
Естественно, что и частные члены могут определяться подобным образом.
Отметим, что служебное слово static не нужно и даже нельзя
использовать в определении статического члена класса. Если бы оно
присутствовало, возникла бы неоднозначность: указывает ли оно на то,
что член класса является статическим, или используется для