ис становится необходимым только тогда, когда могут меняться не только типы, но и число параметров. Чаще всего эллипсис используется для определения интерфейса с библиотекой стандартных функций на С, если этим функциям нет замены: extern "C" int fprintf(FILE*, const char* ...); extern "C" int execl(const char* ...); Есть стандартный набор макроопределений, находящийся в <stdarg.h>, для выбора незаданных параметров этих функций. Рассмотрим функцию реакции на ошибку, первый параметр которой показывает степень тяжести ошибки. За ним может следовать произвольное число строк. Нужно составить сообщение об ошибке с учетом, что каждое слово из него передается как отдельная строка: extern void error(int ...) extern char* itoa(int); main(int argc, char* argv[]) { switch (argc) { case 1: error(0,argv[0],(char*)0); break; case 2: error(0,argv[0],argv[1],(char*)0); break; default: error(1,argv[0], "With",itoa(argc-1),"arguments",(char*)0); } // ... } Функция itoa возвращает строку символов, представляющую ее целый параметр. Функцию реакции на ошибку можно определить так: #include <stdarg.h> void error(int severity ...) /* за "severity" (степень тяжести ошибки) следует список строк, завершающийся нулем */ { va_list ap; va_start(ap,severity); // начало параметров for (;;) { char* p = va_arg(ap,char*); if (p == 0) break; cerr << p << ' '; } va_end(ap); // очистка параметров cerr << '\n'; if (severity) exit(severity); } Вначале при вызове va_start() определяется и инициализируется va_list. Параметрами макроопределения va_start являются имя типа va_list и последний формальный параметр. Для выборки по порядку неописанных параметров используется макроопределение va_arg(). В каждом обращении к va_arg нужно задавать тип ожидаемого фактического параметра. В va_arg() предполагается, что параметр такого типа присутствует в вызове, но обычно нет возможности проверить это. Перед выходом из функции, в которой было обращение к va_start, необходимо вызвать va_end. Причина в том, что в va_start() могут быть такие операции со стеком, из-за которых корректный возврат из функции становится невозможным. В va_end() устраняются все нежелательные изменения стека. Приведение 0 к (char*)0 необходимо потому, что sizeof(int) не обязано совпадать с sizeof(char*). Этот пример демонстрирует все те сложности, с которыми приходится сталкиваться программисту, если он решил обойти контроль типов, используя эллипсис. 4.6.9 Указатель на функцию Возможны только две операции с функциями: вызов и взятие адреса. Указатель, полученный с помощью последней операции, можно впоследствии использовать для вызова функции. Например: 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*. Если вы специально хотите нарушить это условие, то должны использовать явное преобразование типа. 4.7 Макросредства Макросредства языка определяются в $$R.16. В С++ они играют гораздо меньшую роль, чем в С. Можно даже дать такой совет: используйте макроопределения только тогда, когда не можете без них обойтись. Вообще говоря, считается, что практически каждое появление макроимени является свидетельством некоторых недостатков языка, программы или программиста. Макросредства создают определенные трудности для работы служебных системных программ, поскольку они перерабатывают программный текст еще до трансляции. Поэтому, если ваша программа использует макросредства, то сервис, предоставляемый такими программами, как отладчик, профилировщик, программа перекрестных ссылок, будет для нее неполным. Если все-таки вы решите использовать макрокоманды, то вначале тщательно изучите описание препроцессора С++ в вашем справочном руководстве и не старайтесь быть слишком умным. Простое макроопределение имеет вид: #define имя остаток-строки В тексте программы лексема имя заменяется на остаток-строки. Например, объект = имя будет заменено на объект = остаток-строки Макроопределение может иметь параметры. Например: #define mac(a,b) argument1: a argument2: b В макровызове mac должны быть заданы две строки, представляющие параметры. При подстановке они заменят a и b в макроопределении mac(). Поэтому строка expanded = mac(foo bar, yuk yuk) при подстановке преобразуется в expanded = argument1: foo bar argument2: yuk yuk Макроимена нельзя перегружать. Рекурсивные макровызовы ставят перед препроцессором слишком сложную задачу: // ошибка: #define print(a,b) cout<<(a)<<(b) #define print(a,b,c) cout<<(a)<<(b)<<(c) // слишком сложно: #define fac(n) (n>1) ?n*fac(n-1) :1 Препроцессор работает со строками и практически ничего не знает о синтаксисе C++, типах языка и областях видимости. Транслятор имеет дело только с уже раскрытым макроопределением, поэтому ошибка в нем может диагностироваться уже после подстановки, а не при определении макроимени. В результате появляются довольно путанные сообщения об ошибках. Допустимы такие макроопределения: #define Case break;case #define forever for(;;) А вот совершенно излишние макроопределения: #define PI 3.141593 #define BEGIN { #define END } Следующие макроопределения могут привести к ошибкам: #define SQUARE(a) a*a #define INCR_xx (xx)++ #define DISP = 4 Чтобы убедиться в этом, достаточно попробовать сделать подстановку в таком примере: int xx = 0; // глобальный счетчик void f() { int xx = 0; // локальная переменная xx = SQUARE(xx+2); // xx = xx +2*xx+2; INCR_xx; // увеличивается локальная переменная xx if (a-DISP==b) { // a-=4==b // ... } } При ссылке на глобальные имена в макроопределении используйте операцию разрешения области видимости ($$2.1.1), и всюду, где это возможно, заключайте имя параметра макроопределения в скобки. Например: #define MIN(a,b) (((a)<(b))?(a):(b)) Если макроопределение достаточно сложное, и требуется комментарий к нему, то разумнее написать комментарий вида /* */, поскольку в реализации С++ может использоваться препроцессор С, который не распознает комментарии вида //. Например: #define m2(a) something(a) /* глубокомысленный комментарий */ С помощью макросредств можно создать свой собственный язык, правда, скорее всего, он будет непонятен другим. Кроме того, препроцессор С предоставляет довольно слабые макросредства. Если ваша задача нетривиальна, вы, скорее всего, обнаружите, что решить ее с помощью этих средств либо невозможно, либо чрезвычайно трудно. В качестве альтернативы традиционному использованию макросредств в язык введены конструкции const, inline и шаблоны типов. Например: const int answer = 42; template<class T> inline T min(T a, T b) { return (a<b)?a:b; } 4.8 Упражнения 1. (*1) Составьте следующие описания: функция с параметрами типа указатель на символ и ссылка на целое, невозвращающая значения; указатель на такую функцию; функция с параметром, имеющим тип такого указателя; функция, возвращающая такой указатель. Напишите определение функции, у которой параметр и возвращаемое значение имеют тип такого указателя. Подсказка: используйте typedef. 2. (*1) Как понимать следующее описание? Где оно может пригодиться? typedef int (rifii&) (int, int); 3. (*1.5) Напишите программу, подобную той, что выдает "Hello, world". Она получает имя (name) как параметр командной строки и выдает "Hello, name". Измените программу так, чтобы она получала произвольное число имен и всем им выдавала свое приветствие: "Hello, ...". 4. (1.5) Напишите программу, которая, беря из командной строки произвольное число имен файлов, все эти файлы переписывает один за другим в cout. Поскольку в программе происходит конкатенация файлов, вы можете назвать ее cat от слова concatenation - конкатенация). 5. (*2) Переведите небольшую программу с языка С на С++. Измените заголовочные файлы так, чтобы они содержали описание всех вызываемых функций и описание типов всех параметров. По возможности все команды #define замените конструкциями enum, const или inline. Удалите из файлов .c все описания внешних, а определения функций приведите к виду, соответствующему С++. Вызовы malloc() и free() замените операциями new и delete. Удалите ненужные операции приведения. 6. (*2) Напишите функцию sort() ($$4.6.9), использующую более эффективный алгоритм сортировки. 7. (*2) Посмотрите на определение структуры tnode в $$R.9.3. Напишите функцию, заносящую новые слова в дерево узлов tnode. Напишите функцию для вывода узлов дерева tnode. Напишите функцию, которая производит такой вывод в алфавитном порядке. Измените структуру tnode так, чтобы в ней содержался только указатель на слово произвольной длины, которое размещается с помощью new в свободной памяти. Измените функцию так, чтобы она работала с новой структурой tnode. 8. (*1) Напишите функцию itoa(), которая использовалась в примере из $$4.6.8. 9. (*2) Узнайте, какие стандартные заголовочные файлы есть в вашей системе. Поройтесь в каталогах /usr/include или /usr/include/CC (или в тех каталогах, где хранятся стандартные заголовочные файлы вашей системы). Прочитайте любой показавшийся интересным файл. 10. (*2) Напишите функцию, которая будет переворачивать двумерный массив. (Первый элемент массива станет последним). 11. (*2) Напишите шифрующую программу, которая читает символы из cin и пишет их в cout в зашифрованном виде. Можно использовать следующий простой метод шифрации: для символа s зашифрованное представление получается в результате операции s^key[i], где key - массив символов, передаваемый в командной строке. Символы из массива key используются в циклическом порядке, пока не будет прочитан весь входной поток. Первоначальный текст получается повторным применением той же операции с теми же элементами key. Если массив key не задан (или задана пустая строка), шифрация не происходит. 12. (*3) Напишите программу, которая помогает дешифрировать текст, зашифрованный описанным выше способом, когда ключ (т.е. массив key) неизвестен. Подсказка: см. D Kahn "The Codebreakers", Macmillan, 1967, New York, стр. 207-213. 13. (*3) Напишите функцию обработки ошибок, первый параметр который подобен форматирующей строке-параметру printf() и содержит форматы %s, %c и %d. За ним может следовать произвольное количество числовых параметров. Функцию printf() не используйте. Если смысл формата %s и других форматов вам неизвестен, обратитесь к $$10.6. Используйте <stdarg.h>. 14. (*1) Какое имя вы выбрали бы для типов указателей на функции, которые определяются с помощью typedef? 15. (*2) Исследуйте разные программы, чтобы получить представление о разных используемых на практике стилях именования. Как используются заглавные буквы? Как используется подчерк? В каких случаях используются такие имена, как i или x? 16. (*1) Какие ошибки содержатся в следующих макроопределениях? #define PI = 3.141593; #define MAX(a,b) a>b?a:b #define fac(a) (a)*fac((a)-1) 17. (*3) Напишите макропроцессор с простыми возможностями, как у препроцессора С. Текст читайте из cin, а результат записывайте в cout. Вначале реализуйте макроопределения без параметров. Подсказка: в программе калькулятора есть таблица имен и синтаксический анализатор, которыми можно воспользоваться. 18. (*2) Напишите программу, извлекающую квадратный корень из двух (2) с помощью стандартной функции sqrt(), но не включайте в программу <math.h>. Сделайте это упражнение с помощью функции sqrt() на Фортране. 19. (*2) Реализуйте функцию print() из $$4.6.7.  * ГЛАВА 5. КЛАССЫ "Эти типы не абстрактные, они столь же реальны, как int и float" - Даг Макилрой В этой главе описываются возможности определения новых типов, для которых доступ к данным ограничен заданным множеством функций, осуществляющих его. Объясняется, как можно использовать члены структуры данных, как ее защищать, инициализировать и, наконец, уничтожать. В примерах приведены простые классы для управления таблицей имен, работы со стеком, множеством и реализации дискриминирующего (т.е. надежного) объединения. Следующие три главы завершают описание возможностей С++ для построения новых типов, и в них содержится больше интересных примеров. 5.1 Введение и краткий обзор Понятие класса, которому посвящена эта и три следующих главы, служит в С++ для того, чтобы дать программисту инструмент построения новых типов. Ими пользоваться не менее удобно, чем встроенными. В идеале использование определенного пользователем типа не должно отличаться от использования встроенных типов. Различия возможны только в способе построения. Тип есть вполне конкретное представление некоторого понятия. Например, в С++ тип float с операциями +, -, * и т.д. является хотя и ограниченным, но конкретным представлением математического понятия вещественного числа. Новый тип создается для того, чтобы стать специальным и конкретным представлением понятия, которое не находит прямого и естественного отражения среди встроенных типов. Например, в программе из области телефонной связи можно ввести тип trunk_module (линия-связи), в видеоигре - тип explosion (взрыв), а в программе, обрабатывающей текст, - тип list_of_paragraphs (список-параграфов). Обычно проще понимать и изменять программу, в которой типы хорошо представляют используемые в задаче понятия. Удачно подобранное множество пользовательских типов делает программу более ясной. Оно позволяет транслятору обнаруживать недопустимое использование объектов, которое в противном случае останется невыявленным до отладки программы. Главное в определении нового типа - это отделить несущественные детали реализации (например, расположение данных в объекте нового типа) от тех его характеристик, которые существенны для правильного его использования (например, полный список функций, имеющих доступ к данным). Такое разделение обеспечивается тем, что вся работа со структурой данных и внутрение, служебные операции над нею доступны только через специальный интерфейс (через "одно горло"). Глава состоит из четырех частей: $$5.2 Классы и члены. Здесь вводится основное понятие пользовательского типа, называемого классом. Доступ к объектам класса может ограничиваться множеством функций, описания которых входят в описание класса. Эти функции называются функциями-членами и друзьями. Для создания объектов класса используются специальные функции-члены, называемые конструкторами. Можно описать специальную функцию-член для удаления объектов класса при его уничтожении. Такая функция называется деструктором. $$5.3 Интерфейсы и реализации. Здесь приводятся два примера разработки, реализации и использования классов. $$5.4 Дополнительные свойства классов. Здесь приводится много дополнительных подробностей о классах. Показано, как функции, не являющейся членом класса, предоставить доступ к его частной части. Такую функцию называют другом класса. Вводятся понятия статических членов класса и указателей на члены класса. Здесь же показано, как определить дискриминирующее объединение. $$5.5 Конструкторы и деструкторы. Объект может создаваться как автоматический, статический или как объект в свободной памяти. Кроме того, объект может быть членом некоторого агрегата (массива или другого класса), который тоже можно размещать одним из этих трех способов. Подробно объясняется использование конструкторов и деструкторов, описывается применение определяемых пользователем функций размещения в свободной памяти и функций освобождения памяти. 5.2 Классы и члены Класс - это пользовательский тип. Этот раздел знакомит с основными средствами определения класса, создания его объектов, работы с такими объектами и, наконец, удаления этих объектов после использования. 5.2.1 Функции-члены Посмотрим, как можно представить в языке понятие даты, используя для этого тип структуры и набор функций, работающих с переменными этого типа: struct date { int month, day, year; }; date today; void set_date(date*, int, int, int); void next_date(date*); void print_date(const date*); // ... Никакой явной связи между функциями и структурой date нет. Ее можно установить, если описать функции как члены структуры: struct date { int month, day, year; void set(int, int, int); void get(int*, int* int*); void next(); void print(); }; Описанные таким образом функции называются функциями-членами. Их можно вызывать только через переменные соответствующего типа, используя стандартную запись обращения к члену структуры: date today; date my_birthday; void f() { my_birthday.set(30,12,1950); today.set(18,1,1991); my_birthday.print(); today.next(); } Поскольку разные структуры могут иметь функции-члены с одинаковыми именами, при определении функции-члена нужно указывать имя структуры: void date::next() { if (++day > 28 ) { // здесь сложный вариант } } В теле функции-члена имена членов можно использовать без указания имени объекта. В таком случае имя относится к члену того объекта, для которого была вызвана функция. 5.2.2 Классы Мы определили несколько функций для работы со структурой date, но из ее описания не следует, что это единственные функции, которые предоставляют доступ к объектам типа date. Можно установить такое ограничение, описав класс вместо структуры: class date { int month, day, year; public: void set(int, int, int); void get(int*, int*, int*); void next(); void print() }; Служебное слово public (общий) разбивает описание класса на две части. Имена, описанные в первой частной (private) части класса, могут использоваться только в функциях-членах. Вторая - общая часть - представляет собой интерфейс с объектами класса. Поэтому структура - это такой класс, в котором по определению все члены являются общими. Функции-члены класса определяются и используются точно так же, как было показано в предыдущем разделе: void date::print() // печать даты в принятом в США виде { cout << month << '/' << day << '/' << year ; } Однако от функций не членов частные члены класса date уже ограждены: void backdate() { today.day--; // ошибка } Есть ряд преимуществ в том, что доступ к структуре данных ограничен явно указанным списком функций. Любая ошибка в дате (например, December, 36, 1985) могла быть внесена только функцией-членом, поэтому первая стадия отладки - локализация ошибки - происходит даже до первого пуска программы. Это только частный случай общего правила: любое изменение в поведении типа date может и должно вызываться изменениями в его членах. Другое преимущество в том, что потенциальному пользователю класса для работы с ним достаточно знать только определения функций-членов. Защита частных данных основывается только на ограничении использования имен членов класса. Поэтому ее можно обойти с помощью манипуляций с адресами или явных преобразований типа, но это уже можно считать мошенничеством. 5.2.3 Ссылка на себя В функции-члене можно непосредственно использовать имена членов того объекта, для которого она была вызвана: class X { int m; public: int readm() { return m; } }; void f(X aa, X bb) { int a = aa.readm(); int b = bb.readm(); // ... } При первом вызове readm() m обозначает aa.m, а при втором - bb.m. У функции-члена есть дополнительный скрытый параметр, являющийся указателем на объект, для которого вызывалась функция. Можно явно использовать этот скрытый параметр под именем this. Считается, что в каждой функции-члене класса X указатель this описан неявно как X *const this; и инициализируется, чтобы указывать на объект, для которого функция-член вызывалась. Этот указатель нельзя изменять, поскольку он постоянный (*const). Явно описать его тоже нельзя, т.к. this - это служебное слово. Можно дать эквивалентное описание класса X: class X { int m; public: int readm() { return this->m; } }; Для обращения к членам использовать this излишне. В основном this используется в функциях-членах, непосредственно работающих с указателями. Типичный пример - функция, которая вставляет элемент в список с двойной связью: class dlink { dlink* pre; // указатель на предыдущий элемент dlink* suc; // указатель на следующий элемент public: void append(dlink*); // ... }; void dlink::append(dlink* p) { p->suc = suc; // т.е. p->suc = this->suc p->pre = this; // явное использование "this" suc->pre = p; // т.е. this->suc->pre = p suc = p; // т.е. this->suc = p } dlink* list_head; void f(dlink* a, dlink* b) { // ... list_head->append(a); list_head->append(b); } Списки с такой общей структурой служат фундаментом списочных классов, описываемых в главе 8. Чтобы присоединить звено к списку, нужно изменить объекты, на которые настроены указатели this, pre и suc. Все они имеют тип dlink, поэтому функция-член dlink::append() имеет к ним доступ. Защищаемой единицей в С++ является класс, а не отдельный объект класса. Можно описать функцию-член таким образом, что объект, для которого она вызывается, будет доступен ей только по чтению. Тот факт, что функция не будет изменять объект, для которого она вызывается (т.е. this*), обозначается служебным словом const в конце списка параметров: class X { int m; public: readme() const { return m; } writeme(int i) { m = i; } }; Функцию-член со спецификацией const можно вызывать для постоянных объектов, а функцию-член без такой спецификации - нельзя: void f(X& mutable, const X& constant) { mutable.readme(); // нормально mutable.writeme(7); // нормально constant.readme(); // нормально constant.writeme(7); // ошибка } В этом примере разумный транслятор смог бы обнаружить, что функция X::writeme() пытается изменить постоянный объект. Однако, это непростая задача для транслятора. Из-за раздельной трансляции он в общем случае не может гарантировать "постоянство" объекта, если нет соответствующего описания со спецификацией const. Например, определения readme() и writeme() могли быть в другом файле: class X { int m; public: readme() const; writeme(int i); }; В таком случае описание readme() со спецификацией const существенно. Тип указателя this в постоянной функции-члене класса X есть const X *const. Это значит, что без явного приведения с помощью this нельзя изменить значение объекта: class X { int m; public: // ... void implicit_cheat() const { m++; } // ошибка void explicit_cheat() const { ((X*)this)->m++; } // нормально }; Отбросить спецификацию const можно потому, что понятие "постоянства" объекта имеет два значения. Первое, называемое "физическим постоянством" состоит в том, что объект хранится в защищенной от записи памяти. Второе, называемое "логическим постоянством" заключается в том, что объект выступает как постоянный (неизменяемый) по отношению к пользователям. Операция над логически постоянным объектом может изменить часть данных объекта, если при этом не нарушается его постоянство с точки зрения пользователя. Операциями, ненарушающими логическое постоянство объекта, могут быть буферизация значений, ведение статистики, изменение переменных-счетчиков в постоянных функциях-членах. Логического постоянства можно достигнуть приведением, удаляющим спецификацию const: class calculator1 { int cache_val; int cache_arg; // ... public: int compute(int i) const; // ... }; int calculator1::compute(int i) const { if (i == cache_arg) return cache_val; // нелучший способ ((calculator1*)this)->cache_arg = i; ((calculator1*)this)->cache_val = val; return val; } Этого же результата можно достичь, используя указатель на данные без const: struct cache { int val; int arg; }; class calculator2 { cache* p; // ... public: int compute(int i) const; // ... }; int calculator2::compute(int i) const { if (i == p->arg) return p->val; // нелучший способ p->arg = i; p->val = val; return val; } Отметим, что const нужно указывать как в описании, так и в определении постоянной функции-члена. Физическое постоянство обеспечивается помещением объекта в защищенную по записи память, только если в классе нет конструктора ($$7.1.6). 5.2.4 Инициализация Инициализация объектов класса с помощью таких функций как set_date() - неэлегантное и чреватое ошибками решение. Поскольку явно не было указано, что объект требует инициализации, программист может либо забыть это сделать, либо сделать дважды, что может привести к столь же катастрофическим последствиям. Лучше дать программисту возможность описать функцию, явно предназначенную для инициализации объектов. Поскольку такая функция конструирует значение данного типа, она называется конструктором. Эту функцию легко распознать - она имеет то же имя, что и ее класс: class date { // ... date(int, int, int); }; Если в классе есть конструктор, все объекты этого класса будут проинициализированы. Если конструктору требуются параметры, их надо указывать: date today = date(23,6,1983); date xmas(25,12,0); // краткая форма date my_birthday; // неправильно, нужен инициализатор Часто бывает удобно указать несколько способов инициализации объекта. Для этого нужно описать несколько конструкторов: class date { int month, day, year; public: // ... date(int, int, int); // день, месяц, год date(int, int); // день, месяц и текущий год date(int); // день и текущие год и месяц date(); // стандартное значение: текущая дата date(const char*); // дата в строковом представлении }; Параметры конструкторов подчиняются тем же правилам о типах параметров, что и все остальные функции ($$4.6.6). Пока конструкторы достаточно различаются по типам своих параметров, транслятор способен правильно выбрать конструктор: date today(4); date july4("July 4, 1983"); date guy("5 Nov"); date now; // инициализация стандартным значением Размножение конструкторов в примере c date типично. При разработке класса всегда есть соблазн добавить еще одну возможность, - а вдруг она кому-нибудь пригодится. Чтобы определить действительно нужные возможности, надо поразмышлять, но зато в результате, как правило, получается более компактная и понятная программа. Сократить число сходных функций можно с помощью стандартного значения параметра. В примере с date для каждого параметра можно задать стандартное значение, что означает: "взять значение из текущей даты". class date { int month, day, year; public: // ... date(int d =0, int m =0, y=0); // ... }; date::date(int d, int m, int y) { day = d ? d : today.day; month = m ? m : today.month; year = y ? y : today.year; // проверка правильности даты // ... } Когда используется стандартное значение параметра, оно должно отличаться от всех допустимых значений параметра. В случае месяца и дня очевидно, что при значении нуль - это так, но неочевидно, что нуль подходит для значения года. К счастью, в европейском календаре нет нулевого года, т.к. сразу после 1 г. до р.х. (year==-1) идет 1 г. р.х. (year==1). Однако для обычной программы это, возможно, слишком тонкий момент. Объект класса без конструктора может инициализироваться присваиванием ему другого объекта этого же класса. Это незапрещено и в том случае, когда конструкторы описаны: date d = today; // инициализация присваиванием На самом деле, имеется стандартный конструктор копирования, определенный как поэлементное копирование объектов одного класса. Если такой конструктор для класса X не нужен, можно переопределить его как конструктор копирования X::X(const X&). Подробнее поговорим об этом в $$7.6. 5.2.5 Удаление Пользовательские типы чаще имеют, чем не имеют, конструкторы, которые проводят надлежащую инициализацию. Для многих типов требуется и обратная операция - деструктор, гарантирующая правильное удаление объектов этого типа. Деструктор класса 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() память, которая была занята обоими массивами, будет освобождена. 5.2.6 Подстановка Программирование с классами предполагает, что в программе появится множество маленьких функций. По сути, всюду, где в программе с традиционной организацией стояло бы обычное обращение к структуре данных, используется функция. То, что было соглашением, стало стандартом, проверяемым транслятором. В результате программа может стать крайне неэффективной. Хотя вызов функции в C++ и не столь дорогостоящая операция по сравнению с другими языками, все-таки цена ее много выше, чем у пары обращений к памяти, составляющих тело тривиальной функции. Преодолеть эту трудность помогают функции-подстановки (inline). Если в описании класса функция-член определена, а не только описана, то она считается подстановкой. Это значит, например, что при трансляции функций, использующих char_stack из предыдущего примера, не будет использоваться никаких операций вызова функций, кроме реализации операций вывода! Другими словами, при разработке такого класса не нужно принимать во внимание затраты на вызов функций. Любое, даже самое маленькое действие, можно смело определять как функцию без потери эффективности. Это замечание снимает наиболее часто приводимый довод в пользу общих членов данных. Функцию-член можно описать со спецификацией inline и вне описания класса: class char_stack { int size; char* top; char* s; public: char pop(); // ... }; inline char char_stack::pop() { return *--top; } Отметим, что недопустимо описывать разные определения функции-члена, являющейся подстановкой, в различных исходных файлах ($$R.7.1.2). Это нарушило бы понятие о классе как о цельном типе. 5.3 Интерфейсы и реализации Что представляет собой хороший класс? Это нечто, обладающее хорошо определенным множеством операций. Нечто, рассматриваемое как "черный ящик", управлять которым можно только посредством этих операций. Нечто, чье фактическое представление можно изменить любым мыслимым способом, но не изменяя при этом способа использования операций. Нечто, что может потребоваться в нескольких экземплярах. Очевидные примеры хороших классов дают контейнеры разных видов: таблицы, множества, списки, вектора, словари и т.д. Такой класс имеет операцию занесения в контейнер. Обычно имеется и операция проверки: был ли данный член занесен в контейнер? Могут быть операции упорядочивания всех членов и просмотра их в определенном порядке. Наконец, может быть операция удаления члена. Обычно контейнерные классы имеют конструкторы и деструкторы. 5.3.1 Альтернативные реализации Пока описание общей части класса и функций-членов остается неизменным, можно, не влияя на пользователей класса, менять его реализацию. В подтверждение этого рассмотрим таблицу имен из программы калькулятора, приведенной в главе 3. Структура ее такова: struct name { char* string; name* next; double value; }; А вот вариант класса table (таблица имен): // файл table.h class table { name* tbl; public: table() { tbl = 0; } name* look(char*, int = 0); name* insert(char* s) { return look(s,1); } }; Эта таблица отличается от определенной в главе 3 тем, что это настоящий тип. Можно описать несколько таблиц, завести указатель на таблицу и т.д. Например: #include "table.h" table globals; table keywords; table* locals; main() { locals = new table; // ... } Приведем реализацию функции table::look(), в которой используется линейный поиск в списке имен таблицы: #include <string.h> name* table::look(char* p, int ins) { for (name* n = tbl; n; n=n->next) if (strcmp(p,n->string) == 0) return n; if (ins == 0) error("имя не найдено"); name* nn = new name; nn->string = new char[strlen(p)+1]; strcpy(nn->string,p); nn->value = 1; nn->next = tbl; tbl = nn; return nn; } Теперь усовершенствуем класс table так, чтобы поиск имени шел по ключу (хэш-функции от имени), как это и было сделано в примере с калькулятором. Сделать это труднее, если соблюдать ограничение, требующее, чтобы не все программы, использующие приведенную версию класса table, надо было изменять: class table { name** tbl; int size; public: table(int sz = 15); ~table(); name* look(char*, int = 0); name* insert(char* s) { return look(s,1); } }; Изменения в структуре данных и конструкторе произошли потому, что для хэширования таблица должна иметь определенный размер. Задание конструктора со стандартным значением параметра гарантирует, что старые программы, в которых не использовался размер таблицы, останутся верными. Стандартные значения параметров полезны в таких случаях, когда нужно изменить класс, не влияя на программы пользователей класса. Теперь конструктор и деструктор создают и уничтожают хэшированные таблицы: table::table(int sz) { if (sz < 0) error("размер таблицы отрицателен"); tbl = new name*[size = sz]; for ( int i = 0; i<sz; i++) tbl[i] = 0; } table::~table() { for (int i = 0; i<size; i++) { name* nx; for (name* n = tbl[i]; n; n=nx) { nx = n->next; delete n->string; delete n; } } delete tbl; } Описав деструктор для класса name, можно получить более ясный и простой вариант table::~table(). Функция поиска практически совпадает с приведенной в примере калькулятора ($$3.13): name* table::look(const char* p, int ins) { int ii = 0; char* pp = p; while (*pp) ii = ii<<1 ^ *pp++; if (ii < 0) ii = -ii; ii %= size; for (name* n=tbl[ii]; n; n=n->next) if (strcmp(p,n->string) == 0) return n; name* nn = new name; nn->string = new char[strlen(p)+1]; strcpy(nn->string,p); nn->value = 1; nn->next = tbl[ii]; tbl[ii] = nn; return nn; } Очевидно, что функции-члены класса должны перетранслироваться всякий раз, когда в описание класса вносится какое-либо изменение. В идеале такое изменение никак не должно отражаться на пользователях класса. К сожалению, обычно бывает не так. Для размещения переменной, имеющей тип класса, транслятор должен знать размер объекта класса. Если размер объекта изменится, нужно перетранслировать файлы, в которых использовался класс. Можно написать системную программу (и она даже уже написана), которая будет определять минимальное множество файлов, подлежащих перетрансляции после изменения класса. Но такая программа еще не получила широкого распространения. Возможен вопрос: почему С++ был спроектирован таким образом, что после изменения частной части класса требуется перетрансляция программ пользователя? Почему вообще частная часть класса присутствует в описании класса? Иными словами, почему описания частных членов присутствуют в заголовочных файлах, доступных пользователю, если все равно недоступны для него в программе? Ответ один - эффективность. Во многих системах программирования процесс трансляции и последовательность команд, производящая вызов функции, будет проще, если размер автоматических (т.е. размещаемых в стеке) объектов известен на стадии трансляции. Можно не знать определения всего класса, если представлять каждый объект как указатель на "настоящий" объект. Это позволяет решить задачу, поскольку все указатели будут иметь одинаковый размер, а размещение настоящих объектов будет проводиться только в одном файле, в котором доступны частные части классов. Однако, такое решение приводит к дополнительному расходу памяти на каждый объект и дополнительному обращению к памяти при каждом использовании члена. Еще хуже, что каждый вызов функции с автоматическим объектом класса требует вызовов функций выделения и освобождения памяти. К тому же становится невозможной реализация подстановкой функций-членов, работающих с частными членами класса. Наконец, такое изменение сделает невозможным связывание программ на С++ и на С, поскольку транслятор С будет по другому обрабатывать структуры (struct). Поэтому такое решение было сочтено неприемлемым для С++. С другой стороны, С++ предоставляет средство для создания абстрактных типов, в которых связь между интерфейсом пользователя и реализацией довольно слабая. В главе 6 вводятся производные классы и описываются абстрактные базовые классы, а в $$13.3 поясняется, как с помощью этих средств реализовать абстрактные типы. Цель этого - дать возможность определять пользовательские типы столь же эффективные и конкретные, как и стандартные, и дать основные средства определения более гибких вариантов типов, которые могут оказаться и не столь эффективными. 5.3.2 Законченный пример класса Программирование без упрятывания данных (в расчете на структуры) требует меньшего предварительного обдумывания задачи, чем программирование с упрятыванием данных (в расчете на классы). Структуру можно определить не очень задумываясь о том, как ее будут использовать. Когда определяется класс, внимание концентрируется на том, чтобы обеспечить для нового типа полный набор операций. Это важное смещение акцента в проектировании программ. Обычно время, затраченное на разработку нового типа, многократно окупается в процессе отладки и развития программы. Вот пример законченного определения типа intset, представляющего понятие "множество целых": class intset { int cursize, maxsize; int *x; public: intset(int m, int n); // не более m целых из 1..n ~intset(); int member(int t) const; // является ли t членом? void insert(int t); // добавить к множеству t void start(int& i) const { i = 0; } void ok(int& i) const { return i<cursize; } void next(int& i) const { return x[i++]; } }; Для проверки этого класса вначале создадим, а затем распечатаем множество случайных целых чисел. Это простое множество целых можно использовать для проверки, есть ли повторения в их последовательности. Но для большинства задач нужен, конечно, более развитый тип множества. Как всегда возможны ошибки, поэтому нужна функция: #include <iostream.h> void error(const char *s) { cerr << "set: " << s << '\n'; exit(1); } Класс intset используется в функции main(), для которой должно быть задано два параметра: первый определяет число создаваемых случайных чисел, а второй - диапазон их значений: int main(int argc, char* argv[]) { if (argc != 3) error("нужно задавать два параметра"); int count = 0; int m = atoi(argv[1]); // число элементов множества int n = atoi(argv[2]); // из диапазона 1..n intset s(m,n); while (count<m) { int t = randint(n); if (s.member(t)==0) { s.insert(t); count++; } } print_in_order(&s); } Значение счетчика параметров программы argc равно 3, хотя программа имеет только два параметра. Дело в том, что в argv[0] всегда передается дополнительный параметр, содержащий имя программы. Функция extern "C" int atoi(const char*) является стандартной библиотечной функцией, преобразующей целое из строкового представления во внутреннюю двоичную форму. Как обычно, если вы не хотите иметь такое описание в своей программе, то вам надо включить в нее соответствующий заголовочный файл, содержащий описания стандартных библиотечных функций. Случайные числа генерируются с помощью стандартной функции rand: extern "C" int rand(); // будьте осторожны: // числа не совсем случайные int randint(int u) // диапазон 1..u { int r = rand(); if (r < 0) r = -r; return 1 + r%u; } Подробности реализации класса мало интересны для пользователя, но в любом случае будут использоваться функции-члены. Конструктор размещает массив целых с размером, равным заданному максимальному размеру множества, а деструктор удаляет этот массив: intset::intset(int m, int n) // не более m целых в 1..n { if (m<1 || n<m) error("недопустимый размер intset"); cursize = 0; maxsize = m; x = new int[maxsize]; } intset::~intset() { delete x; } Целые добавляются таким образом, что они хранятся во множестве в возрастающем порядке: void intset::insert(int t) { if (++cursize > maxsize) error("слишком много элементов"); int i = cursize-1; x[i] = t; while (i>0 && x[i-1]>x[i]) { int t = x[i]; // поменять местами x[i] и x[i-1] x[i] = x[i-1]; x[i-1] = t; i--; } } Чтобы найти элемент, используется простой двоичный поиск: int intset::member(int t) const // двоичный поиск { int l = 0; int u = cursize-1; while (l <= u) { int m = (l+u)/2; if (t < x[m]) u = m-1; else if (t > x[m]) l = m+1; else return 1; // найден } return 0; // не найден } Наконец, нужно предоставить пользователю набор операций, с помощью которых он мог бы организовать итерацию по множеству в некотором порядке (ведь порядок, используемый в представлении intset, от него скрыт). Множество по своей сути не является внутренне упорядоченным, и нельзя позволить просто выбирать элементы массива (а вдруг завтра intset будет реализовано в виде связанного списка?). Пользователь получает три функции: start() - для инициализации итерации, ok() - для проверки, есть ли следующий элемент, и next() - для получения следующего элемента: class intset { // ... void start(int& i) const { i = 0; } int ok(int& i) const { return i<cursize; } int next(int& i) const { return x[i++]; } }; Чтобы обеспечить совместную работу этих трех операций, надо запоминать тот элемент, на котором остановилась итерация. Для этого пользователь должен задавать целый параметр. Поскольку наше представление множества упорядоченное, реализация этих операций тривиальна. Теперь можно определить функцию print_in_order: void print_in_order(intset* set) { int var; set->sart(var); while (set->ok(var)) cout << set->next(var) << '\n'; } Другой способ построения итератора по множеству приведен в $$7.8. 5.4 Еще о классах В этом разделе описаны дополнительные свойства класса. Описан способ обеспечить доступ к частным членам в функциях, не являющихся членами ($$5.4.1). Описано, как разрешить коллизии имен членов ($$5.4.2) и как сделать описания классов вложенными ($$5.4.3), но при этом избежать нежелательной вложенности ($$5.4.4). Вводится понятие статических членов (static), которые используются для представления операций и данных, относящихся к самому классу, а не к отдельным его объектам ($$5.4.5). Раздел завершается примером, показывающим, как можно построить дискриминирующее (надежное) объединение ($$5.4.6). 5.4.1 Друзья Пусть определены два класса: vector (вектор) и matrix (матрица). Каждый из них скрывает свое представление, но дает полный набор операций для работы с объектами его типа. Допустим, надо определить функцию, умножающую матрицу на вектор. Для простоты предположим, что вектор имеет четыре элемента с индексами от 0 до 3, а в матрице четыре вектора тоже с индексами от 0 до 3. Доступ к элементам вектора обеспечивается функцией elem(), и аналогичная функция есть для матрицы. Можно определить глобальную функцию multiply (умножить) следующим образом: vector multiply(const matrix& m, const vector& v); { vector r; for (int i = 0; i<3; i++) { // r[i] = m[i] * v; r.elem(i) = 0; for (int j = 0; j<3; j++) r.elem(i) +=m.elem(i,j) * v.elem(j); } return r; } Это вполне естественное решение, но оно может оказаться очень неэффективным. При каждом вызове multiply() функция elem() будет вызываться 4*(1+4*3) раз. Если в elem() проводится настоящий контроль границ массива, то на такой контроль будет потрачено значительно больше времени, чем на выполнение самой функции, и в результате она окажется непригодной для пользователей. С другой стороны, если elem() есть некий специальный вариант доступа без контроля, то тем самым мы засоряем интерфейс с вектором и матрицей особой функцией доступа, которая нужна только для обхода контроля. Если можно было бы сделать multiply членом обоих классов vector и matrix, мы могли бы обойтись без контроля индекса при обращении к элементу матрицы, но в то же время не вводить специальной функции elem(). Однако, функция не может быть членом двух классов. Надо иметь в языке возможность предоставлять функции, не являющейся членом, право доступа к частным членам класса. Функция - не член класса, - имеющая доступ к его закрытой части, называется другом этого класса. Функция может стать другом класса, если в его описании она описана как friend (друг). Например: class matrix; class vector { float v[4]; // ... friend vector multiply(const matrix&, const vector&); }; class matrix { vector v[4]; // ... friend vector multiply(const matrix&, const vector&); }; Функция-друг не имеет никаких особенностей, за исключением права доступа к закрытой части класса. В частности, в такой функции нельзя использовать указатель this, если только она действительно не является членом класса. Описание friend является настоящим описанием. Оно вводит имя функции в область видимости класса, в котором она была описана, и при этом происходят обычные проверки на наличие других описаний такого же имени в этой области видимости. Описание friend может находится как в общей, так и в частной частях класса, это не имеет значения. Теперь можно написать функцию multiply, используя элементы вектора и матрицы непосредственно: vector multiply(const matrix& m, const vector& v) { vector r; for (int i = 0; i<3; i++) { // r[i] = m[i] * v; r.v[i] = 0; for ( int j = 0; j<3; j++) r.v[i] +=m.v[i][j] * v.v[j]; } return r; } Отметим, что подобно функции-члену дружественная функция явно описывается в описании класса, с которым дружит. Поэтому она является неотъемлемой частью интерфейса класса наравне с функцией-членом. Функция-член одного класса может быть другом другого класса: class x { // ... void f(); }; class y { // ... friend void x::f(); }; Вполне возможно, что все функции одного класса являются друзьями другого класса. Для этого есть краткая форма записи: class x { friend class y; // ... }; В результате такого описания все функции-члены y становятся друзьями класса x. 5.4.2 Уточнение имени члена Иногда полезно делать явное различие между именами членов классов и прочими именами. Для этого используется операция :: (разрешения области видимости): 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) // ... } // ... } 5.4.3 Вложенные классы Описание класса может быть вложенным. Например: 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:: излишне { /* ... */ } 5.4.4 Статические члены Класс - это тип, а не некоторое данное, и для каждого объекта класса создается своя копия членов, представляющих данные. Однако, наиболее удачная реализация некоторых типов требует, чтобы все объекты этого типа имели некоторые общие данные. Лучше, если эти данные можно описать как часть класса. Например, в операционных системах или при моделировании управления задачами часто нужен список задач: class task { // ... static task* chain; // ... }; Описав член chain как статический, мы получаем гарантию, что он будет создан в единственном числе, т.е. не будет создаваться для каждого объекта task. Но он находится в области видимости класса task, и может быть доступен вне этой области, если только описан в общей части. В этом случае имя члена должно уточняться именем класса: if (task::chain == 0) // какие-то операторы В функции-члене его можно обозначать просто chain. Использование статических членов класса может заметно сократить потребность в глобальных переменных. Описывая член как статический, мы ограничиваем его область видимости и делаем его независимым от отдельных объектов его класса. Это свойство полезно как для функций-членов, так и для членов, представляющих данные: class task { // ... static task* task_chain; static void shedule(int); // ... }; Но описание статического члена - это только описание, и где-то в программе должно быть единственное определение для описываемого объекта или функции, например, такое: task* task::task_chain = 0; void task::shedule(int p) { /* ... */ } Естественно, что и частные члены могут определяться подобным образом. Отметим, что служебное слово static не нужно и даже нельзя использовать в определении статического члена класса. Если бы оно присутствовало, возникла бы неоднозначность: указывает ли оно на то, что член класса является статическим, или используется для описания глобального объекта или функции? Слово static одно из самых перегруженных служебных слов в С и С++. К статическому члену, представляющему данные, относятся оба основных его значения: "статически размещаемый" , т.е. противоположный объектам, размещаемым в стеке или свободной памяти, и "статический" в смысле с ограниченной областью видимости, т.е. противоположный объектам, подлежащим внешнему связыванию. К функциям-членам относится только второе значение static. 5.4.5 Указатели на члены Можно брать адрес члена класса. Операция взятия адреса функции-члена часто оказывается полезной, поскольку цели и способы применения указателей на функции, о которых мы говорили в $$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) успешно заменяют указатели на функции. 5.4.6 Структуры и объединения По определению структура - это класс, все члены которого общие, т.е. описание struct s { ... это просто краткая форма описания class s { public: ... Поименованное объединение определяется как структура, все члены которой имеют один и тот же адрес ($$R.9.5). Если известно, что в каждый момент времени используется значение только одного члена структуры, то объявив ее объединением, можно сэкономить память. Например, можно использовать объединение для хранения лексем транслятора С: union tok_val { char* p; // строка char v[8]; // идентификатор (не более 8 символов) long i; // значения целых double d; // значения чисел с плавающей точкой }; Проблема с объединениями в том, что транслятор в общем случае не знает, какой член используется в данный момент, и поэтому контроль типа невозможен. Например: void strange(int i) { tok_val x; if (i) x.p = "2"; else x.d = 2; sqrt(x.d); // ошибка, если i != 0 } Кроме того, определенное таким образом объединение нельзя инициализировать таким кажущимся вполне естественным способом: tok_val val1 = 12; // ошибка: int присваивается tok_val tok_val val2 = "12"; // ошибка: char* присваивается tok_val Для правильной инициализации надо использовать конструкторы: union tok_val { char* p; // строка char v[8]; // идентификатор (не более 8 символов) long i; // значения целых double d; // значения чисел с плавающей точкой tok_val(const char*); // нужно выбирать между p и v tok_val(int ii) { i = ii; } tok_val(double dd) { d = dd; } }; Эти описания позволяют разрешить с помощью типа членов неоднозначность при перегрузке имени функции (см. $$4.6.6 и $$7.3). Например: void f() { tok_val a = 10; // a.i = 10 tok_val b = 10.0; // b.d = 10.0 } Если это невозможно (например, для типов char* и char[8] или int и char и т.д.), то определить, какой член инициализируется, можно, изучив инициализатор при выполнении программы, или введя дополнительный параметр. Например: tok_val::tok_val(const char* pp) { if (strlen(pp) <= 8) strncpy(v,pp,8); // короткая строка else p = pp; // длинная строка } Но лучше подобной неоднозначности избегать. Стандартная функция strncpy() подобно strcpy() копирует строки, но у нее есть дополнительный параметр, задающий максимальное число копируемых символов. То, что для инициализации объединения используются конструкторы, еще не гарантирует от случайных ошибок при работе с объединением, когда присваивается значение одного типа, а выбирается значение другого типа. Такую гарантию можно получить, если заключить объединение в класс, в котором будет отслеживаться тип заносимого значения : class tok_val { public: enum Tag { I, D, S, N }; private: union { const char* p; char v[8]; long i; double d; }; Tag tag; void check(Tag t) { if (tag != t) error(); } public: Tag get_tag() { return tag; } tok_val(const char* pp); tok_val(long ii) { i = ii; tag = I; } tok_val(double dd) { d = dd; tag = D; } long& ival() { check(I); return i; } double& fval() { check(D); return d; } const char*& sval() { check(S); return p; } char* id() { check(N); return v; } }; tok_val::tok_val(const char* pp) { if (strlen(pp) <= 8) { // короткая строка tag = N; strncpy(v,pp,8); } else { // длинная строка tag = S; p = pp; // записывается только указатель } } Использовать класс tok_val можно так: void f() { tok_val t1("короткая"); // присваивается v tok_val t2("длинная строка"); // присваивается p char s[8]; strncpy(s,t1.id(),8); // нормально strncpy(s,t2.id(),8); // check() выдаст ошибку } Описав тип Tag и функцию get_tag() в общей части, мы гарантируем, что тип tok_val можно использовать как тип параметра. Таким образом, появляется надежная в смысле типов альтернатива описанию параметров с эллипсисом. Вот, например, описание функции обработки ошибок, которая может иметь один, два, или три параметра с типами char*, int или double: extern tok_val no_arg; void error( const char* format, tok_val a1 = no_arg, tok_val a2 = no_arg, tok_val a3 = no_arg); 5.5 Конструкторы и деструкторы Если у класса есть конструктор, он вызывается всякий раз при создании объекта этого класса. Если у класса есть деструктор, он вызывается всякий раз, когда уничтожается объект этого класса. Объект может создаваться как: [1] автоматический, который создается каждый раз, когда его описание встречается при выполнении программы, и уничтожается по выходе из блока, в котором он описан; [2] статический, который создается один раз при запуске программы и уничтожается при ее завершении; [3] объект в свободной памяти, который создается операцией new и уничтожается операцией delete; [4] объект-член, который создается в процессе создания другого класса или при создании массива, элементом которого он является. Кроме этого объект может создаваться, если в выражении явно используется его конструктор ($$7.3) или как временный объект ($$R.12.2). В обоих случаях такой объект не имеет имени. В следующих подразделах предполагается, что объекты относятся к классу с конструктором и деструктором. В качестве примера используется класс table из $$5.3.1. 5.5.1 Локальные переменные Конструктор локальной переменной вызывается каждый раз, когда при выполнении программы встречается ее описание. Деструктор локальной переменной вызывается всякий раз по выходе из блока, где она была описана. Деструкторы для локальных переменных вызываются в порядке, обратном вызову конструкторов при их создании: void f(int i) { table aa; table bb; if (i>0) { table cc; // ... } // ... } Здесь aa и bb создаются (именно в таком порядке) при каждом вызове f(), а уничтожаются они при возврате из f() в обратном порядке - bb, затем aa. Если в текущем вызове f() i больше нуля, то cc создается после bb и уничтожается прежде него. Поскольку aa и bb - объекты класса table, присваивание aa=bb означает копирование по членам bb в aa (см. $$2.3.8). Такая интерпретация присваивания может привести к неожиданному (и обычно нежелательному) результату, если присваиваются объекты класса, в котором определен конструктор: void h() { table t1(100); table t2 = t1; // неприятность table t3(200); t3 = t2; // неприятность } В этом примере конструктор table вызывается дважды: для t1 и t3. Он не вызывается для t2, поскольку этот объект инициализируется присваиванием. Тем не менее, деструктор для table вызывается три раза: для t1, t2 и t3! Далее, стандартная интерпретация присваивания - это копирование по членам, поэтому перед выходом из h() t1, t2 и t3 будут содержать указатель на массив имен, память для которого была выделена в свободной памяти при создании t1. Указатель на память, выделенную для массива имен при создании t3, будет потерян. Этих неприятностей можно избежать (см. $$1.4.2 и $$7.6). 5.5.2 Статическая память Рассмотрим такой пример: table tbl(100); void f(int i) { static table tbl2(i); } int main() { f(200); // ... } Здесь конструктор, определенный в $$5.3.1, будет вызываться дважды: один раз для tbl и один раз для tbl2. Деструктор table::~table() также будет вызван дважды: для уничтожения tbl и tbl2 по выходе из main(). Конструкторы глобальных статических объектов в файле вызываются в том же порядке, в каком встречаются в файле описания объектов, а деструкторы для них вызываются в обратном порядке. Конструктор локального статического объекта вызывается, когда при выполнении программы первый раз встречается определение объекта. Традиционно выполнение main() рассматривалось как выполнение всей программы. На самом деле, это не так даже для С. Уже размещение статического объекта класса с конструктором и (или) деструктором позволяет программисту задать действия, которые будут выполняться до вызова main() и (или) по выходе из main(). Вызов конструкторов и деструкторов для статических объектов играет в С++ чрезвычайно важную роль. С их помощью можно обеспечить соответствующую инициализацию и удаление структур данных, используемых в библиотеках. Рассмотрим <iostream.h>. Откуда берутся cin, cout и cerr? Когда они инициализируются? Более существенный вопрос: поскольку для выходных потоков используются внутренние буфера символов, то происходит выталкивание этих буферов, но когда? Есть простой и очевидный ответ: все действия выполняются соответствующими конструкторами и деструкторами до запуска main() и по выходе из нее (см. $$10.5.1). Существуют альтернативы использованию конструкторов и деструкторов для инициализации и уничтожения библиотечных структур данных, но все они или очень специализированы, или неуклюжи, или и то и другое вместе. Если программа завершается обращение к функции exit(), то вызываются деструкторы для всех построенных статических объектов. Однако, если программа завершается обращением к abort(), этого не происходит. Заметим, что exit() не завершает программу немедленно. Вызов exit() в деструкторе может привести к бесконечной рекурсии. Если нужна гарантия, что будут уничтожены как статические, так и автоматические объекты, можно воспользоваться особыми ситуациями ($$9). Иногда при разработке библиотеки бывает необходимо или просто удобно создать тип с конструктором и деструктором только для одной цели: инициализации и уничтожения объектов. Такой тип используется только один раз для размещения статического объекта, чтобы вызвать конструкторы и деструкторы. 5.5.3 Свободная память Рассмотрим пример: main() { table* p = new table(100); table* q = new table(200); delete p; delete p; // вероятно, вызовет ошибку при выполнении } Конструктор table::table() будет вызываться дважды, как и деструктор table::~table(). Но это ничего не значит, т.к. в С++ не гарантируется, что деструктор будет вызываться только для объекта, созданного операцией new. В этом примере q не уничтожается вообще, зато p уничтожается дважды! В зависимости от типа p и q программист может считать или не считать это ошибкой. То, что объект не удаляется, обычно бывает не ошибкой, а просто потерей памяти. В то же время повторное удаление p - серьезная ошибка. Повторное применение delete к тому же самому указателю может привести к бесконечному циклу в подпрограмме, управляющей свободной памятью. Но в языке результат повторного удаления не определен, и он зависит от реализации. Пользователь может определить свою реализацию операций new и delete (см. $$3.2.6 и $$6.7). Кроме того, можно установить взаимодействие конструктора или деструктора с операциями new и delete (см. $$5.5.6 и $$6.7.2). Размещение массивов в свободной памяти обсуждается в $$5.5.5. 5.5.4 Объекты класса как члены Рассмотрим пример: class classdef { table members; int no_of_members; // ... classdef(int size); ~classdef(); }; Цель этого определения, очевидно, в том, чтобы classdef содержал член, являющийся таблицей размером size, но есть сложность: надо обеспечить вызов конструктора table::table() с параметром size. Это можно сделать, например, так: classdef::classdef(int size) :members(size) { no_of_members = size; // ... } Параметр для конструктора члена (т.е. для table::table()) указывается в определении (но не в описании) конструктора класса, содержащего член (т.е. в определении classdef::classdef()). Конструктор для члена будет вызываться до выполнения тела того конструктора, который задает для него список параметров. Аналогично можно задать параметры для конструкторов других членов (если есть еще другие члены): class classdef { table members; table friends; int no_of_members; // ... classdef(int size); ~classdef(); }; Списки параметров для членов отделяются друг от друга запятыми (а не двоеточиями), а список инициализаторов для членов можно задавать в произвольном порядке: classdef::classdef(int size) : friends(size), members(size), no_of_members(size) { // ... } Конструкторы вызываются в том порядке, в котором они заданы в описании класса. Подобные описания конструкторов существенны для типов, инициализация и присваивание которых отличны друг от друга, иными словами, для объектов, являющихся членами класса с конструктором, для постоянных членов или для членов типа ссылки. Однако, как показывает член no_of_members из приведенного примера, такие описания конструкторов можно использовать для членов любого типа. Если конструктору члена не требуется параметров, то и не нужно задавать никаких списков параметров. Так, поскольку конструктор table::table() был определен со стандартным значением параметра, равным 15, достаточно такого определения: classdef::classdef(int size) : members(size), no_of_members(size) { // ... } Тогда размер таблицы friends будет равен 15. Если уничтожается объект класса, который сам содержит объекты класса (например, classdef), то вначале выполняется тело деструктора объемлющего класса, а затем деструкторы членов в порядке, обратном их описанию. Рассмотрим вместо вхождения объектов класса в качестве членов традиционное альтернативное ему решение: иметь в классе указатели на члены и инициализировать члены в конструкторе: class classdef { table* members; table* friends; int no_of_members; // ... }; classdef::classdef(int size) { members = new table(size); friends = new table; // используется стандартный // размер table no_of_members = size; // ... } Поскольку таблицы создавались с помощью операции new, они должны уничтожаться операцией delete: classdef::~classdef() { // ... delete members; delete friends; } Такие отдельно создаваемые объекты могут оказаться полезными, но учтите, что members и friends указывают на независимые от них объекты, каждый из которых надо явно размещать и удалять. Кроме того, указатель и объект в свободной памяти суммарно занимают больше места, чем объект-член. 5.5.5 Массивы объектов класса Чтобы можно было описать массив объектов класса с конструктором, этот класс должен иметь стандартный конструктор, т.е. конструктор, вызываемый без параметров. Например, в соответствии с определением table tbl[10]; будет создан массив из 10 таблиц, каждая из которых инициализируется вызовом table::table(15), поскольку вызов table::table() будет происходить с фактическим параметром 15. В описании массива объектов не предусмотрено возможности указать параметры для конструктора. Если члены массива обязательно надо инициализировать разными значениями, то начинаются трюки с глобальными или статическими членами. Когда уничтожается массив, деструктор должен вызываться для каждого элемента массива. Для массивов, которые размещаются не с помощью new, это делается неявно. Однако для размещенных в свободной памяти массивов неявно вызывать деструктор нельзя, поскольку транслятор не отличит указатель на отдельный объект массива от указателя на начало массива, например: void f() { table* t1 = new table; table* t2 = new table[10]; delete t1; // удаляется одна таблица delete t2; // неприятность: // на самом деле удаляется 10 таблиц } В данном случае программист должен указать, что t2 - указатель на массив: void g(int sz) { table* t1 = new table; table* t2 = new table[sz]; delete t1; delete[] t2; } Функция размещения хранит число элементов для каждого размещаемого массива. Требование использовать для удаления массивов только операцию delete[] освобождает функцию размещения от обязанности хранить счетчики числа элементов для каждого массива. Исполнение такой обязанности в реализациях С++ вызывало бы существенные потери времени и памяти и нарушило совместимость с С. 5.5.6 Небольшие объекты Если в вашей программе много небольших объектов, размещаемых в свободной памяти, то может оказаться, что много времени тратится на размещение и удаление таких объектов. Для выхода из этой ситуации можно определить более оптимальный распределитель памяти общего назначения, а можно передать обязанность распределения свободной памяти создателю класса, который должен будет определить соответствующие функции размещения и удаления. Вернемся к классу name, который использовался в примерах с table. Он мог бы определяться так: struct name { char* string; name* next; double value; name(char*, double, name*); ~name(); void* operator new(size_t); void operator delete(void*, size_t); private: enum { NALL = 128 }; static name* nfree; }; Функции name::operator new() и name::operator delete() будут использоваться (неявно) вместо глобальных функций operator new() и operator delete(). Программист может для конкретного типа написать более эффективные по времени и памяти функции размещения и удаления, чем универсальные функции operator new() и operator delete(). Можно, например, разместить заранее "куски" памяти, достаточной для объектов типа name, и связать их в список; тогда операции размещения и удаления сводятся к простым операциям со списком. Переменная nfree используется как начало списка неиспользованных кусков памяти: void* name::operator new(size_t) { register name* p = nfree; // сначала выделить if (p) nfree = p->next; else { // выделить и связать в список name* q = (name*) new char[NALL*sizeof(name) ]; for (p=nfree=&q[NALL-1]; q<p; p--) p->next = p-1; (p+1)->next = 0; } return p; } Распределитель памяти, вызываемый new, хранит вместе с объектом его размер, чтобы операция delete выполнялась правильно. Этого дополнительного расхода памяти можно легко избежать, если использовать распределитель, рассчитанный на конкретный тип. Так, на машине автора функция name::operator new() для хранения объекта name использует 16 байтов, тогда как стандартная глобальная функция operator new() использует 20 байтов. Отметим, что в самой функции name::operator new() память нельзя выделять таким простым способом: name* q= new name[NALL]; Это вызовет бесконечную рекурсию, т.к. new будет вызывать name::name(). Освобождение памяти обычно тривиально: void name::operator delete(void* p, size_t) { ((name*)p)->next = nfree; nfree = (name*) p; } Приведение параметра типа void* к типу name* необходимо, поскольку функция освобождения вызывается после уничтожения объекта, так что больше нет реального объекта типа name, а есть только кусок памяти размером sizeof(name). Параметры типа size_t в приведенных функциях name::operator new() и name::operator delete() не использовались. Как можно их использовать, будет показано в $$6.7. Отметим, что наши функции размещения и удаления используются только для объектов типа name, но не для массивов names. 5.6 Упражнения 1. (*1) Измените программу калькулятора из главы 3 так, чтобы можно было воспользоваться классом table. 2. (*1) Определите tnode ($$R.9) как класс с конструкторами и деструкторами и т.п., определите дерево из объектов типа tnode как класс с конструкторами и деструкторами и т.п. 3. (*1) Определите класс intset ($$5.3.2) как множество строк. 4. (*1) Определите класс intset как множество узлов типа tnode. Структуру tnode придумайте сами. 5. (*3) Определите класс для разбора, хранения, вычисления и печати простых арифметических выражений, состоящих из целых констант и операций +, -, * и /. Общий интерфейс класса должен выглядеть примерно так: class expr { // ... public: expr(char*); int eval(); void print(); }; Конструктор expr::expr() имеет параметр-строку, задающую выражение. Функция expr::eval() возвращает значение выражения, а expr::print() выдает представление выражения в cout. Использовать эти функции можно так: expr("123/4+123*4-3"); cout << "x = " << x.eval() << "\n"; x.print(); Дайте два определения класса expr: пусть в первом для представления используется связанный список узлов, а во втором - строка символов. Поэкспериментируйте с разными форматами печати выражения, а именно: с полностью расставленными скобками, в постфиксной записи, в ассемблерном коде и т.д. 6. (*1) Определите класс char_queue (очередь символов) так, чтобы его общий интерфейс не зависел от представления. Реализуйте класс как: (1) связанный список и (2) вектор. О параллельности не думайте. 7. (*2) Определите класс histogram (гистограмма), в котором ведется подсчет чисел в определенных интервалах, задаваемых в виде параметров конструктору этого класса. Определите функцию выдачи гистограммы. Сделайте обработку значений, выходящих за интервал. Подсказка: обратитесь к <task.h>. 8. (*2) Определите несколько классов, порождающих случайные числа с определенными распределениями. Каждый класс должен иметь конструктор, задающий параметры распределения и функцию draw, возвращающую "следующее" значение. Подсказка: обратитесь к <task.h> и классу intset. 9. (*2) Перепишите примеры date ($$5.2.2 и $$5.2.4), char_stack ($$5.2.5) и intset ($$5.3.2), не используя никаких функций-членов (даже конструкторов и деструкторов). Используйте только class и friend. Проверьте каждую из новых версий и сравните их с версиями, в которых используются функции-члены. 10.(*3) Для некоторого языка составьте определения класса для таблицы имен и класса, представляющего запись в этой таблице. Исследуйте транслятор для этого языка, чтобы узнать, какой должна быть настоящая таблица имен. 11.(*2) Измените класс expr из упражнения 5 так, чтобы в выражении можно было использовать переменные и операцию присваивания =. Используйте класс для таблицы имен из упражнения 10. 12.(*1) Пусть есть программа: #include <iostream.h> main() { cout << "Всем привет\n"; } Измените ее так, чтобы она выдавала: Инициализация Всем привет Удаление Саму функцию main() менять нельзя.  * ГЛАВА 6 Не плоди объекты без нужды. - В. Оккам Эта глава посвящена понятию производного класса. Производные классы - это простое, гибкое и эффективное средство определения класса. Новые возможности добавляются к уже существующему классу, не требуя его перепрограммирования или перетрансляции. С помощью производных классов можно организовать общий интерфейс с несколькими различными классами так, что в других частях программы можно будет единообразно работать с объектами этих классов. Вводится понятие виртуальной функции, которое позволяет использовать объекты надлежащим образом даже в тех случаях, когда их тип на стадии трансляции неизвестен. Основное назначение производных классов - упростить программисту задачу выражения общности классов. 6.1 Введение и краткий обзор Любое понятие не существует изолированно, оно существует во взаимосвязи с другими понятиями, и мощность данного понятия во многом определяется наличием таких связей. Раз класс служит для представления понятий, встает вопрос, как представить взаимосвязь понятий. Понятие производного класса и поддерживающие его языковые средства служат для представления иерархических связей, иными словами, для выражения общности между классами. Например, понятия окружности и треугольника связаны между собой, так как оба они представляют еще понятие фигуры, т.е. содержат более общее понятие. Чтобы представлять в программе окружности и треугольники и при этом не упускать из вида, что они являются фигурами, надо явно определять классы окружность и треугольник так, чтобы было видно, что у них есть общий класс - фигура. В главе исследуется, что вытекает из этой простой идеи, которая по сути является основой того, что обычно называется объектно-ориентированным программированием. Глава состоит из шести разделов: $$6.2 с помощью серии небольших примеров вводится понятие производного класса, иерархии классов и виртуальных функций. $$6.3 вводится понятие чисто виртуальных функций и абстрактных классов, даны небольшие примеры их использования. $$6.4 производные классы показаны на законченном примере $$6.5 вводится понятие множественного наследования как возможность иметь для класса более одного прямого базового класса, описываются способы разрешения коллизий имен, возникающих при множественном наследовании. $$6.6 обсуждается механизм контроля доступа. $$6.7 приводятся некоторые приемы управления свободной памятью для производных классов. В последующих главах также будут приводиться примеры, использующие эти возможности языка. 6.2 Производные классы Обсудим, как написать программу учета служащих некоторой фирмы. В ней может использоваться, например, такая структура данных: struct employee { // служащие char* name; // имя short age; // возраст short department; // отдел int salary; // оклад employee* next; // ... }; Поле next нужно для связывания в список записей о служащих одного отдела (employee). Теперь попробуем определить структуру данных для управляющего (manager): struct manager { employee emp; // запись employee для управляющего employee* group; // подчиненный коллектив short level; // ... }; Управляющий также является служащим, поэтому запись employee хранится в члене emp объекта manager. Для человека эта общность очевидна, но для транслятора член emp ничем не отличается от других членов класса. Указатель на структуру manager (manager*) не является указателем на employee (employee*), поэтому нельзя свободно использовать один вместо другого. В частности, без специальных действий нельзя объект manager включить в список объектов типа employee. Придется либо использовать явное приведение типа manager*, либо в список записей employee включить адрес члена emp. Оба решения некрасивы и могут быть достаточно запутанными. Правильное решение состоит в том, чтобы тип manager был типом employee с некоторой дополнительной информацией: struct manager : employee { employee* group; short level; // ... }; Класс manager является производным от employee, и, наоборот, employee является базовым классом для manager. Помимо члена group в классе manager есть члены класса employee (name, age и т.д.). Графически отношение наследования обычно изображается в виде стрелки от производных классов к базовому: employee ^ | manager Обычно говорят, что производный класс наследует базовый класс, поэтому и отношение между ними называется наследованием. Иногда базовый класс называют суперклассом, а производный - подчиненным классом. Но эти термины могут вызывать недоумение, поскольку объект производного класса содержит объект своего базового класса. Вообще производный класс больше своего базового в том смысле, что в нем содержится больше данных и определено больше функций. Имея определения employee и manager, можно создать список служащих, часть из которых является и управляющими: void f() { manager m1, m2; employee e1, e2; employee* elist; elist = &m1; // поместить m1 в elist m1.next = &e1; // поместить e1 в elist e1.next = &m2; // поместить m2 в elist m2.next = &e2; // поместить m2 в elist e2.next = 0; // конец списка } Поскольку управляющий является и служащим, указатель manager* можно использовать как employee*. В то же время служащий не обязательно является управляющим, и поэтому employee* нельзя использовать как manager*. В общем случае, если класс derived имеет общий базовый класс base, то указатель на derived можно без явных преобразований типа присваивать переменной, имеющей тип указателя на base. Обратное преобразование от указателя на base к указателю на derived может быть только явным: void g() { manager mm; employee* pe = &mm; // нормально employee ee; manager* pm = &ee; // ошибка: // не всякий служащий является управляющим pm->level = 2; // катастрофа: при размещении ee // память для члена `level' не выделялась pm = (manager*) pe; // нормально: на самом деле pe // не настроено на объект mm типа manager pm->level = 2; // отлично: pm указывает на объект mm // типа manager, а в нем при размещении // выделена память для члена `level' } Иными словами, если работа с объектом производного класса идет через указатель, то его можно рассматривать как объект базового класса. Обратное неверно. Отметим, что в обычной реализации С++ не предполагается динамического контроля над тем, чтобы после преобразования типа, подобного тому, которое использовалось в присваивании pe в pm, получившийся в результате указатель действительно был настроен на объект требуемого типа (см. $$13.5). 6.2.1 Функции-члены Простые структуры данных вроде employee и manager сами по себе не слишком интересны, а часто и не особенно полезны. Поэтому добавим к ним функции: class employee { char* name; // ... public: employee* next; // находится в общей части, чтобы // можно было работать со списком void print() const; // ... }; class manager : public employee { // ... public: void print() const; // ... }; Надо ответить на некоторые вопросы. Каким образом функция-член производного класса manager может использовать члены базового класса employee? Какие члены базового класса employee могут использовать функции-члены производного класса manager? Какие члены базового класса employee может использовать функция, не являющаяся членом объекта типа manager? Какие ответы на эти вопросы должна давать реализация языка, чтобы они максимально соответствовали задаче программиста? Рассмотрим пример: void manager::print() const { cout << " имя " << name << '\n'; } Член производного класса может использовать имя из общей части своего базового класса наравне со всеми другими членами, т.е. без указания имени объекта. Предполагается, что есть объект, на который настроен this, поэтому корректным обращением к name будет this->name. Однако, при трансляции функции manager::print() будет зафиксирована ошибка: члену производного класса не предоставлено право доступа к частным членам его базового класса, значит name недоступно в этой функции. Возможно многим это покажется странным, но давайте рассмотрим альтернативное решение: функция-член производного кл