Таблица имен
Есть функция поиска в таблице имен:
name* look(char* p, int ins =0);
Второй ее параметр показывает, была ли символьная строка, обозначающая имя, ранее занесена в таблицу. Инициализатор =0 задает стандартное значение параметра, которое используется, если функция look() вызывается только с одним параметром. Это удобно, так как можно писать look("sqrt2"), что означает look("sqrt2",0), т.е. поиск, а не занесение в таблицу. Чтобы было так же удобно задавать операцию занесения в таблицу, определяется вторая функция:
inline name* insert(const char* s) { return look(s,1); }
Как ранее упоминалось, записи в этой таблице имеют такой тип:
struct name {
char* string;
name* next;
double value;
};
Член next используется для связи записей в таблице. Собственно таблица - это просто массив указателей на объекты типа name:
const TBLSZ = 23;
name* table[TBLSZ];
Поскольку по умолчанию все статические объекты инициализируются нулем, такое тривиальное описание таблицы table обеспечивает также и нужную инициализацию.
Для поиска имени в таблице функция look() использует простой хэш-код (записи, в которых имена имеют одинаковый хэш-код, связываются вместе):
int ii = 0; // хэш-код
const char* pp = p;
while (*pp) ii = ii<<1 ^ *pp++;
if (ii < 0) ii = -ii;
ii %= TBLSZ;
Иными словами, с помощью операции ^ ("исключающее ИЛИ") все символы
входной строки p поочередно добавляются к ii. Разряд в результате x^y равен 1 тогда и только тогда, когда эти разряды в операндах x и y различны. До выполнения операции ^ значение ii сдвигается на один разряд влево, чтобы использовался не только один байт ii. Эти действия можно записать таким образом:
ii <<= 1;
ii ^= *pp++;
Для хорошего хэш-кода лучше использовать операцию ^, чем +. Операция сдвига важна для получения приемлемого хэш-кода в обоих случаях. Операторы
if (ii < 0) ii = -ii;
ii %= TBLSZ;
гарантируют, что значение ii будет из диапазона 0...TBLSZ-1. Напомним, что % - это операция взятия остатка. Ниже полностью приведена функция look:
#include <string.h>
name* look( const char* p, int ins =0)
{
int ii = 0; // хэш-код
const char* pp = p;
while (*pp) ii = ii<<1 ^ *pp++;
if (ii < 0) ii = -ii;
ii %= TBLSZ;
for (name* n=table[ii]; 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 = table[ii];
table[ii] = nn;
return nn;
}
После вычисления хэш-кода ii идет простой поиск имени по членам next. Имена сравниваются с помощью стандартной функции сравнения строк strcmp(). Если имя найдено, то возвращается указатель на содержащую его запись, а в противном случае заводится новая запись с этим именем.
Добавление нового имени означает создание нового объекта name в свободной памяти с помощью операции new (см. $$3.2.6), его инициализацию и включение в список имен. Последнее выполняется как занесение нового имени в начало списка, поскольку это можно сделать даже без проверки того, есть ли список вообще. Символьная строка имени также размещается в свободной памяти. Функция strlen() указывает, сколько памяти нужно для строки, операция new отводит нужную память, а функция strcpy() копирует в нее строку. Все строковые функции описаны в <string.h>:
extern int strlen(const char*);
extern int strcmp(const char*, const char*);
extern char* strcpy(char*, const char*);
Тестирование
Программа, которая не прошла тестирование, не работает. Идеал, чтобы после проектирования и (или) верификации программа заработала с первого раза, недостижим для всех, за исключением самых тривиальных программ. Следует стремиться к идеалу, но не заблуждаться, что тестирование простое дело.
"Как проводить тестирование?" - на этот вопрос нельзя ответить в общем случае. Однако, вопрос "Когда начинать тестирование?" имеет такой ответ - на самом раннем этапе, где это возможно. Стратегия тестирования должна быть разработана как часть проекта и включена в реализацию, или, по крайней мере, разрабатываться параллельно с ними. Как только появляется работающая система, надо начинать тестирование. Откладывание тестирования до "проведения полной реализации" - верный способ выйти из графика или передать версию с ошибками.
Всюду, где это возможно, проектирование должно вестись так, чтобы тестировать систему было достаточно просто. В частности, имеет смысл средства тестирования прямо встраивать в систему. Иногда это не делается из-за боязни слишком объемных проверок на стадии выполнения, или из-за опасений, что избыточность, необходимая для полного тестирования, излишне усложнит структуры данных. Обычно такие опасения неоправданы, поскольку собственно программы проверки и дополнительные конструкции, необходимые для них, можно при необходимости удалить из системы перед ее поставкой пользователю. Иногда могут пригодится утверждения о свойствах программы (см. $$12.2.7).
Более важной, чем набор тестов, является подход, когда структура системы такова, что есть реальные шансы убедить себя и пользователей, что ошибки можно исключить с помощью определенного набора статических проверок, статического анализа и тестирования. Если разработана стратегия построения системы, устойчивой к ошибкам (см.$$9.8), стратегия тестирования обычно разрабатывается как вспомогательная.
Если вопросы тестирования полностью игнорируются на этапе проектирования, возникнут проблемы с тестированием, временем поставки и сопровождением системы. Лучше всего начать работать над стратегией тестирования с интерфейсов классов и их взаимозависимостей (как предлагается в $$12.2 и $$12.4).
Трудно определить необходимый объем тестирования. Однако, очевидно, что проблему представляет недостаток тестирования, а не его избыток. Сколько именно ресурсов в сравнении с проектированием и реализацией следует отвести для тестирования зависит от природы системы и методов ее построения. Однако, можно предложить следующее правило: отводить больше ресурсов времени и человеческих усилий на тестирование системы, чем на получения ее первой реализации.
Тип void
Тип void синтаксически эквивалентен основным типам, но использовать его можно только в производном типе. Объектов типа void не существует. С его помощью задаются указатели на объекты неизвестного типа или функции, невозвращающие значение.
void f(); // f не возвращает значения
void* pv; // указатель на объект неизвестного типа
Указатель произвольного типа можно присваивать переменной типа void*. На первый взгляд этому трудно найти применение, поскольку для void* недопустимо косвенное обращение (разыменование). Однако, именно на этом ограничении основывается использование типа void*. Он приписывается параметрам функций, которые не должны знать истинного типа этих параметров. Тип void* имеют также бестиповые объекты, возвращаемые функциями. Для использования таких объектов нужно выполнить явную операцию преобразования типа. Такие функции обычно находятся на самых нижних уровнях системы, которые управляют аппаратными ресурсами. Приведем пример:
void* malloc(unsigned size);
void free(void*);
void f() // распределение памяти в стиле Си
{
int* pi = (int*)malloc(10*sizeof(int));
char* pc = (char*)malloc(10);
//...
free(pi);
free(pc);
}
Обозначение: (тип) выражение - используется для задания операции преобразования выражения к типу, поэтому перед присваиванием pi тип void*, возвращаемый в первом вызове malloc(), преобразуется в тип int. Пример записан в архаичном стиле; лучший стиль управления размещением в свободной памяти показан в $$3.2.6.
ТИПЫ
С каждым именем (идентификатором) в программе связан тип. Он задает те операции, которые могут применяться к имени (т.е. к объекту, который обозначает имя), а также интерпретацию этих операций. Приведем примеры:
int error_number;
float real(complex* p);
Поскольку переменная error_number описана как int (целое), ей можно присваивать, а также можно использовать ее значения в арифметических выражениях. Функцию real можно вызывать с параметром, содержащим адрес complex. Можно получать адреса и переменной, и функции. Некоторые имена, как в нашем примере int и complex, являются именами типов. Обычно имя типа нужно, чтобы задать в описании типа некоторое другое имя. Кроме того, имя типа может использоваться в качестве операнда в операциях sizeof (с ее помощью определяют размер памяти, необходимый для объектов этого типа) и new (с ее помощью можно разместить в свободной памяти объект этого типа). Например:
int main()
{
int* p = new int;
cout << "sizeof(int) = " << sizeof(int) '\n';
}
Еще имя типа может использоваться в операции явного преобразования одного типа к другому ($$3.2.5), например:
float f;
char* p;
//...
long ll = long(p); // преобразует p в long
int i = int(f); // преобразует f в int
Удаление
Пользовательские типы чаще имеют, чем не имеют, конструкторы, которые проводят надлежащую инициализацию. Для многих типов требуется и обратная операция - деструктор, гарантирующая правильное удаление объектов этого типа. Деструктор класса 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() память, которая была занята обоими массивами, будет освобождена.
Указание размещения
По умолчанию операция new создает указанный ей объект в свободной памяти. Как быть, если надо разместить объект в определенном месте? Этого можно добиться переопределением операции размещения. Рассмотрим простой класс:
class X {
// ...
public:
X(int);
// ...
};
Объект можно разместить в любом месте, если ввести в функцию размещения дополнительные параметры:
// операция размещения в указанном месте:
void* operator new(size_t, void* p) { return p; }
и задав эти параметры для операции new следующим образом:
char buffer[sizeof(X)];
void f(int i)
{
X* p = new(buffer) X(i); // разместить X в buffer
// ...
}
Функция operator new(), используемая операцией new, выбирается согласно правилам сопоставления параметров ($$R.13.2). Все функции operator new() должны иметь первым параметром size_t. Задаваемый этим параметром размер неявно передается операцией new.
Определенная нами функция operator new() с задаваемым размещением является самой простой из функций подобного рода. Можно привести другой пример функции размещения, выделяющей память из некоторой заданной области:
class Arena {
// ...
virtual void* alloc(size_t) = 0;
virtual void free(void*) = 0;
};
void operator new(size_t sz, Arena* a)
{
return a.alloc(sz);
}
Теперь можно отводить память для объектов произвольных типов из различных областей (Arena):
extern Arena* Persistent; // постоянная память
extern Arena* Shared; // разделяемая память
void g(int i)
{
X* p = new(Persistent) X(i); // X в постоянной памяти
X* q = new(Shared) X(i); // X в разделяемой памяти
// ...
}
Если мы помещаем объект в область памяти, которая непосредственно не управляется стандартными функциями распределения свободной памяти, то надо позаботиться о правильном уничтожении объекта. Основным средством здесь является явный вызов деструктора:
void h(X* p)
{
p->~X(); // вызов деструктора
Persistent->free(p); // освобождение памяти
}
Заметим, что явных вызовов деструкторов, как и глобальных функций размещения специального назначения, следует, по возможности, избегать. Бывают случаи, когда обойтись без них трудно, но новичок должен трижды подумать, прежде чем использовать явный вызов деструктора, и должен сначала посоветоваться с более опытным коллегой.
Указатель на функцию
Возможны только две операции с функциями: вызов и взятие адреса. Указатель, полученный с помощью последней операции, можно впоследствии использовать для вызова функции. Например:
void error(char* p) { /* ... */ }
void (*efct)(char*); // указатель на функцию
void f()
{
efct = &error; // efct настроен на функцию error
(*efct)("error"); // вызов error через указатель efct
}
Для вызова функции с помощью указателя (efct в нашем примере) надо вначале применить операцию косвенности к указателю - *efct. Поскольку приоритет операции вызова () выше, чем приоритет косвенности *, нельзя писать просто *efct("error"). Это будет означать *(efct("error")), что является ошибкой. По той же причине скобки нужны и при описании указателя на функцию. Однако, писать просто efct("error") можно, т.к. транслятор понимает, что efct является указателем на функцию, и создает команды, делающие вызов нужной функции.
Отметим, что формальные параметры в указателях на функцию описываются так же, как и в обычных функциях. При присваивании указателю на функцию требуется точное соответствие типа функции и типа присваиваемого значения. Например:
void (*pf)(char*); // указатель на void(char*)
void f1(char*); // void(char*);
int f2(char*); // int(char*);
void f3(int*); // void(int*);
void f()
{
pf = &f1; // нормально
pf = &f2; // ошибка: не тот тип возвращаемого
// значения
pf = &f3; // ошибка: не тот тип параметра
(*pf)("asdf"); // нормально
(*pf)(1); // ошибка: не тот тип параметра
int i = (*pf)("qwer"); // ошибка: void присваивается int
}
Правила передачи параметров одинаковы и для обычного вызова, и для вызова с помощью указателя.
Часто бывает удобнее обозначить тип указателя на функцию именем, чем все время использовать достаточно сложную запись. Например:
typedef int (*SIG_TYP)(int); // из <signal.h>
typedef void (SIG_ARG_TYP)(int);
SIG_TYP signal(int, SIG_ARG_TYP);
Также часто бывает полезен массив указателей на функции. Например, можно реализовать систему меню для редактора с вводом, управляемым мышью, используя массив указателей на функции, реализующие команды. Здесь нет возможности подробно описать такой редактор, но дадим самый общий его набросок:
typedef void (*PF)();
PF edit_ops[] = { // команды редактора
&cut, &paste, &snarf, &search
};
PF file_ops[] = { // управление файлом
&open, &reshape, &close, &write
};
Далее надо определить и инициализировать указатели, с помощью которых будут запускаться функции, реализующие выбранные из меню команды. Выбор происходит нажатием клавиши мыши:
PF* button2 = edit_ops;
PF* button3 = file_ops;
Для настоящей программы редактора надо определить большее число объектов, чтобы описать каждую позицию в меню. Например, необходимо где-то хранить строку, задающую текст, который будет выдаваться для каждой позиции. При работе с системой меню назначение клавиш мыши будет постоянно меняться. Частично эти изменения можно представить как изменения значений указателя, связанного с данной клавишей. Если пользователь выбрал позицию меню, которая определяется, например, как позиция 3 для клавиши 2, то соответствующая команда реализуется вызовом:
(*button2[3])();
Чтобы полностью оценить мощность конструкции указатель на функцию, стоит попытаться написать программу без нее. Меню можно изменять в динамике, если добавлять новые функции в таблицу команд.
Довольно просто создавать в динамике и новые меню.
Указатели на функции помогают реализовать полиморфические
подпрограммы, т.е. такие подпрограммы, которые можно применять к объектам различных типов:
typedef int (*CFT)(void*,void*);
void sort(void* base, unsigned n, unsigned int sz, CFT cmp)
/*
Сортировка вектора "base" из n элементов
в возрастающем порядке;
используется функция сравнения, на которую указывает cmp.
Размер элементов равен "sz".
Алгоритм очень неэффективный: сортировка пузырьковым методом
*/
{
for (int i=0; i<n-1; i++)
for (int j=n-1; i<j; j--) {
char* pj = (char*)base+j*sz; // b[j]
char* pj1 = pj - sz; // b[j-1]
if ((*cmp)(pj,pj1) < 0) {
// поменять местами b[j] и b[j-1]
for (int k = 0; k<sz; k++) {
char temp = pj[k];
pj[k] = pj1[k];
pj1[k] = temp;
}
}
}
}
В подпрограмме sort неизвестен тип сортируемых объектов; известно только их число (размер массива), размер каждого элемента и функция, которая может сравнивать объекты. Мы выбрали для функции sort() такой же заголовок, как у qsort() - стандартной функции сортировки из библиотеки С. Эту функцию используют настоящие программы. Покажем, как с помощью sort() можно отсортировать таблицу с такой структурой:
struct user {
char* name; // имя
char* id; // пароль
int dept; // отдел
};
typedef user* Puser;
user heads[] = {
"Ritchie D.M.", "dmr", 11271,
"Sethi R.", "ravi", 11272,
"SZYmanski T.G.", "tgs", 11273,
"Schryer N.L.", "nls", 11274,
"Schryer N.L.", "nls", 11275
"Kernighan B.W.", "bwk", 11276
};
void print_id(Puser v, int n)
{
for (int i=0; i<n; i++)
cout << v[i].name << '\t'
<< v[i].id << '\t'
<< v[i].dept << '\n';
}
Чтобы иметь возможность сортировать, нужно вначале определить подходящие функции сравнения. Функция сравнения должна возвращать отрицательное число, если ее первый параметр меньше второго, нуль, если они равны, и положительное число в противном случае:
int cmp1(const void* p, const void* q)
// сравнение строк, содержащих имена
{
return strcmp(Puser(p)->name, Puser(q)->name);
}
int cmp2( const void* p, const void* q)
// сравнение номеров разделов
{
return Puser(p)->dept - Puser(q)->dept;
}
Следующая программа сортирует и печатает результат:
int main()
{
sort(heads,6,sizeof(user), cmp1);
print_id(heads,6); // в алфавитном порядке
cout << "\n";
sort(heads,6,sizeof(user),cmp2);
print_id(heads,6); // по номерам отделов
}
Допустима операция взятия адреса и для функции-подстановки, и для перегруженной функции ($$R.13.3).
Отметим, что неявное преобразование указателя на что-то в указатель типа void* не выполняется для параметра функции, вызываемой через указатель на нее. Поэтому функцию
int cmp3(const mytype*, const mytype*);
нельзя использовать в качестве параметра для sort(). Поступив иначе, мы нарушаем заданное в описании условие, что cmp3() должна вызываться с параметрами типа mytype*. Если вы специально хотите нарушить это условие, то должны использовать явное преобразование типа.
Указатели
Для большинства типов T указатель на T имеет тип T*. Это значит, что переменная типа T* может хранить адрес объекта типа T. Указатели на массивы и функции, к сожалению, требуют более сложной записи:
int* pi;
char** cpp; // указатель на указатель на char
int (*vp)[10]; // указатель на массив из 10 целых
int (*fp)(char, char*); // указатель на функцию с параметрами
// char и char*, возвращающую int
Главная операция над указателями - это косвенное обращение (разыменование), т.е. обращение к объекту, на который настроен указатель. Эту операцию обычно называют просто косвенностью. Операция косвенности * является префиксной унарной операцией. Например:
char c1 = 'a';
char* p = &c1; // p содержит адрес c1
char c2 = *p; // c2 = 'a'
Переменная, на которую указывает p,- это c1, а значение, которое хранится в c1, равно 'a'. Поэтому присваиваемое c2 значение *p есть 'a'. Над указателями можно выполнять и некоторые арифметические операции. Ниже в качестве примера представлена функция, подсчитывающая число символов в строке, заканчивающейся нулевым символом (который не учитывается):
int strlen(char* p)
{
int i = 0;
while (*p++) i++;
return i;
}
Можно определить длину строки по-другому: сначала найти ее конец, а затем
вычесть адрес начала строки из адреса ее конца.
int strlen(char* p)
{
char* q = p;
while (*q++) ;
return q-p-1;
}
Широко используются указатели на функции; они особо обсуждаются
в $$4.6.9
Указатели и массивы
Массив можно описать так:
char v [ 10 ]; // массив из 10 символов
Описание указателя имеет такой вид:
char * p; // указатель на символ
Здесь [] означает "массив из", а символ * означает "указатель на". Значение нижней границы индекса для всех массивов равно нулю, поэтому v имеет 10 элементов: v [ 0 ] ... v [ 9 ]. Переменная типа указатель может содержать адрес объекта соответствующего типа:
p = & v [ 3 ]; // p указывает на 4-й элемент массива v
Унарная операция & означает взятие адреса.
Указатели и массивы в языке Си++ тесно связаны. Имя массива можно использовать как указатель на его первый элемент, поэтому пример с массивом alpha можно записать так:
int main()
{
char alpha[] = "abcdefghijklmnopqrstuvwxyz";
char* p = alpha;
char ch;
while (ch = *p++)
cout << ch << " = " << int (ch)
<< " = 0" << oct(ch) << '\n';
}
Можно также задать описание p следующим образом:
char* p = &alpha[0];
Эта эквивалентность широко используется при вызовах функций с параметром-массивом, который всегда передается как указатель на его первый элемент. Таким образом, в следующем примере в обоих вызовах strlen передается одно и то же значение:
void f()
{
extern "C" int strlen(const char*); // из <string.h>
char v[] = "Annemarie";
char* p = v;
strlen(p);
strlen(v);
}
Но в том и загвоэдка, что обойти это нельзя: не существует способа так
описать функцию, чтобы при ее вызове массив v копировался ($$4.6.3).
Результат применения к указателям арифметических операций +, -, ++ или -- зависит от типа указуемых объектов. Если такая операция применяется к указателю p типа T*, то считается, что p указывает на массив объектов типа T. Тогда p+1 обозначает следующий элемент этого массива, а p-1 - предыдущий элемент. Отсюда следует, что значение (адрес) p+1 будет на sizeof(T) байтов больше, чем значение p. Поэтому в следующей программе
main()
{
char cv[10];
int iv[10];
char* pc = cv;
int* pi = iv;
cout << "char* " << long(pc+1)-long(pc) << '\n';
cout << "int* " << long(pi+1)-long(pi) << '\n';
}
с учетом того, что на машине автора (Maccintosh) символ занимает один байт,
а целое - четыре байта, получим:
char* 1
int* 4
Перед вычитанием указатели были явной операцией преобразованы к типу long ($$3.2.5). Он использовался для преобразования вместо "очевидного" типа int, поскольку в некоторых реализациях языка С++ указатель может не поместиться в тип int (т.е. sizeof(int)<sizeof(char*)).
Указатели на члены
Можно брать адрес члена класса. Операция взятия адреса функции-члена часто оказывается полезной, поскольку цели и способы применения указателей на функции, о которых мы говорили в $$4.6.9, в равной степени относятся и к таким функциям. Указатель на член можно получить, применив операцию взятия адреса & к полностью уточненному имени члена класса, например, &class_name::member_name. Чтобы описать переменную типа "указатель на член класса X", надо использовать описатель вида X::*. Например:
#include <iostream.h>
struct cl
{
char* val;
void print(int x) { cout << val << x << '\n'; }
cl(char* v) { val = v; }
};
Указатель на член можно описать и использовать так:
typedef void (cl::*PMFI)(int);
int main()
{
cl z1("z1 ");
cl z2("z2 ");
cl* p = &z2;
PMFI pf = &cl::print;
z1.print(1);
(z1.*pf)(2);
z2.print(3);
(p->*pf)(4);
}
Использование typedef для замены трудно воспринимаемого описателя в С достаточно типичный случай. Операции .* и ->* настраивают указатель на конкретный объект, выдавая в результате функцию, которую можно вызывать. Приоритет операции () выше, чем у операций .* и ->*, поэтому нужны скобки.
Во многих случаях виртуальные функции ($$6.2.5) успешно заменяют указатели на функции.
"Улучшенный С"
Минимальная поддержка процедурного программирования включает функции, арифметические операции, выбирающие операторы и циклы. Помимо этого должны быть предоставлены операции ввода- вывода. Базовые языковые средства С++ унаследовал от С (включая указатели), а операции ввода-вывода предоставляются библиотекой. Самая зачаточная концепция модульности реализуется с помощью механизма раздельной трансляции.
Управление памятью
При проектировании библиотеки или просто программы с большим временем
счета один из ключевых вопросов связан с управлением памятью. В общем случае создатель библиотеки не знает, в каком окружении она будет работать. Будет ли там ресурс памяти настолько критичен, что ее нехватка станет серьезной проблемой, или же серьезной помехой станут накладные расходы, связанные с управлением памятью?
Один из основных вопросов управления памятью можно сформулировать так: если функция f() передает или возвращает указатель на объект, то кто должен уничтожать этот объект? Необходимо ответить и на связанный с ним вопрос: в какой момент объект может быть уничтожен? Ответы на эти вопросы особенно важны для создателей и пользователей таких контейнеров, как списки, массивы и ассоциативные массивы. С точки зрения создателя библиотеки идеальными будут ответы: "Система" и "В тот момент, когда объект больше никто не использует". Когда система уничтожает объект, обычно говорят, что она занимается сборкой мусора, а та часть системы, которая определяет, что объект больше никем не используется, и уничтожает его, называется сборщиком мусора.
К сожалению, использование сборщика мусора может повлечь за собой накладные расходы на время счета и память, прерывания полезных функций, определенную аппаратную поддержку, трудности связывания частей программы на разных языках или просто усложнение системы. Многие пользователи не могут позволить себе этого. Говорят, что программисты на Лиспе знают, насколько важно управление памятью, и поэтому не могут отдать его пользователю. Программисты на С тоже знают, насколько важно управление памятью, и поэтому не могут оставить его системе.
Поэтому в большинстве программ на С++ не приходится рассчитывать на сборщик мусора и нужно предложить свою стратегию размещения объектов в свободной памяти, не обращаясь к системе. Но реализации С++ со сборщиком мусора все-таки существуют.
Рассмотрим самую простую схему управления памятью для программ на С++. Для этого заменим operator new() на тривиальную функцию размещения, а operator delete() - на пустую функцию:
inline size_t align(size_t s)
/*
Даже в простой функции размещения нужно выравнивание памяти, чтобы на
объект можно было настроить указатель произвольного типа
*/
{
union Word { void* p; long double d; long l; }
int x = s + sizeof(Word) - 1;
x -= x%sizeof(Word);
return x;
}
static void* freep; // настроим start на свободную память
void* operator new(size_t s) // простая линейная функция размещения
{
void* p = freep;
s = align(s);
freep += s;
return p;
}
void operator delete(void*) { } // пусто
Если память бесконечна, то наше решение дает сборщик мусора без всяких сложностей и накладных расходов. Такой подход не применим для библиотек, когда заранее неизвестно, каким образом будет использоваться память, и когда программа, пользующаяся библиотекой, будет иметь большое время счета. Такой способ выделения памяти идеально подходит для программ, которым требуется ограниченный объем памяти или объем, пропорциональный размеру входного потока данных.
Управление проектом
Если только это имеет какой-то смысл, большинство людей делает то, что их поощряют делать. Так, в контексте программного проекта, если менеджер поощряет определенные способы действий и наказывает за другие, редкие программисты или разработчики рискнут своим положением, встречая сопротивления или безразличия администрации, чтобы делать так, как они полагают нужным.
Организация, в которой считают своих программистов недоумками, очень скоро получит программистов, которые будут рады и способны действовать только как недоумки.
Отсюда следует, что менеджер должен поощрять такие структуры, которые соответствуют сформулированным целям проекта и реализации. Однако на практике слишком часто бывает иначе. Существенное изменение стиля программирования достижимо только при соответствующем изменении в стиле проектирования, кроме того, обычно и то и другое требует изменения в стиле управления. Мыслительная и организационная инерция слишком просто сводят все к локальным изменениям, хотя только глобальные изменения могут принести успех. Прекрасной иллюстрацией служит переход на язык с объектно-ориентированным программированием, например на С++, когда он не влечет за собой соответствующих изменений в методах проектирования, чтобы воспользоваться новыми возможностями языка (см. $$12.1), и, наоборот, когда переход на "объектно-ориентированное проектирование" не сопровождается переход на язык реализации, который поддерживает этот стиль.
Управляющие классы
Концепция абстрактного класса дает эффективное средство для разделения интерфейса и его реализации. Мы применяли эту концепцию и получали постоянную связь между интерфейсом, заданным абстрактным типом, и реализацией, представленной конкретным типом. Так, невозможно переключить абстрактный итератор с одного класса-источника на другой, например, если исчерпано множество (класс set), невозможно перейти на потоки.
Далее, пока мы работаем с объектами абстрактного типа с помощью указателей или ссылок, теряются все преимущества виртуальных функций. Программа пользователя начинает зависеть от конкретных классов реализации. Действительно, не зная размера объекта, даже при абстрактном типе нельзя разместить объект в стеке, передать как параметр по значению или разместить как статический. Если работа с объектами организована через указатели или ссылки, то задача распределения памяти перекладывается на пользователя ($$13.10).
Существует и другое ограничение, связанное с использованием абстрактных типов. Объект такого класса всегда имеет определенный размер, но классы, отражающие реальное понятие, могут требовать память разных размеров.
Есть распространенный прием преодоления этих трудностей, а именно, разбить отдельный объект на две части: управляющую, которая определяет интерфейс объекта, и содержательную, в которой находятся все или большая часть атрибутов объекта. Связь между двумя частями реализуется с помощью указателя в управляющей части на содержательную часть. Обычно в управляющей части кроме указателя есть и другие данные, но их немного. Суть в том, что состав управляющей части не меняется при изменении содержательной части, и она настолько мала, что можно свободно работать с самими объектами, а не с указателями или ссылками на них.
управляющая часть содержательная часть
Простым примером управляющего класса может служить класс string из $$7.6. В нем содержится интерфейс, контроль доступа и управление памятью для содержательной части. В этом примере управляющая и содержательная части представлены конкретными типами, но чаще содержательная часть представляется абстрактным классом.
Теперь вернемся к абстрактному типу set из $$13.3. Как можно определить управляющий класс для этого типа, и какие это даст плюсы и минусы? Для данного класса set можно определить управляющий класс просто перегрузкой операции ->:
class set_handle {
set* rep;
public:
set* operator->() { return rep; }
set_handler(set* pp) : rep(pp) { }
};
Это не слишком влияет на работу с множествами, просто передаются объекты типа set_handle вместо объектов типа set& или set*, например:
void my(set_handle s)
{
for (T* p = s->first(); p; p = s->next())
{
// ...
}
// ...
}
void your(set_handle s)
{
for (T* p = s->first(); p; p = s->next())
{
// ...
}
// ...
}
void user()
{
set_handle sl(new slist_set);
set_handle v(new vector_set v(100));
my(sl);
your(v);
my(v);
your(sl);
}
Если классы set и set_handle разрабатывались совместно,легко реализовать подсчет числа создаваемых множеств:
class set {
friend class set_handle;
protected:
int handle_count;
public:
virtual void insert(T*) = 0;
virtual void remove(T*) = 0;
virtual int is_member(T*) = 0;
virtual T* first() = 0;
virtual T* next() = 0;
set() : handle_count(0) { }
};
Чтобы подсчитать число объектов данного типа set, в управляющем классе нужно увеличивать или уменьшать значение счетчика set_handle:
class set_handle {
set* rep;
public:
set* operator->() { return rep; }
set_handle(set* pp)
: rep(pp) { pp->handle_count++; }
set_handle(const set_handle& r)
: rep(r.rep) { rep->handle_count++; }
set_handle& operator=(const set_handle& r)
{
rep->handle_count++;
if (--rep->handle_count == 0) delete rep;
rep = r.rep;
return *this;
}
~set_handle()
{ if (--rep->handle_count == 0) delete rep; }
};
Если все обращения к классу set обязательно идут через set_handle, пользователь может не беспокоиться о распределении памяти под объекты типа set.
На практике иногда приходится извлекать указатель на содержательную часть из управляющего класса и пользоваться непосредственно им. Можно, например, передать такой указатель функции, которая ничего не знает об управляющем классе. Если функция не уничтожает объект, на который она получила указатель, и если она не сохраняет указатель для дальнейшего использования после возврата, никаких ошибок быть не должно. Может оказаться полезным переключение управляющего класса на другую содержательную часть:
class set_handle {
set* rep;
public:
// ...
set* get_rep() { return rep; }
void bind(set* pp)
{
pp->handle_count++;
if (--rep->handle_count == 0) delete rep;
rep = pp;
}
};
Создание новых производных от set_handle классов обычно не имеет особого смысла, поскольку это - конкретный тип без виртуальных функций. Другое дело - построить управляющий класс для семейства классов, определяемых одним базовым. Полезным приемом будет создание производных от такого управляющего класса. Этот прием можно применять как для узловых классов, так и для абстрактных типов.
Естественно задавать управляющий класс как шаблон типа:
template<class T> class handle {
T* rep;
public:
T* operator->() { return rep; }
// ...
};
Но при таком подходе требуется взаимодействие между управляющим и "управляемым" классами. Если управляющий и управляемые классы разрабатываются совместно, например, в процессе создания библиотеки, то это может быть допустимо. Однако, существуют и другие решения ($$13.10).
За счет перегрузки операции -> управляющий класс получает возможность контроля и выполнения каких-то операций при каждом обращении к объекту. Например, можно вести подсчет частоты использования объектов через управляющий класс:
template<class T>
class Xhandle {
T* rep;
int count;
public:
T* operator->() { count++; return rep; }
// ...
};
Нужна более сложная техника, если требуется выполнять операции как перед, так и после обращения к объекту. Например, может потребоваться множество с блокировкой при выполнении операций добавления к множеству и удаления из него. Здесь, по сути, в управляющем классе приходится дублировать интерфейс с объектами содержательной части:
class set_controller {
set* rep;
// ...
public:
lock();
unlock();
virtual void insert(T* p)
{ lock(); rep->insert(p); unlock(); }
virtual void remove(T* p)
{ lock(); rep->remove(p); unlock(); }
virtual int is_member(T* p)
{ return rep->is_member(p); }
virtual T* first() { return rep->first(); }
virtual T* next() { return rep->next(); }
// ...
};
Писать функции- переходники для всего интерфейса утомительно (а значит могут появляться ошибки), но не трудно и это не ухудшает характеристик программы.
Заметим, что не все функции из set следует блокировать. Как показывает опыт автора, типичный случай, когда операции до и после обращения к объекту надо выполнять не для всех, а только для некоторых функций-членов. Блокировка всех операций, как это делается в мониторах некоторых операционных систем, является избыточной и может существенно ухудшить параллельный режим выполнения.
Переопределив все функции интерфейса в управляющем классе, мы получили по сравнению с приемом перегрузки операции ->, то преимущество, что теперь можно строить производные от set_controller классы. К сожалению, мы можем потерять и некоторые достоинства управляющего класса, если к производным классам будут добавляться члены, представляющие данные. Можно сказать, что программный объем, который разделяется между управляемыми классами уменьшается по мере роста программного объема управляющего класса.
Условные операторы и циклы
В С++ есть традиционный набор выбирающих операторов и циклов. Ниже приводятся примеры операторов if, switch и while.
В следующем примере показано преобразование дюйма в сантиметр и обратно. Предполагается, что во входном потоке значение в сантиметрах завершается символом i, а значение в дюймах - символом c:
#include <iostream.h>
int main ()
{
const float fac = 2.54;
float x, in, cm;
char ch = 0;
cout << "enter length: ";
cin >> x; // ввод числа с плавающей точкой
cin >> ch // ввод завершающего символа
if ( ch == 'i' )
{ // дюйм
in = x;
cm = x * fac;
}
else if ( ch == 'c' )
{ // сантиметры
in = x / fac;
cm = x;
}
else
in = cm = 0;
cout << in << " in = " << cm << " cm\n";
}
Операция >> ("ввести из") используется как оператор ввода; cin
является стандартным входным потоком. Тип операнда, расположенного справа от операции >>, определяет, какое значение вводится; оно записывается в этот операнд.
Оператор switch (переключатель) сравнивает значение с набором констант. Проверку в предыдущем примере можно записать так:
switch ( ch )
{
case 'i':
in = x;
cm = x * fac;
break;
case 'c':
in = x / fac;
cm = x;
break;
default:
in = cm = 0;
break;
}
Операторы break используются для выхода из переключателя. Все константы вариантов должны быть различны. Если сравниваемое значение не совпадает ни с одной из них, выполняется оператор с меткой default. Вариант default может и отсутствовать.
Приведем запись, задающую копирование 10 элементов одного массива в другой:
int v1 [ 10 ];
int v2 [ 10 ];
// ...
for ( int i=0; i<10; i++ ) v1 [ i ] = v2 [ i ];
Словами это можно выразить так: "Начать с i равного нулю, и пока i меньше 10, копировать i-тый элемент и увеличивать i." Инкремент (++) переменной целого типа просто сводится к увеличению на 1.
Уточнение имени члена
Иногда полезно делать явное различие между именами членов классов и прочими именами. Для этого используется операция :: (разрешения области видимости):
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)
// ...
}
// ...
}
Узловые классы
В действительности иерархия классов строится, исходя из совсем другой концепции производных классов, чем концепция интерфейс-реализация, которая использовалась для абстрактных типов. Класс рассматривается как фундамент строения. Но даже, если в основании находится абстрактный класс, он допускает некоторое представление в программе и сам предоставляет для производных классов какие-то полезные функции. Примерами узловых классов могут служить классы rectangle ($$6.4.2) и satellite ($$6.5.1). Обычно в иерархии класс представляет некоторое общее понятие, а производные классы представляют конкретные варианты этого понятия. Узловой класс является неотъемлемой частью иерархии классов. Он пользуется сервисом, представляемым базовыми классами, сам обеспечивает определенный сервис и предоставляет виртуальные функции и (или) защищенный интерфейс, чтобы позволить дальнейшую детализацию своих операций в производных классах.
Типичный узловой класс не только предоставляет реализацию интерфейса, задаваемого его базовым классом (как это делает класс реализации по отношению к абстрактному типу), но и сам расширяет интерфейс, добавляя новые функции. Рассмотрим в качестве примера класс dialog_box, который представляет окно некоторого вида на экране. В этом окне появляются вопросы пользователю и в нем он задает свой ответ с помощью нажатия клавиши или "мыши":
class dialog_box : public window {
// ...
public:
dialog_box(const char* ...); // заканчивающийся нулем список
// обозначений клавиш
// ...
virtual int ask();
};
Здесь важную роль играет функция ask() и конструктор, с помощью которого программист указывает используемые клавиши и задает их числовые значения. Функция ask() изображает на экране окно и возвращает номер нажатой в ответ клавиши. Можно представить такой вариант использования:
void user()
{
for (;;) {
// какие-то команды
dialog_box cont("continue",
"try again",
"abort",
(char*) 0);
switch (cont.ask()) {
case 0: return;
case 1: break;
case 2: abort();
}
}
}
Обратим внимание на использование конструктора. Конструктор, как правило, нужен для узлового класса и часто это нетривиальный конструктор. Этим узловые классы отличаются от абстрактных классов, для которых редко нужны конструкторы.
Пользователь класса dialog_box ( а не только создатель этого класса) рассчитывает на сервис, представляемый его базовыми классами. В рассматриваемом примере предполагается, что существует некоторое стандартное размещение нового окна на экране. Если пользователь захочет управлять размещением окна, базовый для dialog_box класс window (окно) должен предоставлять такую возможность, например:
dialog_box cont("continue","try again","abort",(char*)0);
cont.move(some_point);
Здесь функция движения окна move() рассчитывает на определенные функции базовых классов.
Сам класс dialog_box является хорошим кандидатом для построения производных классов. Например, вполне разумно иметь такое окно, в котором, кроме нажатия клавиши или ввода с мышью, можно задавать строку символов (скажем, имя файла). Такое окно dbox_w_str строится как производный класс от простого окна dialog_box:
class dbox_w_str : public dialog_box {
// ...
public:
dbox_w_str (
const char* sl, // строка запроса пользователю
const char* ... // список обозначений клавиш
);
int ask();
virtual char* get_string();
//...
};
Функция get_string() является той операцией, с помощью которой программист получает заданную пользователем строку. Функция ask() из класса dbox_w_str гарантирует, что строка введена правильно, а если пользователь не стал вводить строку, то тогда в программу возвращается соответствующее значение (0).
void user2()
{
// ...
dbox_w_str file_name("please enter file name",
"done",
(char*)0);
file_name.ask();
char* p = file_name.get_string();
if (p) {
// используем имя файла
}
else {
// имя файла не задано
}
//
}
Подведем итог - узловой класс должен:
[1] рассчитывать на свои базовые классы как для их реализации, так и для представления сервиса пользователям этих классов;
[2] представлять более полный интерфейс (т.е. интерфейс с большим числом функций-членов) пользователям, чем базовые классы;
[3] основывать в первую очередь (но не исключительно) свой общий интерфейс на виртуальных функциях;
[4] зависеть от всех своих (прямых и косвенных) базовых классов;
[5] иметь смысл только в контексте своих базовых классов;
[6] служить базовым классом для построения производных классов;
[7] воплощаться в объекте.
Не все, но многие, узловые классы будут удовлетворять условиям 1, 2, 6 и 7. Класс, который не удовлетворяет условию 6, походит на конкретный тип и может быть назван конкретным узловым классом. Класс, который не удовлетворяет условию 7, походит на абстрактный тип и может быть назван абстрактным узловым классом. У многих узловых классов есть защищенные члены, чтобы предоставить для производных классов менее ограниченный интерфейс.
Укажем на следствие условия 4: для трансляции своей программы пользователь узлового класса должен включить описания всех его прямых и косвенных базовых классов, а также описания всех тех классов, от которых, в свою очередь, зависят базовые классы. В этом узловой класс опять представляет контраст с абстрактным типом. Пользователь абстрактного типа не зависит от всех классов, использующихся для реализации типа и для трансляции своей программы не должен включать их описания.
Виртуальные базовые классы
В предыдущих разделах множественное наследование рассматривалось как существенный фактор, позволяющий за счет слияния классов безболезненно интегрировать независимо создававшиеся программы. Это самое основное применение множественного наследования, и, к счастью (но не случайно), это самый простой и надежный способ его применения.
Иногда применение множественного наследования предполагает достаточно тесную связь между классами, которые рассматриваются как "братские" базовые классы. Такие классы-братья обычно должны проектироваться совместно. В большинстве случаев для этого не требуется особый стиль программирования, существенно отличающийся от того, который мы только что рассматривали. Просто на производный класс возлагается некоторая дополнительная работа. Обычно она сводится к переопределению одной или нескольких виртуальных функций (см. $$13.2 и $$8.7). В некоторых случаях классы-братья должны иметь общую информацию. Поскольку С++ - язык со строгим контролем типов, общность информации возможна только при явном указании того, что является общим в этих классах. Способом такого указания может служить виртуальный базовый класс.
Виртуальный базовый класс можно использовать для представления "головного" класса, который может конкретизироваться разными способами:
class window {
// головная информация
virtual void draw();
};
Для простоты рассмотрим только один вид общей информации из класса window - функцию draw(). Можно определять разные более развитые классы, представляющие окна (window). В каждом определяется своя (более развитая) функция рисования (draw):
class window_w_border : public virtual window {
// класс "окно с рамкой"
// определения, связанные с рамкой
void draw();
};
class window_w_menu : public virtual window {
// класс "окно с меню"
// определения, связанные с меню
void draw();
};
Теперь хотелось бы определить окно с рамкой и меню:
class window_w_border_and_menu
: public virtual window,
public window_w_border,
public window_w_menu {
// класс "окно с рамкой и меню"
void draw();
};
Каждый производный класс добавляет новые свойства окна. Чтобы воспользоваться комбинацией всех этих свойств, мы должны гарантировать, что один и тот же объект класса window используется для представления вхождений базового класса window в эти производные классы. Именно это обеспечивает описание window во всех производных классах как виртуального базового класса.
Можно следующим образом изобразить состав объекта класса window_w_border_and_menu:
Чтобы увидеть разницу между обычным и виртуальным наследованием, сравните этот рисунок с рисунком из $$6.5, показывающим состав объекта класса satellite. В графе наследования каждый базовый класс с данным именем, который был указан как виртуальный, будет представлен единственным объектом этого класса. Напротив, каждый базовый класс, который при описании наследования не был указан как виртуальный, будет представлен своим собственным объектом.
Теперь надо написать все эти функции draw(). Это не слишком трудно, но для неосторожного программиста здесь есть ловушка. Сначала пойдем самым простым путем, который как раз к ней и ведет:
void window_w_border::draw()
{
window::draw();
// рисуем рамку
}
void window_w_menu::draw()
{
window::draw();
// рисуем меню
}
Пока все хорошо. Все это очевидно, и мы следуем образцу определения таких функций при условии единственного наследования ($$6.2.1), который работал прекрасно. Однако, в производном классе следующего уровня появляется ловушка:
void window_w_border_and_menu::draw() // ловушка!
{
window_w_border::draw();
window_w_menu::draw();
// теперь операции, относящиеся только
// к окну с рамкой и меню
}
На первый взгляд все вполне нормально. Как обычно, сначала выполняются все операции, необходимые для базовых классов, а затем те, которые относятся собственно к производным классам. Но в результате функция window::draw() будет вызываться дважды! Для большинства графических программ это не просто излишний вызов, а порча картинки на экране. Обычно вторая выдача на экран затирает первую.
Чтобы избежать ловушки, надо действовать не так поспешно. Мы отделим действия, выполняемые базовым классом, от действий, выполняемых из базового класса. Для этого в каждом классе введем функцию _draw(), которая выполняет нужные только для него действия, а функция draw() будет выполнять те же действия плюс действия, нужные для каждого базового класса. Для класса window изменения сводятся к введению излишней функции:
class window {
// головная информация
void _draw();
void draw();
};
Для производных классов эффект тот же:
class window_w_border : public virtual window {
// класс "окно с рамкой"
// определения, связанные с рамкой
void _draw();
void draw();
};
void window_w_border::draw()
{
window::_draw();
_draw(); // рисует рамку
};
Только для производного класса следующего уровня проявляется отличие функции, которое и позволяет обойти ловушку с повторным вызовом window::draw(), поскольку теперь вызывается window::_draw() и только один раз:
class window_w_border_and_menu
: public virtual window,
public window_w_border,
public window_w_menu {
void _draw();
void draw();
};
void window_w_border_and_menu::draw()
{
window::_draw();
window_w_border::_draw();
window_w_menu::_draw();
_draw(); // теперь операции, относящиеся только
// к окну с рамкой и меню
}
Не обязательно иметь обе функции window::draw() и window::_draw(), но наличие их позволяет избежать различных простых описок.
В этом примере класс window служит хранилищем общей для window_w_border и window_w_menu информации и определяет интерфейс для общения этих двух классов. Если используется единственное наследование, то общность информации в дереве классов достигается тем, что эта информация передвигается к корню дерева до тех пор, пока она не станет доступна всем заинтересованным в ней узловым классам. В результате легко возникает неприятный эффект: корень дерева или близкие к нему классы используются как пространство глобальных имен для всех классов дерева, а иерархия классов вырождается в множество несвязанных объектов.
Существенно, чтобы в каждом из классов-братьев переопределялись функции, определенные в общем виртуальном базовом классе. Таким образом каждый из братьев может получить свой вариант операций, отличный от других. Пусть в классе window есть общая функция ввода get_input():
class window {
// головная информация
virtual void draw();
virtual void get_input();
};
В одном из производных классов можно использовать эту функцию, не задумываясь о том, где она определена:
class window_w_banner : public virtual window {
// класс "окно с заголовком"
void draw();
void update_banner_text();
};
void window_w_banner::update_banner_text()
{
// ...
get_input();
// изменить текст заголовка
}
В другом производном классе функцию get_input() можно определять, не задумываясь о том, кто ее будет использовать:
class window_w_menu : public virtual window {
// класс "окно с меню"
// определения, связанные с меню
void draw();
void get_input(); // переопределяет window::get_input()
};
Все эти определения собираются вместе в производном классе следующего уровня:
class window_w_banner_and_menu
: public virtual window,
public window_w_banner,
public window_w_menu
{
void draw();
};
Контроль неоднозначности позволяет убедиться, что в классах-братьях определены разные функции:
class window_w_input : public virtual window {
// ...
void draw();
void get_input(); // переопределяет window::get_input
};
class window_w_input_and_menu
: public virtual window,
public window_w_input,
public window_w_menu
{ // ошибка: оба класса window_w_input и
// window_w_menu переопределяют функцию
// window::get_input
void draw();
};
Транслятор обнаруживает подобную ошибку, а устранить неоднозначность можно обычным способом: ввести в классы window_w_input и window_w_menu функцию, переопределяющую "функцию-нарушителя", и каким-то образом устранить неоднозначность:
class window_w_input_and_menu
: public virtual window,
public window_w_input,
public window_w_menu
{
void draw();
void get_input();
};
В этом классе window_w_input_and_menu::get_input() будет переопределять все функции get_input(). Подробно механизм разрешения неоднозначности описан в $$R.10.1.1.
Виртуальные функции
С помощью виртуальных функций можно преодолеть трудности, возникающие при использовании поля типа. В базовом классе описываются функции, которые могут переопределяться в любом производном классе. Транслятор и загрузчик обеспечат правильное соответствие между объектами и применяемыми к ним функциями:
class employee {
char* name;
short department;
// ...
employee* next;
static employee* list;
public:
employee(char* n, int d);
// ...
static void print_list();
virtual void print() const;
};
Служебное слово virtual (виртуальная) показывает, что функция print() может иметь разные версии в разных производных классах, а выбор нужной версии при вызове print() - это задача транслятора. Тип функции указывается в базовом классе и не может быть переопределен в производном классе. Определение виртуальной функции должно даваться для того класса, в котором она была впервые описана (если только она не является чисто виртуальной функцией, см. $$6.3). Например:
void employee::print() const
{
cout << name << '\t' << department << '\n';
// ...
}
Мы видим, что виртуальную функцию можно использовать, даже если нет производных классов от ее класса. В производном же классе не обязательно переопределять виртуальную функцию, если она там не нужна. При построении производного класса надо определять только те функции, которые в нем действительно нужны:
class manager : public employee {
employee* group;
short level;
// ...
public:
manager(char* n, int d);
// ...
void print() const;
};
Место функции print_employee() заняли функции-члены print(), и она стала не нужна. Список служащих строит конструктор employee ($$6.2.2). Напечатать его можно так:
void employee::print_list()
{
for ( employee* p = list; p; p=p->next) p->print();
}
Данные о каждом служащем будут печататься в соответствии с типом записи о нем. Поэтому программа
int main()
{
employee e("J.Brown",1234);
manager m("J.Smith",2,1234);
employee::print_list();
}
напечатает
J.Smith 1234
level 2
J.Brown 1234
Обратите внимание, что функция печати будет работать даже в том случае, если функция employee_list() была написана и оттранслирована еще до того, как был задуман конкретный производный класс manager! Очевидно, что для правильной работы виртуальной функции нужно в каждом объекте класса employee хранить некоторую служебную информацию о типе. Как правило, реализации в качестве такой информации используют просто указатель. Этот указатель хранится только для объектов класса с виртуальными функциями, но не для объектов всех классов, и даже для не для всех объектов производных классов. Дополнительная память отводится только для классов, в которых описаны виртуальные функции. Заметим, что при использовании поля типа, для него все равно нужна дополнительная память.
Если в вызове функции явно указана операция разрешения области видимости ::, например, в вызове manager::print(), то механизм вызова виртуальной функции не действует. Иначе подобный вызов привел бы к бесконечной рекурсии. Уточнение имени функции дает еще один положительный эффект: если виртуальная функция является подстановкой (в этом нет ничего необычного), то в вызове с операцией :: происходит подстановка тела функции. Это эффективный способ вызова, который можно применять в важных случаях, когда одна виртуальная функция обращается к другой с одним и тем же объектом. Пример такого случая - вызов функции manager::print(). Поскольку тип объекта явно задается в самом вызове manager::print(), нет нужды определять его в динамике для функции employee::print(), которая и будет вызываться.
Виртуальные конструкторы
Узнав о виртуальных деструкторах, естественно спросить: "Могут ли конструкторы то же быть виртуальными?" Если ответить коротко - нет. Можно дать более длинный ответ: "Нет, но можно легко получить требуемый эффект".
Конструктор не может быть виртуальным, поскольку для правильного построения объекта он должен знать его истинный тип. Более того, конструктор - не совсем обычная функция. Он может взаимодействовать с функциями управления памятью, что невозможно для обычных функций. От обычных функций-членов он отличается еще тем, что не вызывается для существующих объектов. Следовательно нельзя получить указатель на конструктор.
Но эти ограничения можно обойти, если определить функцию, содержащую вызов конструктора и возвращающую построенный объект. Это удачно, поскольку нередко бывает нужно создать новый объект, не зная его истинного типа. Например, при трансляции иногда возникает необходимость сделать копию дерева, представляющего разбираемое выражение. В дереве могут быть узлы выражений разных видов. Допустим, что узлы, которые содержат повторяющиеся в выражении операции, нужно копировать только один раз. Тогда нам потребуется виртуальная функция размножения для узла выражения.
Как правило "виртуальные конструкторы" являются стандартными конструкторами без параметров или конструкторами копирования, параметром которых служит тип результата:
class expr {
// ...
public:
expr(); // стандартный конструктор
virtual expr* new_expr() { return new expr(); }
};
Виртуальная функция new_expr() просто возвращает стандартно инициализированный объект типа expr, размещенный в свободной памяти. В производном классе можно переопределить функцию new_expr() так, чтобы она возвращала объект этого класса:
class conditional : public expr {
// ...
public:
conditional(); // стандартный конструктор
expr* new_expr() { return new conditional(); }
};
Это означает, что, имея объект класса expr, пользователь может создать объект в "точности такого же типа":
void user(expr* p1, expr* p2)
{
expr* p3 = p1->new_expr();
expr* p4 = p2->new_expr();
// ...
}
Переменным p3 и p4 присваиваются указатели неизвестного, но подходящего типа.
Тем же способом можно определить виртуальный конструктор копирования, называемый операцией размножения, но надо подойти более тщательно к специфике операции копирования:
class expr {
// ...
expr* left;
expr* right;
public:
// ...
// копировать `s' в `this'
inline void copy(expr* s);
// создать копию объекта, на который смотрит this
virtual expr* clone(int deep = 0);
};
Параметр deep показывает различие между копированием собственно объекта (поверхностное копирование) и копированием всего поддерева, корнем которого служит объект (глубокое копирование). Стандартное значение 0 означает поверхностное копирование.
Функцию clone() можно использовать, например, так:
void fct(expr* root)
{
expr* c1 = root->clone(1); // глубокое копирование
expr* c2 = root->clone(); // поверхностное копирование
// ...
}
Являясь виртуальной, функция clone() способна размножать объекты любого производного от expr класса. Настоящее копирование можно определить так:
void expr::copy(expression* s, int deep)
{
if (deep == 0) { // копируем только члены
*this = *s;
}
else { // пройдемся по указателям:
left = s->clone(1);
right = s->clone(1);
// ...
}
}
Функция expr::clone() будет вызываться только для объектов типа expr (но не для производных от expr классов), поэтому можно просто разместить в ней и возвратить из нее объект типа expr, являющийся собственной копией:
expr* expr::clone(int deep)
{
expr* r = new expr(); // строим стандартное выражение
r->copy(this,deep); // копируем `*this' в `r'
return r;
}
Такую функцию clone() можно использовать для производных от expr классов, если в них не появляются члены-данные (а это как раз типичный случай):
class arithmetic : public expr {
// ...
// новых членов-данных нет =>
// можно использовать уже определенную функцию clone
};
С другой стороны, если добавлены члены-данные, то нужно определять собственную функцию clone():
class conditional : public expression {
expr* cond;
public:
inline void copy(cond* s, int deep = 0);
expr* clone(int deep = 0);
// ...
};
Функции copy() и clone() определяются подобно своим двойникам из expression:
expr* conditional::clone(int deep)
{
conditional* r = new conditional();
r->copy(this,deep);
return r;
}
void conditional::copy(expr* s, int deep)
{
if (deep == 0) {
*this = *s;
}
else {
expr::copy(s,1); // копируем часть expr
cond = s->cond->clone(1);
}
}
Определение последней функции показывает отличие настоящего копирования в expr::copy() от полного размножения в expr::clone() (т.е. создания нового объекта и копирования в него). Простое копирование оказывается полезным для определения более сложных операций копирования и размножения. Различие между copy() и clone() эквивалентно различию между операцией присваивания и конструктором копирования ($$1.4.2) и эквивалентно различию между функциями _draw() и draw() ($$6.5.3). Отметим, что функция copy() не является виртуальной. Ей и не надо быть таковой, поскольку виртуальна вызывающая ее функция clone(). Очевидно, что простые операции копирования можно также определять как функции-подстановки.
Вложенные классы
Описание класса может быть вложенным. Например:
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:: излишне
{ /* ... */ }
Возвращаемое значение
Если функция не описана как void, она должна возвращать значение. Например:
int f() { } // ошибка
void g() { } // нормально
Возвращаемое значение указывается в операторе return в теле функции. Например:
int fac(int n) { return (n>1) ? n*fac(n-1) : 1; }
В теле функции может быть несколько операторов return:
int fac(int n)
{
if (n > 1)
return n*fac(n-1);
else
return 1;
}
Подобно передаче параметров, операция возвращения значения функции эквивалентна инициализации. Считается, что оператор return инициализирует переменную, имеющую тип возвращаемого значения. Тип выражения в операторе return сверяется с типом функции, и производятся все стандартные и пользовательские преобразования типа. Например:
double f()
{
// ...
return 1; // неявно преобразуется в double(1)
}
При каждом вызове функции создается новая копия ее формальных параметров и автоматических переменных. Занятая ими память после выхода из функции будет снова использоваться, поэтому неразумно возвращать указатель на локальную переменную. Содержимое памяти, на которую настроен такой указатель, может измениться непредсказуемым образом:
int* f()
{
int local = 1;
// ...
return &local; // ошибка
}
Эта ошибка не столь типична, как сходная ошибка, когда тип функции - ссылка:
int& f()
{
int local = 1;
// ...
return local; // ошибка
}
К счастью, транслятор предупреждает о том, что возвращается ссылка на локальную переменную. Вот другой пример:
int& f() { return 1; } // ошибка
Время жизни объектов
Если только программист не вмешается явно, объект будет создан при появлении его определения и уничтожен, когда исчезнет из области видимости. Объекты с глобальными именами создаются, инициализируются (причем только один раз) и существуют до конца программы. Если локальные объекты описаны со служебным словом static, то они также существуют до конца программы. Инициализация их происходит, когда в первый раз управление "проходит через" описание этих объектов, например:
int a = 1;
void f()
{
int b = 1; // инициализируется при каждом вызове f()
static int c = a; // инициализируется только один раз
cout << " a = " << a++
<< " b = " << b++
<< " c = " << c++ << '\n';
}
int main()
{
while (a < 4) f();
}
Здесь программа выдаст такой результат:
a = 1 b = 1 c = 1
a = 2 b = 1 c = 2
a = 3 b = 1 c = 3
''Из примеров этой главы для краткости изложения исключена макрокоманда #include <iostream>. Она нужна лишь в тех из них, которые выдают результат.
Операция "++" является инкрементом, т. е. a++ означает: добавить 1
к переменной a.
Глобальная переменная или локальная переменная static, которая не была явно инициализирована, инициализируется неявно нулевым значением (#2.4.5). Используя операции new и delete, программист может создавать объекты, временем жизни которых он управляет сам (см. $$3.2.6).
В этой главе объясняется смысл
Язык программирования С++ задумывался как язык, который будет:
-
лучше языка С;
- поддерживать абстракцию данных;
- поддерживать объектно-ориентированное программирование.
В этой главе объясняется смысл этих фраз без подробного описания конструкций языка.
$$1.2 содержит неформальное описание различий "процедурного", "модульного" и "объектно-ориентированного" программирования. Приведены конструкции языка, которые существенны для каждого из перечисленных стилей программирования. Свойственный С стиль программирования обсуждается в разделах "процедурное программирование и "модульное программирование". Язык С++ - "лучший вариант С". Он лучше поддерживает такой стиль программирования, чем сам С, причем это делается без потери какой-либо общности или эффективности по сравнению с С. В то же время язык C является подмножеством С++. Абстракция данных и объектно-ориентированное программирование рассматриваются как "поддержка абстракции данных" и "поддержка объектно- ориентированного программирования". Первая базируется на возможности определять новые типы и работать с ними, а вторая – на возможности задавать иерархию типов.
$$1.3 содержит описание основных конструкций для процедурного и модульного программирования. В частности, определяются функции, указатели, циклы, ввод-вывод и понятие программы как совокупности раздельно транслируемых модулей. Подробно эти возможности описаны в главах 2, 3 и 4.
$$1.4 содержит описание средств, предназначенных для эффективной реализации абстракции данных. В частности, определяются классы, простейший механизм контроля доступа, конструкторы и деструкторы, перегрузка операций, преобразования пользовательских типов, обработка особых ситуаций и шаблоны типов. Подробно эти возможности описаны в главах 5, 7, 8 и 9.
$$1.5 содержит описание средств поддержки объектно-ориентированного программирования. В частности, определяются производные классы и виртуальные функции, обсуждаются некоторые вопросы реализации. Все это подробно изложено в главе 6.
$$1.6 содержит описание определенных ограничений на пути совершенствования как языков программирования общего назначения вообще, так и С++ в частности. Эти ограничения связаны с эффективностью, с противоречащими друг другу требованиями разных областей приложения, проблемами обучения и необходимостью трансляции и выполнения программ в старых системах.
Если какой-то раздел окажется для вас непонятным, настоятельно советуем прочитать соответствующие главы, а затем, ознакомившись с подробным описанием основных конструкций языка, вернуться к этой главе. Она нужна для того, чтобы можно было составить общее представление о языке. В ней недостаточно сведений, чтобы немедленно начать программировать.
Роль файла в языке С++ сводится к тому, что он определяет файловую область видимости ($$R.3.2). Это область видимости глобальных функций (как статических, так и подстановок), а также глобальных переменных (как статических, так и со спецификацией const). Кроме того, файл является традиционной единицей хранения в системе, а также единицей трансляции. Обычно системы хранят, транслируют и представляют пользователю программу на С++ как множество файлов, хотя существуют системы, устроенные иначе. В этой главе будет обсуждаться в основном традиционное использование файлов.
Всю программу поместить в один файл, как правило, невозможно, поскольку программы стандартных функций и программы операционной системы нельзя включить в текстовом виде в программу пользователя. Вообще, помещать всю программу пользователя в один файл обычно неудобно и непрактично. Разбиения программы на файлы может облегчить понимание общей структуры программы и дает транслятору возможность поддерживать эту структуру. Если единицей трансляции является файл, то даже при небольшом изменении в нем следует его перетранслировать. Даже для программ не слишком большого размера время на перетрансляцию можно значительно сократить, если ее разбить на файлы подходящего размера.
Вернемся к примеру с калькулятором. Решение было дано в виде одного файла. Когда вы попытаетесь его транслировать, неизбежно возникнут некоторые проблемы с порядком описаний. По крайней мере одно "ненастоящее" описание придется добавить к тексту, чтобы транслятор мог разобраться в использующих друг друга функциях expr(), term() и prim(). По тексту программы видно, что она состоит из четырех частей: лексический анализатор (сканер), собственно анализатор, таблица имен и драйвер. Однако, этот факт никак не отражен в самой программе. На самом деле калькулятор не был запрограммирован именно так. Так не следует писать программу. Даже если не учитывать все рекомендации по программированию, сопровождению и оптимизации для такой "зряшной" программы, все равно ее следует создавать из нескольких файлов хотя бы для удобства.
Чтобы раздельная трансляция стала возможной, программист должен предусмотреть описания, из которых транслятор получит достаточно сведений о типах для трансляции файла, составляющего только часть программы. Требование непротиворечивости использования всех имен и типов для программы, состоящей из нескольких раздельно транслируемых частей, так же справедливо, как и для программы, состоящей из одного файла. Это возможно только в том случае, когда описания, находящиеся в разных единицах трансляции, будут согласованы. В вашей системе программирования имеются средства, которые способны установить, выполняется ли это. В частности, многие противоречия обнаруживает редактор связей. Редактор связей - это программа, которая связывает по именам раздельно транслируемые части программы. Иногда его по ошибке называют загрузчиком.
Обычно в программах используются объекты, являющиеся конкретным представлением абстрактных понятий. Например, в С++ тип данных 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.
Одним из самых полезных видов классов является контейнерный класс, т.е. такой класс, который хранит объекты каких-то других типов. Списки, массивы, ассоциативные массивы и множества - все это контейнерные классы. С помощью описанных в главах 5 и 7 средств можно определить класс, как контейнер объектов единственного, известного типа. Например, в $$5.3.2 определяется множество целых. Но контейнерные классы обладают тем интересным свойством, что тип содержащихся в них объектов не имеет особого значения для создателя контейнера, но для пользователя конкретного контейнера этот тип является существенным. Следовательно, тип содержащихся объектов должен параметром контейнерного класса, и создатель такого класса будет определять его с помощью типа-параметра. Для каждого конкретного контейнера (т.е. объекта контейнерного класса) пользователь будет указывать каким должен быть тип содержащихся в нем объектов. Примером такого контейнерного класса был шаблон типа Vector из $$1.4.3.
В этой главе исследуется простой шаблон типа stack (стек) и в результате вводится понятие шаблонного класса. Затем рассматриваются более полные и правдоподобные примеры нескольких родственных шаблонов типа для списка. Вводятся шаблонные функции и формулируются правила, что может быть параметром таких функций. В конце приводится шаблон типа для ассоциативного массива.
Широко известна трудность задачи проектирования и реализации стандартных средств ввода-вывода для языков программирования. Традиционно средства ввода-вывода были рассчитаны исключительно на небольшое число встроенных типов данных. Однако, в нетривиальных программах на С++ есть много пользовательских типов данных, поэтому необходимо предоставить возможность ввода-вывода значений таких типов. Очевидно, что средства ввода-вывода должны быть простыми, удобными, надежными в использовании и, что важнее всего, адекватными. Пока никто не нашел решения, которое удовлетворило бы всех; поэтому необходимо дать возможность пользователю создавать иные средства ввода-вывода, а также расширять стандартные средства ввода-вывода в расчете на определенное применение.
Цель создания С++ была в том, чтобы пользователь мог определить новые типы данных, работа с которыми была бы столь же удобна и эффективна как и со встроенными типами. Таким образом, кажется разумным потребовать, чтобы средства ввода-вывода для С++ программировались с использованием возможностей С++, доступных каждому. Представленные здесь потоковые средства ввода-вывода появились в результате попытки удовлетворить этим требованиям.
Основная задача потоковых средств ввода-вывода - это процесс преобразования объектов определенного типа в последовательность символов и наоборот. Существуют и другие схемы ввода-вывода, но указанная является основной, и если считать символ просто набором битов, игнорируя его естественную связь с алфавитом, то многие схемы двоичного ввода-вывода можно свести к ней. Поэтому программистская суть задачи сводится к описанию связи между объектом определенного типа и бестиповой (что существенно) строкой.
Последующие разделы описывают основные части потоковой библиотеки С++:
10.2 Вывод: То, что для прикладной программы представляется выводом, на самом деле является преобразованием таких объектов как int, char *, complex или Employee_record в последовательность символов. Описываются средства для записи объектов встроенных и пользовательских типов данных.
Создание любой нетривиальной программной системы - сложная и часто выматывающая задача. Даже для отдельного программиста собственно запись операторов программы есть только часть всей работы. Обычно анализ всей задачи, проектирование программы в целом, документация, тестирование, сопровождение и управление всем этим затмевает задачу написания и отладки отдельных частей программы. Конечно, можно все эти виды деятельности обозначить как "программирование" и затем вполне обоснованно утверждать: "Я не проектирую, я только программирую". Но как бы не назывались отдельные виды деятельности, бывает иногда важно сосредоточиться на них по отдельности, так же как иногда бывает важно рассмотреть весь процесс в целом. Стремясь поскорее довести систему до поставки, нельзя упускать из вида ни детали, ни картину в целом, хотя довольно часто происходит именно это. Эта глава сосредоточена на тех частях процесса развития программы, которые не связаны с написанием и отладкой отдельных программных фрагментов. Обсуждение здесь менее точное и детальное, чем во всех остальных частях книги, где рассматриваются конкретные черты языка или определенные приемы программирования. Это неизбежно, поскольку нет готовых рецептов создания хороших программ. Детальные рецепты "как" могут существовать только для определенных, хорошо разработанных областей применения, но не для достаточно широких областей приложения. В программировании не существует заменителей разума, опыта и вкуса. Следовательно, в этой главе вы найдете только общие рекомендации, альтернативные подходы и осторожные выводы.
Сложность данной тематики связана с абстрактной природой программ и тем фактом, что приемы, применимые для небольших проектов (скажем, программа в 10000 строк, созданная одним или двумя людьми), не распространяются на средние или большие проекты. По этой причине иногда мы приводим примеры из менее абстрактных инженерных дисциплин, а не только из программирования. Не преминем напомнить, что "доказательство по аналогии" является мошенничеством, и аналогии служат здесь только в качестве примера. Понятия проектирования, формулируемые с помощью определенных конструкций С++, и поясняемые примерами, мы будем обсуждать в главах 12 и 13. Предложенные в этой главе рекомендации, отражаются как в самом языке С++, так и в решении конкретных программных задач по всей книге.
Разработка библиотеки общего назначения - это гораздо более трудная задача, чем создание обычной программы. Программа - это решение конкретной задачи для конкретной области приложения, тогда как библиотека должна предоставлять возможность решение для множества задач, связанных с многими областями приложения. В обычной программе позволительны сильные допущения об ее окружении, тогда как хорошую библиотеку можно успешно использовать в разнообразных окружениях, создаваемых множеством различных программ. Чем более общей и полезной окажется библиотека, тем в большем числе окружений она будет проверяться, и тем жестче будут требования к ее корректности, гибкости, эффективности, расширяемости, переносимости, непротиворечивости, простоте, полноте, легкости использования и т.д. Все же библиотека не может дать вам все, поэтому нужен определенный компромисс. Библиотеку можно рассматривать как специальный, интересный вариант того, что в предыдущей главе мы называли компонентом. Каждый совет по проектированию и сопровождению компонентов становится предельно важным для библиотек, и, наоборот, многие методы построения библиотек находят применение при проектировании различных компонентов.
Было бы слишком самонадеянно указывать как следует конструировать библиотеки. В прошлом оказались успешными несколько различных методов, а сам предмет остается полем активных дискуссий и экспериментов. Здесь только обсуждаются некоторые важные аспекты этой задачи и предлагаются некоторые приемы, оказавшиеся полезными при создании библиотек. Не следует забывать, что библиотеки предназначены для совершенно разных областей программирования, поэтому не приходится рассчитывать, что какой-то один метод окажется наиболее приемлемым для всех библиотек. Действительно, нет никаких причин полагать, что методы, оказавшиеся полезными при реализации средств параллельного программирования для ядра многопроцессорной операционной системы, кажутся наиболее приемлемыми при создании библиотеки, предназначенной для решения научных задач, или библиотеки, представляющей графический интерфейс.
Понятие класса, которому посвящена эта
Понятие класса, которому посвящена эта и три следующих главы, служит в С++ для того, чтобы дать программисту инструмент построения новых типов. Ими пользоваться не менее удобно, чем встроенными. В идеале использование определенного пользователем типа не должно отличаться от использования встроенных типов. Различия возможны только в способе построения.
Тип есть вполне конкретное представление некоторого понятия. Например, в С++ тип float с операциями +, -, * и т.д. является хотя и ограниченным, но конкретным представлением математического понятия вещественного числа. Новый тип создается для того, чтобы стать специальным и конкретным представлением понятия, которое не находит прямого и естественного отражения среди встроенных типов. Например, в программе из области телефонной связи можно ввести тип trunk_module (линия-связи), в видеоигре - тип explosion (взрыв), а в программе, обрабатывающей текст, - тип list_of_paragraphs (список-параграфов). Обычно проще понимать и изменять программу, в которой типы хорошо представляют используемые в задаче понятия. Удачно подобранное множество пользовательских типов делает программу более ясной. Оно позволяет транслятору обнаруживать недопустимое использование объектов, которое в противном случае останется невыявленным до отладки программы.
Главное в определении нового типа - это отделить несущественные детали реализации (например, расположение данных в объекте нового типа) от тех его характеристик, которые существенны для правильного его использования (например, полный список функций, имеющих доступ к данным). Такое разделение обеспечивается тем, что вся работа со структурой данных и внутрение, служебные операции над нею доступны только через специальный интерфейс (через "одно горло").
Глава состоит из четырех частей:
$$5.2 Классы и члены. Здесь вводится основное понятие пользовательского типа, называемого классом. Доступ к объектам класса может ограничиваться множеством функций, описания которых входят в описание класса. Эти функции называются функциями-членами и друзьями. Для создания объектов класса используются специальные функции-члены, называемые конструкторами. Можно описать специальную функцию-член для удаления объектов класса при его уничтожении. Такая функция называется деструктором.
$$5.3 Интерфейсы и реализации. Здесь приводятся два примера разработки, реализации и использования классов.
$$5.4 Дополнительные свойства классов. Здесь приводится много дополнительных подробностей о классах. Показано, как функции, не являющейся членом класса, предоставить доступ к его частной части. Такую функцию называют другом класса. Вводятся понятия статических членов класса и указателей на члены класса. Здесь же показано, как определить дискриминирующее объединение.
$$5.5 Конструкторы и деструкторы. Объект может создаваться как автоматический, статический или как объект в свободной памяти. Кроме того, объект может быть членом некоторого агрегата (массива или другого класса), который тоже можно размещать одним из этих трех способов. Подробно объясняется использование конструкторов и деструкторов, описывается применение определяемых пользователем функций размещения в свободной памяти и функций освобождения
Любое понятие не существует изолированно, оно существует во взаимосвязи с другими понятиями, и мощность данного понятия во многом определяется наличием таких связей. Раз класс служит для представления понятий, встает вопрос, как представить взаимосвязь понятий. Понятие производного класса и поддерживающие его языковые средства служат для представления иерархических связей, иными словами, для выражения общности между классами. Например, понятия окружности и треугольника связаны между собой, так как оба они представляют еще понятие фигуры, т.е. содержат более общее понятие. Чтобы представлять в программе окружности и треугольники и при этом не упускать из вида, что они являются фигурами, надо явно определять классы окружность и треугольник так, чтобы было видно, что у них есть общий класс - фигура. В главе исследуется, что вытекает из этой простой идеи, которая по сути является основой того, что обычно называется объектно-ориентированным программированием.
Глава состоит из шести разделов:
$$6.2 с помощью серии небольших примеров вводится понятие производного класса, иерархии классов и виртуальных функций.
$$6.3 вводится понятие чисто виртуальных функций и абстрактных классов, даны небольшие примеры их использования.
$$6.4 производные классы показаны на законченном примере
$$6.5 вводится понятие множественного наследования как возможность иметь для класса более одного прямого базового класса, описываются способы разрешения коллизий имен, возникающих при множественном наследовании.
$$6.6 обсуждается механизм контроля доступа.
$$6.7 приводятся некоторые приемы управления свободной памятью для производных классов.
В последующих главах также будут приводиться примеры, использующие эти возможности языка.
Введение операций с помощью параметров шаблонного класса
Возможны ситуации, когда неявность связи между шаблонной функцией sort() и шаблонным классом Comparator создает трудности. Неявную связь легко упустить из виду и в то же время разобраться в ней может быть непросто. Кроме того, поскольку эта связь "встроена" в функцию sort(), невозможно использовать эту функцию для сортировки векторов одного типа, если операция сравнения рассчитана на другой тип (см. упражнение 3 в $$8.9). Поместив функцию sort() в класс, мы можем явно задавать связь с классом Comparator:
template<class T, class Comp> class Sort {
public:
static void sort(Vector<T>&);
};
Не хочется повторять тип элемента, и это можно не делать, если использовать typedef в шаблоне Comparator:
template<class T> class Comparator {
public:
typedef T T; // определение Comparator<T>::T
static int lessthan(T& a, T& b) {
return a < b;
}
// ...
};
В специальном варианте для указателей на строки это определение выглядит так:
class Comparator<char*> {
public:
typedef char* T;
static int lessthan(T a, T b) {
return strcmp(a,b) < 0;
}
// ...
};
После этих изменений можно убрать параметр, задающий тип элемента, из класса Sort:
template<class T, class Comp> class Sort {
public:
static void sort(Vector<T>&);
};
Теперь можно использовать сортировку так:
void f(Vector<int>& vi,
Vector<String>& vc,
Vector<int>& vi2,
Vector<char*>& vs)
{
Sort< int,Comparator<int> >::sort(vi);
Sort< String,Comparator<String> >:sort(vc);
Sort< int,Comparator<int> >::sort(vi2);
Sort< char*,Comparator<char*> >::sort(vs);
}
и определить функцию sort() следующим образом:
template<class T, class Comp>
void Sort<T,Comp>::sort(Vector<T>& v)
{
for (int i=0; i<n-1; i++)
for (int j=n-1; i<j; j--)
if (Comp::lessthan(v[j],v[j-1])) {
T temp = v[j];
v[j] = v[j-1];
v[j-1] = temp;
}
}
Последний вариант ярко демонстрирует как можно соединять в одну программу отдельные ее части. Этот пример можно еще больше упростить, если использовать класс сравнителя (Comp) в качестве единственного параметра шаблона. В этом случае в определениях класса Sort и функции Sort::sort() тип элемента будет обозначаться как Comp::T.
ВВОД
Ввод во многом сходен с выводом. Есть класс istream, который реализует
операцию ввода >> ("ввести из" - "input from") для небольшого набора стандартных типов. Для пользовательских типов можно определить функцию operator>>.
Ввод пользовательских типов
Операцию ввода для пользовательского типа можно определить в точности так же, как и операцию вывода, но для операции ввода существенно, чтобы второй параметр имел тип ссылки, например:
istream& operator>>(istream& s, complex& a)
/*
формат input рассчитан на complex; "f" обозначает float:
f
( f )
( f , f )
*/
{
double re = 0, im = 0;
char c = 0;
s >> c;
if (c == '(') {
s >> re >> c;
if (c == ',') s >> im >> c;
if (c != ')') s.clear(ios::badbit); // установим состояние
}
else {
s.putback(c);
s >> re;
}
if (s) a = complex(re,im);
return s;
}
Несмотря на сжатость кода, обрабатывающего ошибки, на самом деле учитывается большая часть ошибок. Инициализация локальной переменной с нужна для того, чтобы в нее не попало случайное значение, например '(', в случае неудачной операции. Последняя проверка состояния потока гарантирует, что параметр a получит значение только при успешном вводе.
Операция, устанавливающая состояние потока, названа clear() (здесь clear - ясный, правильный), поскольку чаще всего она используется для восстановления состояния потока как good(); значением по умолчанию для параметра ios::clear() является ios::goodbit.
Ввод встроенных типов
Класс istream определяется следующим образом:
class istream : public virtual ios {
//...
public:
istream& operator>>(char*); // строка
istream& operator>>(char&); // символ
istream& operator>>(short&);
istream& operator>>(int&);
istream& operator>>(long&);
istream& operator>>(float&);
istream& operator>>(double&);
//...
};
Функции ввода operator>> определяются так:
istream& istream::operator>>(T& tvar)
{
// пропускаем обобщенные пробелы
// каким-то образом читаем T в`tvar'
return *this;
}
Теперь можно ввести в VECTOR последовательность целых, разделяемых пробелами, с помощью функции:
int readints(Vector<int>& v)
// возвращаем число прочитанных целых
{
for (int i = 0; i<v.size(); i++)
{
if (cin>>v[i]) continue;
return i;
}
// слишком много целых для размера Vector
// нужна соответствующая обработка ошибки
}
Появление значения с типом, отличным от int, приводит к прекращению операции ввода, и цикл ввода завершается. Так, если мы вводим
1 2 3 4 5. 6 7 8.
то функция readints() прочитает пять целых чисел
1 2 3 4 5
Символ точка останется первым символом, подлежащим вводу. Под пробелом, как определено в стандарте С, понимается обобщенный пробел, т.е. пробел, табуляция, конец строки, перевод строки или возврат каретки. Проверка на обобщенный пробел возможна с помощью функции isspace() из файла <ctype.h>.
В качестве альтернативы можно использовать функции get():
class istream : public virtual ios {
//...
istream& get(char& c); // символ
istream& get(char* p, int n, char ='n'); // строка
};
В них обобщенный пробел рассматривается как любой другой символ и они предназначены для таких операций ввода, когда не делается никаких предположений о вводимых символах.
Функция istream::get(char&) вводит один символ в свой параметр. Поэтому программу посимвольного копирования можно написать так:
main()
{
char c;
while (cin.get(c)) cout << c;
}
Такая запись выглядит несимметрично, и у операции >> для вывода символов
есть двойник под именем put(), так что можно писать и так:
main()
{
char c;
while (cin.get(c)) cout.put(c);
}
Функция с тремя параметрами istream::get() вводит в символьный вектор не менее n символов, начиная с адреса p. При всяком обращении к get() все символы, помещенные в буфер (если они были), завершаются 0, поэтому если второй параметр равен n, то введено не более n-1 символов. Третий параметр определяет символ, завершающий ввод. Типичное использование функции get() с тремя параметрами сводится к чтению строки в буфер заданного размера для ее дальнейшего разбора, например так:
void f()
{
char buf[100];
cin >> buf; // подозрительно
cin.get(buf,100,'\n'); // надежно
//...
}
Операция cin>>buf подозрительна, поскольку строка из более чем 99 символов переполнит буфер. Если обнаружен завершающий символ, то он остается в потоке первым символом подлежащим вводу. Это позволяет проверять буфер на переполнение:
void f()
{
char buf[100];
cin.get(buf,100,'\n'); // надежно
char c;
if (cin.get(c) && c!='\n') {
// входная строка больше, чем ожидалось
}
//...
}
Естественно, существует версия get() для типа unsigned char.
В стандартном заголовочном файле <ctype.h> определены несколько
функций, полезных для обработки при вводе:
int isalpha(char) // 'a'..'z' 'A'..'Z'
int isupper(char) // 'A'..'Z'
int islower(char) // 'a'..'z'
int isdigit(char) // '0'..'9'
int isxdigit(char) // '0'..'9' 'a'..'f' 'A'..'F'
int isspace(char) // ' ' '\t' возвращает конец строки
// и перевод формата
int iscntrl(char) // управляющий символ в диапазоне
// (ASCII 0..31 и 127)
int ispunct(char) // знак пунктуации, отличен от
// приведенных выше
int isalnum(char) // isalpha() | isdigit()
int isprint(char) // видимый: ascii ' '..'~'
int isgraph(char) // isalpha() | isdigit() | ispunct()
int isascii(char c) { return 0<=c && c<=127; }
Все они, кроме isascii(), работают с помощью простого просмотра, используя символ как индекс в таблице атрибутов символов. Поэтому вместо выражения типа
(('a'<=c && c<='z') || ('A'<=c && c<='Z')) // буква
которое не только утомительно писать, но оно может быть и ошибочным (на машине с кодировкой EBCDIC оно задает не только буквы), лучше использовать вызов стандартной функции isalpha(), который к тому же более эффективен. В качестве примера приведем функцию eatwhite(), которая читает из потока обобщенные пробелы:
istream& eatwhite(istream& is)
{
char c;
while (is.get(c)) {
if (isspace(c)==0) {
is.putback(c);
break;
}
}
return is;
}
В ней используется функция putback(), которая возвращает символ в поток, и он становится первым подлежащим чтению.
Ввод-вывод в С
Поскольку текст программ на С и на С++ часто путают, то путают иногда и потоковый ввод-вывод С++ и функции ввода-вывода семейства printf для языка С. Далее, т.к. С-функции можно вызывать из программы на С++, то многие предпочитают использовать более знакомые функции ввода-вывода С.
По этой причине здесь будет дана основа функций ввода-вывода С. Обычно операции ввода-вывода на С и на С++ могут идти по очереди на уровне строк. Перемешивание их на уровне посимвольного ввода-вывода возможно для некоторых реализаций, но такая программа может быть непереносимой. Некоторые реализации потоковой библиотеки С++ при допущении ввода-вывода на С требуют вызова статической функции-члена ios::sync_with_stdio().
В общем, потоковые функции вывода имеют перед стандартной функцией С printf() то преимущество, что потоковые функции обладают определенной типовой надежностью и единообразно определяют вывод объектов предопределенного и пользовательского типов.
Основная функция вывода С есть
int printf(const char* format, ...)
и она выводит произвольную последовательность параметров в формате,
задаваемом строкой форматирования format. Строка форматирования состоит из объектов двух типов: простые символы, которые просто копируются в выходной поток, и спецификации преобразований, каждая из которых преобразует и печатает очередной параметр. Каждая спецификация преобразования начинается с символа %, например
printf("there were %d members present.",no_of_members);
Здесь %d указывает, что no_of_members следует считать целым и печатать как соответствующую последовательность десятичных цифр. Если no_of_members==127, то будет напечатано
there were 127 members present.
Набор спецификаций преобразований достаточно большой и обеспечивает большую гибкость печати. За символом % может следовать:
- необязательный знак минус, задающий выравнивание влево в указанном поле для преобразованного значения;
d необязательная строка цифр, задающая ширину поля; если в преобразованном значении меньше символов, чем ширина строки, то оно дополнится до ширины поля пробелами слева (или справа, если дана спецификация выравнивания влево); если строка ширины поля начинается с нуля, то дополнение будет проводится нулями, а не пробелами;
. необязательный символ точка служит для отделения ширины поля от последующей строки цифр;
d необязательная строка цифр, задающая точность, которая определяет число цифр после десятичной точки для значений в спецификациях e или f, или же задает максимальное число печатаемых символов строки;
* для задания ширины поля или точности может использоваться * вместо строки цифр. В этом случае должен быть параметр целого типа, который содержит значение ширины поля или точности;
h необязательный символ h указывает, что последующая спецификация d, o, x или u относится к параметру типа короткое целое;
l необязательный символ l указывает, что последующая спецификация d, o, x или u относится к параметру типа длинное целое;
% обозначает, что нужно напечатать сам символ %; параметр не нужен;
c символ, указывающий тип требуемого преобразования. Символы преобразования и их смысл следующие:
d Целый параметр выдается в десятичной записи;
o Целый параметр выдается в восьмеричной записи;
x Целый параметр выдается в шестнадцатеричной записи;
f Вещественный или с двойной точностью параметр выдается в десятичной записи вида [-]ddd.ddd, где число цифр после точки равно спецификации точности для параметра. Если точность не задана, печатается шесть цифр; если явно задана точность 0, точка и цифры после нее не печатаются;
e Вещественный или с двойной точностью параметр выдается в десятичной записи вида [-]d.ddde+dd; здесь одна цифра перед точкой, а число цифр после точки равно спецификации точности для параметра; если она не задана печатается шесть цифр;
g Вещественный или с двойной точностью параметр печатается по той спецификации d, f или e, которая дает большую точность при меньшей ширине поля;
c Символьный параметр печатается. Нулевые символы игнорируются;
s Параметр считается строкой (символьный указатель), и печатаются символы из строки до нулевого символа или до достижения числа символов, равного спецификации точности; но, если точность равна 0 или не указана, печатаются все символы до нулевого;
p Параметр считается указателем и его вид на печати зависит от реализации;
u Беззнаковый целый параметр печатается в десятичной записи.
Несуществующее поле или поле с шириной, меньшей реальной, приведет к усечению поля. Дополнение пробелами происходит, если только спецификация ширины поля больше реальной ширины. Ниже приведен более сложный пример:
char* src_file_name;
int line;
char* line_format = "\n#line %d \"%s\"\n";
main()
{
line = 13;
src_file_name = "C++/main.c";
printf("int a;\n");
printf(line_format,line,src_file_name);
printf("int b;\n");
}
в котором печатается
int a;
#line 13 "C++/main.c"
int b;
Использование printf() ненадежно в том смысле, что нет никакого контроля типов. Так, ниже приведен известный способ получения неожиданного результата - печати мусорного значения или чего похуже:
char x;
// ...
printf("bad input char: %s",x);
Однако, эти функции обеспечивают большую гибкость и знакомы программирующим на С.
Как обычно, getchar() позволяет знакомым способом читать символы из входного потока:
int i;:
while ((i=getchar())!=EOF) { // символьный ввод C
// используем i
}
Обратите внимание: чтобы было законным сравнение с величиной EOF типа int при проверке на конец файла, результат getchar() надо помещать в переменную типа int, а не char.
За подробностями о вводе-выводе на С отсылаем к вашему руководству по С или книге Кернигана и Ритчи "Язык программирования С".
Выбирающие операторы
Значение можно проверить с помощью операторов if или switch:
if ( выражение ) оператор
if ( выражение ) оператор else оператор
switch ( выражение ) оператор
В языке С++ среди основных типов нет отдельного булевского (тип со значениями истина, ложь). Все операции отношений:
== != < > <= >=
дают в результате целое 1, если отношение выполняется, и 0 в противном
случае. Обычно определяют константы TRUE как 1 и FALSE как 0.
В операторе if, если выражение имеет ненулевое значение, выполняется первый оператор, а иначе выполняется второй (если он указан). Таким образом, в качестве условия допускается любое выражение типа целое или указатель. Пусть a целое, тогда
if (a) // ...
эквивалентно
if (a != 0) ...
Логические операции
&& || !
обычно используются в условиях. В операциях && и || второй операнд
не вычисляется, если результат определяется значением первого операнда. Например, в выражении
if (p && l<p->count) // ...
сначала проверяется значение p, и только если оно не равно нулю, то проверяется отношение l<p->count. Некоторые простые операторы if удобно заменять выражениями условия. Например, вместо оператора
if (a <= b)
max = b;
else
max = a;
лучше использовать выражение
max = (a<=b) ? b : a;
Условие в выражении условия не обязательно окружать скобками, но если их использовать, то выражение становится понятнее.
Простой переключатель (switch) можно записать с помощью серии операторов if. Например,
switch (val) {
case 1:
f();
break;
case 2:
g();
break;
default:
h();
break;
}
можно эквивалентно задать так:
if (val == 1)
f();
else if (val == 2)
g();
else
h();
Смысл обеих конструкций совпадает, но все же первая предпочтительнее, поскольку в ней нагляднее показана суть операции: проверка на совпадение значения val со значением из множества констант. Поэтому в нетривиальных случаях запись, использующая переключатель, понятнее.
Нужно позаботиться о каком-то завершении оператора, указанного в варианте переключателя, если только вы не хотите, чтобы стали выполняться операторы из следующего варианта. Например, переключатель
switch (val) { // возможна ошибка
case 1:
cout << "case 1\n";
case 2:
cout << "case 2\n";
default:
cout << "default: case not found\n";
}
при val==1 напечатает к большому удивлению непосвященных:
case 1
case 2
default: case not found
Имеет смысл отметить в комментариях те редкие случаи, когда стандартный переход на следующий вариант оставлен намеренно. Тогда этот переход во всех остальных случаях можно смело считать ошибкой. Для завершения оператора в варианте чаще всего используется break, но иногда используются return и даже goto. Приведем пример:
switch (val) { // возможна ошибка
case 0:
cout << "case 0\n";
case1:
case 1:
cout << "case 1\n";
return;
case 2:
cout << "case 2\n";
goto case1;
default:
cout << "default: case not found\n";
return;
}
Здесь при значении val равном 2 мы получим:
case 2
case 1
Отметим, что метку варианта нельзя использовать в операторе goto:
goto case 2; // синтаксическая ошибка
Выравнивание полей
С помощью обращений к setf() можно управлять расположением символов
в пределах поля:
cout.setf(ios::left,ios::adjustfield); // влево
cout.setf(ios::right,ios::adjustfield); // вправо
cout.setf(ios::internal,ios::adjustfield); // внутреннее
Будет установлено выравнивание в поле вывода, определяемом функцией ios::width(), причем не затрагивая других компонентов состояния потока.
Выравнивание можно задать следующим образом:
cout.width(4);
cout << '(' << -12 << ")\n";
cout.width(4);
cout.setf(ios::left,ios::adjustfield);
cout << '(' << -12 << ")\n";
cout.width(4);
cout.setf(ios::internal,ios::adjustfield);
cout << '(' << -12 << "\n";
что выдаст
( -12)
(-12 )
(- 12)
Если установлен флаг выравнивания internal (внутренний), то символы добавляются между знаком и величиной. Как видно, стандартным является выравнивание вправо.
Выражение признательности
Кроме лиц, перечисленных в соответствующем разделе предисловия к первому изданию книги, мне хотелось бы выразить свою благодарность Элу Эхо, Стиву Бароффу, Джиму Коплину, Тому Хансену, Петеру Джаглу, Брайану Кернигану, Эндрю Кенигу, Биллу Леггету, Лоррейн Мингаччи, Уоррену Монтгомери, Майку Моубри, Робу Мюррею, Джонатану Шапиро, Майку Вилоту и Петеру Вейнбергу за комментарии черновых вариантов второго издания книги. В развитии языка С++ за период от 1985 до 1991 гг. принимали участие многие специалисты. Я могу упомянуть лишь нескольких из них: Эндрю Кенига, Брайана Кернигана, Дага Макилроя и Джонатана Шапиро. Кроме того, выражаю признательность многим участникам создания справочного руководства С++, предложившим свои варианты, а также тем, с кем довелось нести тяжкую ношу в течение первого года работы комитета X3J16 по стандартизации языка С++.
Мюррей-Хилл, шт.Нью Джерси Бьерн Страуструп
Язык С++ никогда бы не стал реальностью без, если бы постоянно не использовались предложения и советы и не учитывалась конструктивная критика со стороны многих друзей и коллег. Особенно следует упомянуть Тома Карджила, Джима Копли, Стью Фельдмана, Сэнди Фрэзера, Стива Джонсона, Брайана Кернигана, Барта Локанти, Дага Макилроя, Дэнниса Ритчи, Лэрри Рослера, Джерри Шварца и Джона Шапиро, которые внесли важные для развития языка идеи. Дэйв Пресотто реализовал текущую версию библиотеки потокового ввода/вывода.
Свой вклад в развитие С++ и создание транслятора внесли сотни людей, которые присылали мне предложения по совершенствованию языка, описания трудностей, с которыми они сталкивались, и ошибки транслятора. Здесь я могу упомянуть лишь некоторых из них: Гари Бишопа, Эндрю Хьюма, Тома Карцеса, Виктора Миленковича, Роба Мюррэя, Леони Росс, Брайана Шмальта и Гарри Уокера.
Многие участвовали в подготовке книги к изданию, особенно Джон Бентли, Лаура Ивс, Брайан Керниган, Тэд Ковальски, Стив Махани, Джон Шапиро и участники семинара по языку С++, который проводился фирмой Bell Labs в Колумбии, Огайо, 26-27 июня 1985 г.
Мюррей-Хилл, шт.Нью-Джерси Бьерн Страуструп
ВЫВОД
Строгую типовую и единообразную работу как со встроенными, так и с пользовательскими типами можно обеспечить, если использовать единственное перегруженное имя функции для различных операций вывода. Например:
put(cerr,"x = "); // cerr - выходной поток ошибок
put(cerr,x);
put(cerr,'\n');
Тип аргумента определяет какую функцию надо вызывать в каждом случае.
Такой подход применяется в нескольких языках, однако, это слишком длинная запись. За счет перегрузки операции << , чтобы она означала "вывести" ("put to"), можно получить более простую запись и разрешить программисту выводить в одном операторе последовательность объектов, например так:
cerr << "x = " << x << '\n';
Здесь cerr обозначает стандартный поток ошибок. Так, если х типа int со значением 123, то приведенный оператор выдаст
x = 123
и еще символ конца строки в стандартный поток ошибок. Аналогично, если х
имеет пользовательский тип complex со значением (1,2.4), то указанный оператор выдаст
x = (1,2.4)
в поток cerr. Такой подход легко использовать пока x такого типа, для
которого определена операция <<, а пользователь может просто доопределить << для новых типов.
Мы использовали операцию вывода, чтобы избежать многословности, неизбежной, если применять функцию вывода. Но почему именно символ << ? Невозможно изобрести новую лексему (см. 7.2). Кандидатом для ввода и вывода была операция присваивания, но большинство людей предпочитает, чтобы операции ввода и вывода были различны. Более того, порядок выполнения операции = неподходящий, так cout=a=b означает cout=(a=b). Пробовали использовать операции < и >, но к ним так крепко привязано понятие "меньше чем" и "больше чем", что операции ввода-вывода с ними во всех практически случаях не поддавались прочтению.
Операции << и >> похоже не создают таких проблем. Они асиметричны,
что позволяет приписывать им смысл "в" и "из". Они не относятся к числу наиболее часто используемых операций над встроенными типами, а приоритет << достаточно низкий, чтобы писать арифметические выражения в качестве операнда без скобок:
cout << "a*b+c=" << a*b+c << '\n';
Скобки нужны, если выражение содержит операции с более низким приоритетом:
cout << "a^b|c=" << (a^b|c) << '\n';
Операцию сдвига влево можно использовать в операции вывода, но, конечно, она должна быть в скобках:
cout << "a<<b=" << (a<<b) << '\n';
Вывод целых
Прием задания нового значения множества флагов с помощью операции | и функций flags() и setf() работает только тогда, когда один бит определяет значение флага. Не такая ситуация при задании системы счисления целых или вида выдачи вещественных. Здесь значение, определяющее вид выдачи, нельзя задать одним битом или комбинацией отдельных битов.
Решение, принятое в <iostream.h>, сводится к использованию версии функции setf(), работающей со вторым "псевдопараметром", который показывает какой именно флаг мы хотим добавить к новому значению.
Поэтому обращения
cout.setf(ios::oct,ios::basefield); // восьмеричное
cout.setf(ios::dec,ios::basefield); // десятичное
cout.setf(ios::hex,ios::basefield); // шестнадцатеричное
установят систему счисления, не затрагивая других компонентов состояния потока. Если система счисления установлена, она используется до явной переустановки, поэтому
cout << 1234 << ' '; // десятичное по умолчанию
cout << 1234 << ' ';
cout.setf(ios::oct,ios::basefield); // восьмеричное
cout << 1234 << ' ';
cout << 1234 << ' ';
cout.setf(ios::hex,ios::basefield); // шестнадцатеричное
cout << 1234 << ' ';
cout << 1234 << ' ';
напечатает
1234 1234 2322 2322 4d2 4d2
Если появится необходимость указывать систему счисления для каждого выдаваемого числа, следует установить флаг showbase. Поэтому, добавив перед приведенными выше обращениями
cout.setf(ios::showbase);
мы получим
1234 1234 02322 02322 0x4d2 0x4d2
Стандартные манипуляторы, приведенные в $$10.4.2.1, предлагают более элегантный способ определения системы счисления при выводе целых.
Вывод плавающих чисел.
Вывод вещественных величин также управляется с помощью функций, работающих с состоянием потока. В частности, обращения:
cout.setf(ios::scientific,ios::floatfield);
cout.setf(ios::fixed,ios::floatfield);
cout.setf(0,ios::floatfield); // вернуться к стандартному
установят вид печати вещественных чисел без изменения других компонентов состояния потока. Например:
cout << 1234.56789 << '\n';
cout.setf(ios::scientific,ios::floatfield);
cout << 1234.56789 << '\n';
cout.setf(ios::fixed,ios::floatfield);
cout << 1234.56789 << '\n';
напечатает
1234.57
1.234568e+03
1234.567890
После точки печатается n цифр, как задается в обращении
cout.precision(n)
По умолчанию n равно 6. Вызов функции precision влияет на все операции
ввода-вывода с вещественными до следующего обращения к precision, поэтому
cout.precision(8);
cout << 1234.56789 << '\n';
cout << 1234.56789 << '\n';
cout.precision(4);
cout << 1234.56789 << '\n';
cout << 1234.56789 << '\n';
выдаст
1234.5679
1234.5679
1235
1235
Заметьте, что происходит округление, а не отбрасывание дробной части.
Стандартные манипуляторы, введенные в $$10.4.2.1, предлагают более элегантный способ задания формата вывода вещественных.
Вывод пользовательских типов
Рассмотрим пользовательский тип данных:
class complex {
double re, im;
public:
complex(double r = 0, double i = 0) { re=r; im=i; }
friend double real(complex& a) { return a.re; }
friend double imag(complex& a) { return a.im; }
friend complex operator+(complex, complex);
friend complex operator-(complex, complex);
friend complex operator*(complex, complex);
friend complex operator/(complex, complex);
//...
};
Для нового типа complex операцию << можно определить так:
ostream& operator<<(ostream&s, complex z)
{
return s << '(' real(z) << ',' << imag(z) << ')';
};
и использовать как operator<< для встроенных типов. Например,
main()
{
complex x(1,2);
cout << "x = " << x << '\n';
}
выдаст
x = (1,2)
Для определения операции вывода над пользовательскими типами данных не нужно модифицировать описание класса ostream, не требуется и доступ к структурам данных, скрытым в описании класса. Последнее очень кстати, поскольку описание класса ostream находится среди стандартных заголовочных файлов, доступ по записи к которым закрыт для большинства пользователей, и изменять которые они вряд ли захотят, даже если бы могли. Это важно и по той причине, что дает защиту от случайной порчи этих структур данных. Кроме того имеется возможность изменить реализацию ostream, не затрагивая пользовательских программ.
Вывод встроенных типов
Для управления выводом встроенных типов определяется класс ostream с операцией << (вывести):
class ostream : public virtual ios {
// ...
public:
ostream& operator<<(const char*); //строки
ostream& operator<<(char);
ostream& operator<<(short i)
{ return *this << int(i); }
ostream& operator<<(int);
ostream& operator<<(long);
ostream& operator<<(double);
ostream& operator<<(const void*); // указатели
// ...
};
Естественно, в классе ostream должен быть набор функций operator<<() для работы с беззнаковыми типами.
Функция operator<< возвращает ссылку на класс ostream, из которого она вызывалась, чтобы к ней можно было применить еще раз operator<<. Так, если х типа int, то
cerr << "x = " << x;
понимается как
(cerr.operator<<("x = ")).operator<<(x);
В частности, это означает, что если несколько объектов выводятся с помощью одного оператора вывода, то они будут выдаваться в естественном порядке: слева - направо.
Функция ostream::operator<<(int) выводит целые значения, а функция ostream::operator<<(char) - символьные. Поэтому функция
void val(char c)
{
cout << "int('"<< c <<"') = " << int(c) << '\n';
}
печатает целые значения символов и с помощью программы
main()
{
val('A');
val('Z');
}
будет напечатано
int('A') = 65
int('Z') = 90
Здесь предполагается кодировка символов ASCII, на вашей машине может быть
иной результат. Обратите внимание, что символьная константа имеет тип char, поэтому cout<<'Z' напечатает букву Z, а вовсе не целое 90.
Функция ostream::operator<<(const void*) напечатает значение указателя в такой записи, которая более подходит для используемой системы адресации. Программа
main()
{
int i = 0;
int* p = new int(1);
cout << "local " << &i
<< ", free store " << p << '\n';
}
выдаст на машине, используемой автором,
local 0x7fffead0, free store 0x500c
Для других систем адресации могут быть иные соглашения об изображении
значений указателей.
Обсуждение базового класса ios отложим до 10.4.1.
Вызов функции
Вызов функции, т.е. конструкцию выражение(список-выражений), можно рассматривать как бинарную операцию, в которой выражение является левым операндом, а список-выражений - правым. Операцию вызова можно перегружать как и другие операции. В функции operator()() список фактических параметров вычисляется и проверяется по типам согласно обычным правилам передачи параметров. Перегрузка операции вызова имеет смысл прежде всего для типов, с которыми возможна только одна операция, а также для тех типов, одна из операций над которыми имеет настолько важное значение, что все остальные в большинстве случаев можно не учитывать.
Мы не дали определения итератора для ассоциативного массива типа assoc. Для этой цели можно определить специальный класс assoc_iterator, задача которого выдавать элементы из assoc в некотором порядке. В итераторе необходимо иметь доступ к данным, хранимым в assoc, поэтому он должен быть описан как friend:
class assoc {
friend class assoc_iterator;
pair* vec;
int max;
int free;
public:
assoc(int);
int& operator[](const char*);
};
Итератор можно определить так:
class assoc_iterator {
const assoc* cs; // массив assoc
int i; // текущий индекс
public:
assoc_iterator(const assoc& s) { cs = &s; i = 0; }
pair* operator()()
{ return (i<cs->free)? &cs->vec[i++] : 0; }
};
Массив assoc объекта assoc_iterator нужно инициализировать, и при каждом
обращении к нему с помощью операторной функции () будет возвращаться указатель на новую пару (структура 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(vec) )
cout << p->name << ": " << p->val << '\n';
}
Итератор подобного вида имеет преимущество перед набором функций, решающим ту же задачу: итератор может иметь собственные частные данные, в которых можно хранить информацию о ходе итерации. Обычно важно и то, что можно одновременно запустить сразу несколько итераторов одного типа.
Конечно, использование объектов для представления итераторов непосредственно никак не связано с перегрузкой операций. Одни предпочитают использовать тип итератора с такими операциями, как first(), next() и last(), другим больше нравится перегрузка операции ++ , которая позволяет получить итератор, используемый как указатель (см. $$8.8). Кроме того, операторная функция operator() активно используется для выделения подстрок и индексации многомерных массивов.
Функция operator() должна быть функцией-членом.
Задание интерфейса
Запуск или перехват особой ситуации отражается на взаимоотношениях функций. Поэтому имеет смысл задавать в описании функции множество особых ситуаций, которые она может запустить:
void f(int a) throw (x2, x3, x4);
В этом описании указано, что f() может запустить особые ситуации x2, x3 и x4, а также ситуации всех производных от них типов, но больше никакие ситуации она не запускает. Если функция перечисляет свои особые ситуации, то она дает определенную гарантию всякой вызывающей ее функции, а именно, если попытается запустить иную особую ситуацию, то это приведет к вызову функции unexpected(). Стандартное предназначение unexpected() состоит в вызове функции terminate(), которая, в свою очередь, обычно вызывает abort(). Подробности даны в $$9.7.
По сути определение
void f() throw (x2, x3, x4)
{
// какие-то операторы
}
эквивалентно такому определению
void f()
{
try {
// какие-то операторы
}
catch (x2) { // повторный запуск
throw;
}
catch (x3) { // повторный запуск
throw;
}
catch (x4) { // повторный запуск
throw;
}
catch (...) {
unexpected();
}
}
Преимущество явного задания особых ситуаций функции в ее описании перед эквивалентным способом, когда происходит проверка на особые ситуации в теле функции, не только в более краткой записи. Главное здесь в том, что описание функции входит в ее интерфейс, который видим для всех вызывающих функций. С другой стороны, определение функции может и не быть универсально доступным. Даже если у вас есть исходные тексты всех библиотечных функций, обычно желание изучать их возникает не часто.
Если в описании функции не указаны ее особые ситуации, считается, что она может запустить любую особую ситуацию.
int f(); // может запустить любую особую ситуацию
Если функция не будет запускать никаких особых ситуаций, ее можно описать, явно указав пустой список:
int g() throw (); // не запускает никаких особых ситуаций
Казалось было бы логично, чтобы по умолчанию функция не запускала никаких особых ситуаций. Но тогда пришлось бы описывать свои особые ситуации практически для каждой функции Это, как правило, требовало бы ее перетрансляции, а кроме того препятствовало бы общению с функциями, написанными на других языках. В результате программист стал бы стремиться отключить механизм особых ситуаций и писал бы излишние операторы, чтобы обойти их. Пользователь считал бы такие программы надежными, поскольку мог не заметить подмены, но это было бы совершенно неоправдано.
Задание реализации с помощью параметров шаблона
В контейнерных классах часто приходится выделять память. Иногда бывает необходимо (или просто удобно) дать пользователю возможность выбирать из нескольких вариантов выделения памяти, а также позволить ему задавать свой вариант. Это можно сделать несколькими способами. Один из способов состоит в том, что определяется шаблон типа для создания нового класса, в интерфейс которого входит описание соответствующего контейнера и класса, производящего выделение памяти по способу, описанному в $$6.7.2:
template<class T, class A> class Controlled_container
: public Container<T>, private A {
// ...
void some_function()
{
// ...
T* p = new(A::operator new(sizeof(T))) T;
// ...
}
// ...
};
Шаблон типа здесь необходим, поскольку мы создаем контейнерный класс. Наследование от Container<T> нужно, чтобы класс Controlled_container можно было использовать как контейнерный класс. Шаблон типа с параметром A позволит нам использовать различные функции размещения:
class Shared : public Arena { /* ... */ };
class Fast_allocator { /* ... */ };
Controlled_container<Process_descriptor,Shared> ptbl;
Controlled_container<Node,Fast_allocator> tree;
Controlled_container<Personell_record,Persistent> payroll;
Это универсальный способ предоставлять производным классам содержательную информацию о реализации. Его положительными качествами являются систематичность и возможность использовать функции-подстановки. Для этого способа характерны необычно длинные имена. Впрочем, как обычно, typedef позволяет задать синонимы для слишком длинных имен типов:
typedef
Controlled_container<Personell_record,Persistent> pp_record;
pp_record payroll;
Обычно шаблон типа для создания такого класса как pp_record используют только в том случае, когда добавляемая информация по реализации достаточно существенна, чтобы не вносить ее в производный класс ручным программированием. Примером такого шаблона может быть общий (возможно, для некоторых библиотек стандартный) шаблонный класс Comparator ($$8.4.2), а также нетривиальные (возможно, стандартные для некоторых библиотек) классы Allocator (классы для выделения памяти). Отметим, что построение производных классов в таких примерах идет по "основному проспекту", который определяет интерфейс с пользователем (в нашем примере это Container). Но есть и "боковые улицы", задающие детали реализации.
Заголовочные файлы
Типы одного объекта или функции должны быть согласованы во всех их описаниях. Должен быть согласован по типам и входной текст, обрабатываемый транслятором, и связываемые части программы. Есть простой, хотя и несовершенный, способ добиться согласованности описаний в различных файлах. Это: включить во входные файлы, содержащие операторы и определения данных, заголовочные файлы, которые содержат интерфейсную информацию.
Средством включения текстов служит макрокоманда #include, которая позволяет собрать в один файл (единицу трансляции) несколько исходных файлов программы. Команда
#include "включаемый-файл"
заменяет строку, в которой она была задана, на содержимое файла включаемый-файл. Естественно, это содержимое должно быть текстом на С++, поскольку его будет читать транслятор. Как правило, операция включения реализуется отдельной программой, называемой препроцессором С++. Она вызывается системой программирования перед собственно трансляцией для обработки таких команд во входном тексте. Возможно и другое решение: часть транслятора, непосредственно работающая с входным текстом, обрабатывает команды включения файлов по мере их появления в тексте. В той системе программирования, в которой работает автор, чтобы увидеть результат команд включения файлов, нужно задать команду:
CC -E file.c
Эта команда для обработки файла file.c запускает препроцессор (и только!), подобно тому, как команда CC без флага -E запускает сам транслятор.
Для включения файлов из стандартных каталогов (обычно каталоги с именем INCLUDE) надо вместо кавычек использовать угловые скобки < и >. Например:
#include <stream.h> // включение из стандартного каталога
#include "myheader.h" // включение из текущего каталога
Включение из стандартных каталогов имеет то преимущество, что имена этих каталогов никак не связаны с конкретной программой (обычно вначале включаемые файлы ищутся в каталоге /usr/include/CC, а затем в /usr/include). К сожалению, в этой команде пробелы существенны:
#include < stream.h> // <stream.h> не будет найден
Было бы нелепо, если бы каждый раз перед включением файла требовалась его перетрансляция. Обычно включаемые файлы содержат только описания, а не операторы и определения, требующие существенной трансляторной обработки. Кроме того, система программирования может предварительно оттранслировать заголовочные файлы, если, конечно, она настолько развита, что способна сделать это, не изменяя семантики программы.
Укажем, что может содержать заголовочный файл:
Определения типов struct point { int x, y; };
Шаблоны типов template<class T>
class V { /* ... */ }
Описания функций extern int strlen(const char*);
Определения inline char get() { return *p++; }
функций-подстановок
Описания данных extern int a;
Определения констант const float pi = 3.141593;
Перечисления enum bool { false, true };
Описания имен class Matrix;
Команды включения файлов #include <signal.h>
Макроопределения #define Case break;case
Комментарии /* проверка на конец файла */
Перечисление того, что стоит помещать в заголовочный файл, не является требованием языка, это просто совет по разумному использованию включения файлов. С другой стороны, в заголовочном файле никогда не должно быть:
Определений обычных функций char get() { return *p++; }
Определений данных int a;
Определений составных констант const tb[i] = { /* ... */ };
По традиции заголовочные файлы имеют расширение .h, а файлы, содержащие определения функций или данных, расширение .c. Иногда их называют "h-файлы" или "с-файлы" соответственно. Используют и другие расширения для этих файлов: .C, cxx, .cpp и .cc. Принятое расширение вы найдете в своем справочном руководстве. Макросредства описываются в $$4.7. Отметим только, что в С++ они используются не столь широко, как в С, поскольку С++ имеет определенные возможности в самом языке: определения констант (const), функций-подстановок (inline), дающие возможность более простой операции вызова, и шаблонов типа, позволяющие порождать семейство типов и функций ($$8).
Совет помещать в заголовочный файл определения только простых, но не составных, констант объясняется вполне прагматической причиной. Просто большинство трансляторов не настолько разумно, чтобы предотвратить создание ненужных копий составной константы. Вообще говоря, более простой вариант всегда является более общим, а значит транслятор должен учитывать его в первую очередь, чтобы создать хорошую программу.
Законченный пример класса
Программирование без упрятывания данных (в расчете на структуры) требует меньшего предварительного обдумывания задачи, чем программирование с упрятыванием данных (в расчете на классы). Структуру можно определить не очень задумываясь о том, как ее будут использовать. Когда определяется класс, внимание концентрируется на том, чтобы обеспечить для нового типа полный набор операций. Это важное смещение акцента в проектировании программ. Обычно время, затраченное на разработку нового типа, многократно окупается в процессе отладки и развития программы.
Вот пример законченного определения типа 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.
Закрытие потоков
Файл может быть закрыт явно, если вызвать close() для его потока:
mystream.close();
Но это неявно делает деструктор потока, так что явный вызов close() может понадобиться, если только файл нужно закрыть до достижения конца области определенности потока.
Здесь возникает вопрос, как реализация может обеспечить создание предопределенных потоков cout, cin и cerr до их первого использования и закрытие их только после последнего использования. Конечно, разные реализации библиотеки потоков из <iostream.h> могут по-разному решать эту задачу. В конце концов, решение – это прерогатива реализации, и оно должно быть скрыто от пользователя. Здесь приводится только один способ, примененный только в одной реализации, но он достаточно общий, чтобы гарантировать правильный порядок создания и уничтожения глобальных объектов различных типов.
Основная идея в том, чтобы определить вспомогательный класс, который по сути служит счетчиком, следящим за тем, сколько раз <iostream.h> был включен в раздельно компилировавшиеся программные файлы:
class Io_init {
static int count;
//...
public:
Io_init();
^Io_init();
};
static Io_init io_init ;
Для каждого программного файла определен свой объект с именем io_init.
Конструктор для объектов io_init использует Io_init::count как первый признак того, что действительная инициализация глобальных объектов потоковой библиотеки ввода-вывода сделана в точности один раз:
Io_init::Io_init()
{
if (count++ == 0) {
// инициализировать cout
// инициализировать cerr
// инициализировать cin
// и т.д.
}
}
Обратно, деструктор для объектов io_init использует Io_count, как последнее указание на то, что все потоки закрыты:
Io_init::^Io_init()
{
if (--count == 0) {
// очистить cout (сброс, и т.д.)
// очистить cerr (сброс, и т.д.)
// очистить cin
// и т.д.
}
}
Это общий прием работы с библиотеками, требующими инициализации и удаления глобальных объектов. Впервые в С++ его применил Д. Шварц. В системах, где при выполнении все программы размещаются в основной памяти, для этого приема нет помех. Если это не так, то накладные расходы, связанные с вызовом в память каждого программного файла для выполнения функций инициализации, будут заметны. Как всегда, лучше, по возможности, избегать глобальных объектов. Для классов, в которых каждая операция значительна по объему выполняемой работы, чтобы гарантировать инициализацию, было бы разумно проверять такие первые признаки (наподобие Io_init::count) при каждой операции. Однако, для потоков такой подход был бы излишне расточительным.
Замечание для программистов на С
Чем лучше программист знает С, тем труднее будет для него при программировании на С++ отойти от стиля программирования на С. Так он теряет потенциальные преимущества С++. Поэтому советуем просмотреть раздел "Отличия от С" в справочном руководстве ($$R.18). Здесь мы только укажем на те места, в которых использование дополнительных возможностей С++ приводит к лучшему решению, чем программирование на чистом С. Макрокоманды практически не нужны в С++: используйте const ($$2.5) или enum ($$2.5.1), чтобы определить поименованные константы; используйте inline ($$4.6.2), чтобы избежать расходов ресурсов, связанных с вызовом функций; используйте шаблоны типа ($$8), чтобы задать семейство функций и типов. Не описывайте переменную, пока она действительно вам не понадобится, а тогда ее можно сразу инициализировать, ведь в С++ описание может появляться в любом месте, где допустим оператор. Не используйте malloc(), эту операцию лучше реализует new ($$3.2.6). Объединения нужны не столь часто, как в С, поскольку альтернативность в структурах реализуется с помощью производных классов. Старайтесь обойтись без объединений, но если они все-таки нужны, не включайте их в основные интерфейсы; используйте безымянные объединения ($$2.6.2). Старайтесь не использовать указателей типа void*, арифметических операций с указателями, массивов в стиле С и операций приведения. Если все-таки вы используете эти конструкции, упрятывайте их достаточно надежно в какую-нибудь функцию или класс. Укажем, что связывание в стиле С возможно для функции на С++, если она описана со спецификацией extern "C" ($$4.4).
Но гораздо важнее стараться думать о программе как о множестве взаимосвязанных понятий, представляемых классами и объектами, чем представлять ее как сумму структур данных и функций, что-то делающих с этими данными.
Замечания о программировании на языке С++
Предполагается, что в идеальном случае разработка программы делится на три этапа: вначале необходимо добиться ясного понимания задачи, затем определить ключевые понятия, используемые для ее решения, и, наконец, полученное решение выразить в виде программы. Однако, детали решения и точные понятия, которые будут использоваться в нем, часто проясняются только после того, как их попытаются выразить в программе. Именно в этом случае большое значение приобретает выбор языка программирования.
Во многих задачах используются понятия, которые трудно представить в
программе в виде одного из основных типов или в виде функции без связанных с ней статических данных. Такое понятие может представлять в программе класс. Класс - это тип; он определяет поведение связанных с ним объектов: их создание, обработку и уничтожение. Кроме этого, класс определяет реализацию объектов в языке, но на начальных стадиях разработки программы это не является и не должно являться главной заботой. Для написания хорошей программы надо составить такой набор классов, в котором каждый класс четко представляет одно понятие. Обычно это означает, что программист должен сосредоточиться на вопросах: Как создаются объекты данного класса? Могут ли они копироваться и (или) уничтожаться? Какие операции можно определить над этими объектами? Если на эти вопросы удовлетворительных ответов не находится, то, скорее всего, это означает, что понятие не было достаточно ясно сформулировано. Тогда, возможно, стоит еще поразмышлять над задачей и предлагаемым решением, а не немедленно приступать к программированию, надеясь в процессе него найти ответы.
Проще всего работать с понятиями, которые имеют традиционную математическую форму представления: всевозможные числа, множества, геометрические фигуры и т.д. Для таких понятий полезно было бы иметь стандартные библиотеки классов, но к моменту написания книги их еще не было. В программном мире накоплено удивительное богатство из таких библиотек, но нет ни формального, ни фактического стандарта на них. Язык С++ еще достаточно молод, и его библиотеки не развились в такой степени, как сам язык.
Понятие не существует в вакууме, вокруг него всегда группируются связанные с ним понятия. Определить в программе взаимоотношения классов, иными словами, установить точные связи между используемыми в задаче понятиями, бывает труднее, чем определить каждый из классов сам по себе. В результате не должно получиться "каши" - когда каждый класс (понятие) зависит от всех остальных. Пусть есть два класса A и B. Тогда связи между ними типа "A вызывает функцию из B", "A создает объекты B", "A имеет член типа B" обычно не вызывают каких-либо трудностей. Связи же типа "A использует данные из B", как правило, можно вообще исключить.
Одно из самых мощных интеллектуальных средств, позволяющих справиться со сложностью, - это иерархическое упорядочение, т.е. упорядочение связанных между собой понятий в древовидную структуру, в которой самое общее понятие находится в корне дерева. Часто удается организовать классы программы как множество деревьев или как направленный ацикличный граф. Это означает, что программист определяет набор базовых классов, каждый из которых имеет свое множество производных классов. Набор операций самого общего вида для базовых классов (понятий) обычно определяется с помощью виртуальных функций ($$6.5). Интерпретация этих операций, по мере надобности, может уточняться для каждого конкретного случая, т.е. для каждого производного класса.
Естественно, есть ограничения и при такой организации программы. Иногда используемые в программе понятия не удается упорядочить даже с помощью направленного ацикличного графа. Некоторые понятия оказываются по своей природе взаимосвязанными. Циклические зависимости не вызовут проблем, если множество взаимосвязанных классов настолько мало, что в нем легко разобраться. Для представления на С++ множества взаимозависимых классов можно использовать дружественные классы ($$5.4.1).
Если понятия программы нельзя упорядочить в виде дерева или направленного ацикличного графа, а множество взаимозависимых понятий не поддается локализации, то, по всей видимости, вы попали в такое затруднительное положение, выйти из которого не сможет помочь ни один из языков программирования. Если вам не удалось достаточно просто сформулировать связи между основными понятиями задачи, то, скорее всего, вам не удастся ее запрограммировать.
Еще один способ выражения общности понятий в языке предоставляют шаблоны типа. Шаблонный класс задает целое семейство классов. Например, шаблонный класс список задает классы вида "список объектов T", где T может быть произвольным типом. Таким образом, шаблонный тип указывает, как получается новый тип из заданного в качестве параметра. Самые типичные шаблонные классы - это контейнеры, в частности, списки, массивы и ассоциативные массивы.
Напомним, что можно легко и просто запрограммировать многие задачи, используя только простые типы, структуры данных, обычные функции и несколько классов из стандартных библиотек. Весь аппарат построения новых типов следует привлекать только тогда, когда он действительно необходим.
Вопрос "Как написать хорошую программу на С++?" очень похож на вопрос "Как пишется хорошая английская проза?". На него есть два ответа: "Нужно знать, что вы, собственно, хотите написать" и "Практика и подражание хорошему стилю". Оба совета пригодны для С++ в той же мере, что и для английского языка, и обоим достаточно трудно следовать.
Замечания по проекту языка
При разработке языка С++ одним из важнейших критериев выбора была простота. Когда возникал вопрос, что упростить: руководство по языку и другую документацию или транслятор, - то выбор делали в пользу первого. Огромное значение придавалось совместимости с языком С, что помешало удалить его синтаксис.
В С++ нет типов данных и элементарных операций высокого уровня. Например, не существует типа матрица с операцией обращения или типа строка с операцией конкатенации. Если пользователю понадобятся подобные типы, он может определить их в самом языке. Программирование на С++ по сути сводится к определению универсальных или зависящих от области приложения типов. Хорошо продуманный пользовательский тип отличается от встроенного типа только способом определения, но не способом применения.
Из языка исключались возможности, которые могут привести к накладным расходам памяти или времени выполнения, даже если они непосредственно не используются в программе. Например, было отвергнуто предложение хранить в каждом объекте некоторую служебную информацию. Если пользователь описал структуру, содержащую две величины, занимающие по 16 разрядов, то гарантируется, что она поместится в 32-х разрядный регистр.
Язык С++ проектировался для использования в довольно традиционной среде, а именно: в системе программирования С операционной системы UNIX. Но есть вполне обоснованные доводы в пользу использования С++ в более богатой программной среде. Такие возможности, как динамическая загрузка, развитые системы трансляции и базы данных для хранения определений типов, можно успешно использовать без ущерба для языка.
Типы С++ и механизмы упрятывания данных рассчитаны на определенный синтаксический анализ, проводимый транслятором для обнаружения случайной порчи данных. Они не обеспечивают секретности данных и защиты от умышленного нарушения правил доступа к ним. Однако, эти средства можно свободно использовать, не боясь накладных расходов памяти и времени выполнения программы. Учтено, что конструкция языка активно используется тогда, когда она не только изящно записывается на нем, но и вполне по средствам обычным программам.
Замечания по реализации
Существует несколько распространяемых независимых реализаций С++. Появилось большое число сервисных программ, библиотек и интегрированных систем программирования. Имеется масса книг, руководств, журналов, статей, сообщений по электронной почте, технических бюллетеней, отчетов о конференциях и курсов, из которых можно получить все необходимые сведения о последних изменениях в С++, его использовании, сервисных программах, библиотеках, новых трансляторах и т.д. Если вы серьезно рассчитываете на С++, стоит получить доступ хотя бы к двум источникам информации, поскольку у каждого источника может быть своя позиция.
Большинство программных фрагментов, приведенных в книге, взяты непосредственно из текстов программ, которые были транслированы на машине DEC VAX 11/8550 под управлением 10-й версии системы UNIX [25]. Использовался транслятор, являющийся прямым потомком транслятора С++, созданного автором. Здесь описывается "чистый С++", т.е. не используются никакие зависящие от реализации расширения. Следовательно, примеры должны идти при любой реализации языка. Однако, шаблоны типа и обработка особых ситуаций относятся к самым последним расширениям языка, и возможно, что ваш транслятор их не содержит.
Запросы ресурсов
Если в некоторой функции потребуются определенные ресурсы, например, нужно открыть файл, отвести блок памяти в области свободной памяти, установить монопольные права доступа и т.д., для дальнейшей работы системы обычно бывает крайне важно, чтобы ресурсы были освобождены надлежащим образом. Обычно такой "надлежащий способ" реализует функция, в которой происходит запрос ресурсов и освобождение их перед выходом. Например:
void use_file(const char* fn)
{
FILE* f = fopen(fn,"w"); // работаем с f
fclose(f);
}
Все это выглядит вполне нормально до тех пор, пока вы не поймете, что при любой ошибке, происшедшей после вызова fopen() и до вызова fclose(), возникнет особая ситуация, в результате которой мы выйдем из use_file(), не обращаясь к fclose(). Стоит сказать, что та же проблема возникает и в языках, не поддерживающих особые ситуации. Так, обращение к функции longjump()из стандартной библиотеки С может иметь такие же неприятные последствия.
Если вы создаете устойчивую к ошибкам системам, эту проблему придется решать. Можно дать примитивное решение:
void use_file(const char* fn)
{
FILE* f = fopen(fn,"w");
try {
// работаем с f
}
catch (...) {
fclose(f);
throw;
}
fclose(f);
}
Вся часть функции, работающая с файлом f, помещена в проверяемый блок, в котором перехватываются все особые ситуации, закрывается файл и особая ситуация запускается повторно.
Недостаток этого решения в его многословности, громоздкости и потенциальной расточительности. К тому же всякое многословное и громоздкое решение чревато ошибками, хотя бы в силу усталости программиста. К счастью, есть более приемлемое решение. В общем виде проблему можно сформулировать так:
void acquire()
{
// запрос ресурса 1
// ...
// запрос ресурса n
// использование ресурсов
// освобождение ресурса n
// ...
// освобождение ресурса 1
}
Как правило бывает важно, чтобы ресурсы освобождались в обратном по сравнению с запросами порядке. Это очень сильно напоминает порядок работы с локальными объектами, создаваемыми конструкторами и уничтожаемыми деструкторами. Поэтому мы можем решить проблему запроса и освобождения ресурсов, если будем использовать подходящие объекты классов с конструкторами и деструкторами. Например, можно определить класс FilePtr, который выступает как тип FILE* :
class FilePtr {
FILE* p;
public:
FilePtr(const char* n, const char* a)
{ p = fopen(n,a); }
FilePtr(FILE* pp) { p = pp; }
~FilePtr() { fclose(p); }
operator FILE*() { return p; }
};
Построить объект FilePtr можно либо, имея объект типа FILE*, либо, получив нужные для fopen() параметры. В любом случае этот объект будет уничтожен при выходе из его области видимости, и его деструктор закроет файл. Теперь наш пример сжимается до такой функции:
void use_file(const char* fn)
{
FilePtr f(fn,"w");
// работаем с f
}
Деструктор будет вызываться независимо от того, закончилась ли функция нормально, или произошел запуск особой ситуации.
Защищенные члены
Дадим пример защищенных членов, вернувшись к классу window из предыдущего раздела. Здесь функции _draw() предназначались только для использования в производных классах, поскольку предоставляли неполный набор возможностей, а поэтому не были достаточны удобны и надежны для общего применения. Они были как бы строительным материалом для более развитых функций. С другой стороны, функции draw() предназначались для общего применения. Это различие можно выразить, разбив интерфейсы классов window на две части - защищенный интерфейс и общий интерфейс:
class window {
public:
virtual void draw();
// ...
protected:
void _draw();
// другие функции, служащие строительным материалом
private:
// представление класса
};
Такое разбиение можно проводить и в производных классах, таких, как window_w_border или window_w_menu.
Префикс _ используется в именах защищенных функций, являющихся частью реализации класса, по общему правилу: имена, начинающиеся с _, не должны присутствовать в частях программы, открытых для общего использования. Имен, начинающихся с двойного символа подчеркивания, лучше вообще избегать (даже для членов).
Вот менее практичный, но более подробный пример:
class X {
// по умолчанию частная часть класса
int priv;
protected:
int prot;
public:
int publ;
void m();
};
Для члена X::m доступ к членам класса неограничен:
void X::m()
{
priv = 1; // нормально
prot = 2; // нормально
publ = 3; // нормально
}
Член производного класса имеет доступ только к общим и защищенным членам:
class Y : public X {
void mderived();
};
Y::mderived()
{
priv = 1; // ошибка: priv частный член
prot = 2; // нормально: prot защищенный член, а
// mderived() член производного класса Y
publ = 3; // нормально: publ общий член
}
В глобальной функции доступны только общие члены:
void f(Y* p)
{
p->priv = 1; // ошибка: priv частный член
p->prot = 2; // ошибка: prot защищенный член, а f()
// не друг или член классов X и Y
p->publ = 3; // нормально: publ общий член
}
Зависимости в рамках иерархии классов.
Естественно, производный класс зависит от своих базовых классов. Гораздо реже учитывают, что обратное также может быть справедливо.
Эту мысль можно выразить таким способом: "Сумасшествие наследуется,
вы можете получить его от своих детей."
Если класс содержит виртуальную функцию, производные классы могут по своему усмотрению решать, реализовывать ли часть операций этой функции каждый раз, когда она переопределяется в производном классе. Если член базового класса сам вызывает одну из виртуальных функций производного класса, тогда реализация базового класса зависит от реализаций его производных классов. Точно так же, если класс использует защищенный член, его реализация будет зависеть от производных классов. Рассмотрим определения:
class B {
//...
protected:
int a;
public:
virtual int f();
int g() { int x = f(); return x-a; }
};
Каков результат работы g()? Ответ существенно зависит от определения f() в некотором производном классе. Ниже приводится вариант, при котором g() будет возвращать 1:
class D1 : public B {
int f() { return a+1; }
};
а при нижеследующем определении g() напечатает "Hello, World" и вернет 0:
class D1 : public {
int f() { cout<<"Hello, World\n"; return a; }
};
Этот пример демонстрирует один из важнейших моментов, связанных с виртуальными функциями. Хотя вы можете сказать, что это глупость, и программист никогда не напишет ничего подобного. Дело здесь в том, что виртуальная функция является частью интерфейса с базовым классом, и что этот класс будет, по всей видимости, использоваться без информации о его производных классах. Следовательно, можно так описать поведение объекта базового класса, чтобы в дальнейшем писать программы, ничего не зная о его производных классах.
Всякий класс, который переопределяет производную функцию, должен реализовать вариант этой функции. Например, виртуальная функция rotate() из класса Shape вращает геометрическую фигуру, а функции rotate() для производных классов, таких, как Circle и Triangle, должны вращать объекты соответствующих типов, иначе будет нарушено основное положение о классе Shape. Но о поведении класса B или его производных классов D1 и D2 не сформулировано никаких положений, поэтому приведенный пример и кажется неразумным. При построении класса главное внимание следует уделять описанию ожидаемых действий виртуальных функций.
Следует ли считать нормальной зависимость от неизвестных (возможно еще неопределенных) производных классов? Ответ, естественно, зависит от целей программиста. Если цель состоит в том, чтобы изолировать класс от всяких внешних влияний и, тем самым, доказать, что он ведет себя определенным образом, то лучше избегать виртуальных функций и защищенных членов. Если цель состоит в том, чтобы разработать структуру, в которую последующие программисты (или вы сами через неделю) смогут встраивать свои программы, то именно виртуальные функции и предлагают элегантный способ решения, а защищенные члены могут быть полезны при его реализации.
В качестве примера рассмотрим простой шаблон типа, определяющий буфер:
template<class T>
class buffer {
// ...
void put(T);
T get();
};
Если реакция на переполнение и обращение к пустому буферу, "запаяна"
в сам класс, его применение будет ограничено. Но если функции put() и get() обращаются к виртуальным функциям overflow() и underflow() соответственно, то пользователь может, удовлетворяя своим нуждам, создать буфера различных типов:
template<class T>
class buffer {
//...
virtual int overflow(T);
virtual int underflow();
void put(T); // вызвать overflow(T), когда буфер полон
T get(); // вызвать underflow(T), когда буфер пуст
};
template<class T>
class circular_buffer : public buffer<T> {
//...
int overflow(T); // перейти на начало буфера, если он полон
int underflow();
};
template<class T>
class expanding_buffer : public buffer<T> {
//...
int overflow(T); // увеличить размер буфера, если он полон
int underflow();
};
Этот метод использовался в библиотеках потокового ввода-вывода ($$10.5.3).