В этой главе описывается понятие производного класса в C++.
Производные классы дают простой, гибкий и эффективный аппарат
задания для класса альтернативного интерфейса и определения класса
посредством добавления возможностей к уже имеющемуся классу без
перепрограммирования или перекомпиляции. С помощью производных
классов можно также обеспечить общий интерфейс для нескольких
различных классов так, чтобы другие части программы могли работать
с объектами этих классов одинаковым образом. При этом обычно в
каждый объект помещается информация о типе, чтобы эти объекты могли
обрабатываться соответствующим образом в ситуациях, когда их тип
нельзя узнать во время компиляции. Для элегантной и надежной
обработки таких динамических зависимостей типов имеется понятие
виртуальной функции. По своей сути производные классы существуют
для того, чтобы облегчить программисту формулировку общности.
Представим себе процесс написания некоторого средства общего
назначения (например, тип связанный список, таблица имен или
планировщик для системы моделирования), которое предназначается
для использования многими разными людьми в различных
обстоятельствах. Очевидно, что в кандидатах на роль таких средств
недостатка нет, и выгоды от их стандартизации огромны. Кажется,
любой опытный программист написал (и отладил) дюжину вариантов
типов множества, таблицы имен, сортирующей функции и т.п., но
оказывается, что таблиц имен каждый программист и каждая программа
используют свою версию этих понятий, из-за чего программы слишком
трудно читать, тяжело отлаживать и сложно модифицировать. Более
того, в большой программе вполне может быть несколько копий
идентичных (почти) частей кода для работы с такими фундаментальными
понятиями.
7.1 Введение
Причина этого хаоса частично состоит в том, что представить такие
общие понятия в языке программирования сложно с концептуальной точки
зрения, а частично в том, что средства, обладающие достаточной
общностью, налагают дополнительные расходы по памяти и/или по
времени, что делает их неудобными для самых простых и наиболее
напряженно используемых средств (связанные списки, вектора и т.п.),
где они были бы наиболее полезны. Понятие производного класса в
C++, описываемое в #7.2, не обеспечивают общего решения всех этих проблем, но оно дает способ справляться с довольно небольшим числом
важных случаев. Будет, например, показано, как определить
эффективный класс общего связанного списка таким образом, чтобы все
его версии использовали код совместно.
Написание общецелевых средств - задача непростая, и часто
основной акцент в их разработке другой, чем при разработке программ
специального назначения. Конечно, нет четкой границы между
средствами общего и специального назначения, и к методам и языковым
средствам, которые описываются в этой главе, можно относиться так,
что они становятся все более полезны с ростом объема и сложности
создаваемых программ.
7.2 Производные Классы
7.2.1 Построение Производного Класса | |
7.2.2 Функции Члены | |
7.2.3 Видимость | |
7.2.4 Указатели | |
7.2.5 Иерархия Типов | |
7.2.6 Конструкторы и Деструкторы | |
7.2.7 Поля Типа | |
7.2.8 Виртуальные Функции |
Чтобы разделить задачи понимания аппарата языка и методов его
применения, знакомство с понятием производных классов делается в
три этапа. Вначале с помощью небольших примеров, которые не надо
воспринимать как реалистичные, будут описаны сами средства языка
(запись и семантика). После этого демонстрируются некоторые
неочевидные применения производных классов, и, наконец, приводится
законченная программа.
Рассмотрим построение программы, которая имеет дело с людьми,
служащими в некоторой фирме. Структура данных в этой программе
может быть например такой:
Просто структуры данных вроде employee и manager на самом деле не
столь интересны и часто не особенно полезны, поэтому рассмотрим,
как добавить к ним функции. Например:
Класс employee стал открытым (public) базовым классом класса
manager в результате описания:
Если производный класс derived имеет открытый базовый класс base,
то указатель на derived можно присваивать переменной типа указатель
на base не используя явное преобразование типа. Обратное
преобразование, указателя на base в указатель на derived, должно
быть явным. Например:
Производный класс сам может быть базовым классом. Например:
Для некоторых производных классов нужны конструкторы. Если у
базового класса есть конструктор, он должен вызываться, и если для
этого конструктора нужны параметры, их надо предоставить. Например:
7.2.1 Построение Производного Класса
struct employee { // служащий
char* name; // имя
short age; // возраст
short department; // подразделение
int salary; //
employee* next;
// ...
};
Список аналогичных служащих будет связываться через поле next.
Теперь давайте определим менеджера:
struct manager { // менеджер
employee emp; // запись о менеджере как о служащем
employee* group; // подчиненные люди
// ...
};
Менеджер также является служащим; относящиеся к служащему employee
данные хранятся в члене emp объекта manager. Для читающего это
человека это, может быть, очевидно, но нет ничего выделяющего член
emp для компилятора. Указатель на менеджера (manager*) не является
указателем на служащего (employee*), поэтому просто использовать
один там, где требуется другой, нельзя. В частности, нельзя
поместить менеджера в список служащих, не написав для этого
специальную программу. Можно либо применить к manager* явное
преобразование типа, либо поместить в список служащих адрес члена
emp, но и то и другое мало элегантно и довольно неясно. Корректный
подход состоит в том, чтобы установить, что менеджер является
служащим с некоторой добавочной информацией:
struct manager : employee {
employee* group;
// ...
};
manager является производным от employee и, обратно, employee есть
базовый класс для manager. Класс manager дополнительно к члену
group имеет члены класса employee (name, age и т.д.).
Имея определения employee и manager мы можем теперь создать
список служащих, некоторые из которых являются менеджерами.
Например:
void f()
{
manager m1, m2;
employee e1, e2;
employee* elist;
elist = &m1; // поместить m1, e1, m2 и e2 в elist
m1.next = &e1;
e1.next = &m2;
m2.next = &e2;
e2.next = 0;
}
Поскольку менеджер является служащим, manager* может использоваться
как employee*. Однако служащий необязательно является менеджером,
поэтому использовать employee* как manager* нельзя.
7.2.2 Функции Члены
class employee {
char* name;
// ...
public:
employee* next;
void print();
// ...
};
class manager : public employee {
// ...
public:
void print();
// ...
};
Надо ответить на некоторые вопросы. Как может функция член
производного класса manager использовать члены его базового класса
employee? Как члены базового класса employee могут использовать
функции члены производного класса manager? Какие члены базового
класса employee может использовать функция не член на объекте типа
manager? Каким образом программист может повлиять на ответы на эти
вопросы, чтобы удовлетворить требованиям приложения?
Рассмотрим:
void manager::print()
{
cout << " имя " << name << "\n";
// ...
}
Член производного класса может использовать открытое имя из своего
базового класса так же, как это могут делать другие члены
последнего, то есть без указания объекта. Предполагается, что на
объект указывает this, поэтому (корректной) ссылкой на имя name
является this->name. Однако функция manager::print компилироваться
не будет, член производного класса не имеет никакого особого права
доступа к закрытым членам его базового класса, поэтому для нее name
недоступно.
Это многим покажется удивительным, но представьте себе другой
вариант: что функция член могла бы обращаться к закрытым членам
своего базового класса. Возможность, позволяющая программисту
получать доступ к закрытой части класса просто с помощью вывода из
него другого класса, лишила бы понятие закрытого члена всякого
смысла. Более того, нельзя было бы узнать все использования
закрытого имени посмотрев на функции, описанные как члены и друзья
этого класса. Пришлось бы проверять каждый исходный файл во всей
программе на наличие в нем производных классов, потом исследовать
каждую функцию этих классов, потом искать все классы, производные
от этих классов, и т.д. Это по меньшей мере утомительно и скорее
всего нереально.
С другой стороны, можно ведь использовать механизм friend, чтобы
предоставить такой доступ или отдельным функциям, или всем функциям
отдельного класса (как описывается в #5.3). Например:
class employee {
friend void manager::print();
// ...
};
решило бы проблему с manager::print(), и
class employee {
friend class manager;
// ...
};
сделало бы доступным каждый член employee для всех функций класса
manager. В частности, это сделает name доступным для
manager::print().
Другое, иногда более прозрачное решение для производного класса, -
использовать только открытые члены его базового класса. Например:
void manager::print()
{
employee::print(); // печатает информацию о служащем
// ... // печатает информацию о менеджере
}
Заметьте, что надо использовать ::, потому что print() была
переопределена в manager. Такое повторное использование имен
типично. Неосторожный мог бы написать так:
void manager::print()
{
print(); // печатает информацию о служащем
// ... // печатает информацию о менеджере
}
и обнаружить, что программа после вызова manager::print()
неожиданно попадает в последовательность рекурсивных вызовов.
7.2.3 Видимость
class manager : public employee {
// ...
};
Это означает, что открытый член класса employee является также и
открытым членом класса manager. Например:
void clear(manager* p)
{
p->next = 0;
}
будет компилироваться, так как next - открытый член и employee и
manager'а. Альтернатива - можно определить закрытый (private)
класс, просто опустив в описании класса слово public:
class manager : employee {
// ...
};
Это означает, что открытый член класса employee является закрытым
членом класса manager. То есть, функции члены класса manager могут
как и раньше использовать открытые члены класса employee, но для
пользователей класса manager эти члены недоступны. В частности, при
таком описании класса manager функция clear() компилироваться не
будет. Друзья производного класса имеют к членам базового класса
такой же доступ, как и функции члены.
Поскольку, как оказывается, описание открытых базовых классов
встречается чаще описания закрытых, жалко, что описание открытого
базового класса длиннее описания закрытого. Это, кроме того, служит
источником запутывающих ошибок у начинающих.
Когда описывается производная struct, ее базовый класс по
умолчанию является public базовым классом. То есть,
struct D : B { ...
означает
class D : public B { public: ...
Отсюда следует, что если вы не сочли полезным то скрытие данных,
которое дают class, public и friend, вы можете просто не
использовать эти ключевые слова и придерживаться struct. Такие
средства языка, как функции члены, конструкторы и перегрузка
операций, не зависят от механизма скрытия данных.
Можно также объявить некоторые, но не все, открытые $ члены
базового класса открытыми членами производного класса. Например:
class manager : employee {
// ...
public:
// ...
employee::name;
employee::department;
};
Запись
имя_класса :: имя_члена ;
не вводит новый член, а просто делает открытый член базового класса
открытым для производного класса. Теперь name и department могут
использоваться для manager'а, а salary и age - нет. Естественно,
сделать сделать закрытый член базового класса открытым членом
производного класса невозможно. Невозможно с помощью этой записи
также сделать открытыми перегруженные имена.
Подытоживая, можно сказать, что вместе с предоставлением средств
дополнительно к имеющимся в базовом классе, производный класс можно
использовать для того, чтобы сделать средства (имена) недоступными
для пользователя. Другими словами, с помощью производного класса
можно обеспечивать прозрачный, полупрозрачный и непрозрачный доступ
к его базовому классу.
7.2.4 Указатели
class base { /* ... */ };
class derived : public base { /* ... */ };
derived m;
base* pb = &m; // неявное преобразование
derived* pd = pb; // ошибка: base* не является derived*
pd = (derived*)pb; // явное преобразование
Иначе говоря, объект производного класса при работе с ним через
указатель и можно рассматривать как объект его базового класса.
Обратное неверно.
Будь base закрытым базовым классом класса derived, неявное
преобразование derived* в base* не делалось бы. Неявное
преобразование не может в этом случае быть выполнено, потому что к
открытому члкну класса base можно обращаться через указатель на
base, но нельзя через указатель на derived:
class base {
int m1;
public:
int m2; // m2 - открытый член base
};
class derived : base {
// m2 НЕ открытый член derived
};
derived d;
d.m2 = 2; // ошибка: m2 из закрытой части класса
base* pb = &d; // ошибка: (закрытый base)
pb->m2 = 2; // ok
pb = (base*)&d; // ok: явное преобразование
pb->m2 = 2; // ok
Помимо всего прочего, этот пример показывает, что используя явное
приведение к типу можно сломать правила защиты. Ясно, делать это не
рекомендуется, и это приносит программисту заслуженную "награду". К
несчастью , недисциплинированное использование явного преобразования
может создать адские условия для невинных жертв, которые
эксплуатируют программу, где это делается. Но, к счастью, нет
способа воспользоваться приведением для получения доступа к
закрытому имени m1. Закрытый член класса может использоваться
только членами и друзьями этого класса.
7.2.5 Иерархия Типов
class employee { ... };
class secretary : employee { ... };
class manager : employee { ... };
class temporary : employee { ... };
class consultant : temporary { ... };
class director : manager { ... };
class vice_president : manager { ... };
class president : vice_president { ... };
Такое множество родственных классов принято называть иерархией
классов. Поскольку можно выводить класс только из одного базового
класса, такая иерархия является деревом и не может быть графом
более общей структуры. Например:
class temporary { ... };
class employee { ... };
class secretary : employee { ... };
// не C++:
class temporary_secretary : temporary : secretary { ... };
class consultant : temporary : employee { ... };
И этот факт вызывает сожаление, потому что направленный
ациклический граф производных классов был бы очень полезен. Такие
структуры описать нельзя, но можно смоделировать с помощью членов
соответствующий типов. Например:
class temporary { ... };
class employee { ... };
class secretary : employee { ... };
// Альтернатива:
class temporary_secretary : secretary
{ temporary temp; ... };
class consultant : employee
{ temporary temp; ... };
Это выглядит неэлегантно и страдает как раз от тех проблем, для
преодоления которых были изобретены производные классы. Например,
поскольку consultant не является производным от temporary,
consultant'а нельзя помещать с список временных служащих (temporary
employee), не написав специальной программы. Однако во многих
полезных программах этот метод успешно используется.
7.2.6 Конструкторы и Деструкторы
class base {
// ...
public:
base(char* n, short t);
~base();
};
class derived : public base {
base m;
public:
derived(char* n);
~derived();
};
Параметры конструктора базового класса специфицируются в
определении конструктора производного класса. В этом смысле базовый
класс работает точно также, как неименованный член производного
класса (см. #5.5.4). Например:
derived::derived(char* n) : (n,10), m("member",123) { // ... }
Чтобы использовать производные классы не просто как удобную
сокращенную запись в описаниях, надо разрешить следующую проблему:
Если задан указатель типа base*, какому производному типу в
действительности принадлежит указываемый объект? Есть три основных
способа решения этой проблемы:
[1] Обеспечить, чтобы всегда указывались только объекты одного
типа (#7.3.3);
[2] Поместить в базовый класс поле типа, которое смогут
просматривать функции; и
[3] Использовать виртуальные функции (#7.2.8).
Обыкновенно указатели на базовые классы используются при
разработке контейнерных (или вмещающих) классов: множество, вектор,
список и т.п. В этом случае решение 1 дает однородные списки, то
есть списки объектов одного типа. Решения 2 и 3 можно использовать
для построения неоднородных списков, то есть списков объектов
(указателей на объекты) нескольких различных типов. Решение 3 - это
специальный вариант решения 2, безопасный относительно типа.
Давайте сначала исследуем простое решение с помощью поля типа, то
есть решение 2. Пример со служащими и менеджерами можно было бы
переопределить так:
enum empl_type { M, E }; struct employee { empl_type type; employee* next; char* name; short department; // ... }; struct manager : employee { employee* group; short level; // уровень };
void print_employee(employee* e) { switch (e->type) { case E: cout << e->name << "\t" << e->department << "\n"; // ... break; case M: cout << e->name << "\t" << e->department << "\n"; // ... manager* p = (manager*)e; cout << " уровень " << p->level << "\n"; // ... break; } }
void f() { for (; ll; ll=ll->next) print_employee(ll); }
void print_employee(employee* e) { cout << e->name << "\t" << e->department << "\n"; // ... if (e->type == M) { manager* p = (manager*)e; cout << " уровень " << p->level << "\n"; // ... } }
Виртуальные функции преодолевают сложности решения с помощью полей типа, позволяя программисту описывать в базовом классе функции, которые можно переопределять в любом производном классе. Компилятор и загрузчик обеспечивают правильное соответствие между объектами и применяемыми к ним функциями. Например:
struct employee { employee* next; char* name; short department; // ... virtual void print(); };
void employee::print() { cout << e->name << "\t" << e->department << "\n"; // ... }
struct manager : employee { employee* group; short level; // ... void print(); }; void manager::print() { employee::print(); cout << "\tуровень" << level << "\n"; // ... }
void f(employee* ll) { for (; ll; ll=ll->next) ll->print(); }
main() { employee e; e.name = "Дж.Браун"; e.department = 1234; e.next = 0; manager m; m.name = "Дж.Смит"; e.department = 1234; m.level = 2; m.next = &e; f(&m); }
Дж.Смит 1234 уровень 2 Дж.Браун 1234
7.3.1 Интерфейс | |
7.3.2 Реализация | |
7.3.3 Как Этим Пользоваться | |
7.3.4 Обработка Ошибок | |
7.3.5 Обобщенные Классы | |
7.3.6 Ограниченные Интерфейсы |
После того, как описаны средства языка, которые относятся к
производным классам, обсуждение снова может вернуться к стоящим
задачам. В классах, которые описываются в этом разделе,
основополагающая идея состоит в том, что они однажды написаны, а
потом их используют программисты, которые не могут изменить их
определение. Физически классы состоят из одного или более
заголовочных файлов, определяющих интерфейс, и одного или более
файлов, определяющих реализацию. Заголовочные файлы будут помещены
куда-то туда, откуда пользователь может взять их копии с помощью
директивы #include. Файлы, определяющие реализацию, обычно
компилируют и помещают в библиотеку.
Рассмотрим такое написание класса slist для однократно связанного
списка, с помощью которого можно создавать как однородные, так и
неоднородные списки объектов тех типов, которые еще должны быть
определены. Сначала мы определим тип ent:
Реализующие slist функции в основном просты. Единственная
настоящая сложность - что делать в случае ошибки, если, например,
пользователь попытается get() что-нибудь из пустого списка. Мы
обсудим это в #7.3.4. Здесь приводятся определения членов slist. Обратите внимание, как хранение указателя на последний элемент
кругового списка дает возможность просто реализовать оба действия
append() и insert():
7.3.1 Интерфейс
typedef void* ent;
Точная сущность типа ent несущественна, но нужно, чтобы в нем мог
храниться указатель. Тогда мы определим тип slink:
class slink {
friend class slist;
friend class slist_iterator;
slink* next;
ent e;
slink(ent a, slink* p) { e=a; next=p;}
};
В одном звене может храниться один ent, и с помощью него
реализуется класс slist:
class slist {
friend class slist_iterator;
slink* last; // last->next - голова списка
public:
int insert(ent a); // добавить в голову списка
int append(ent a); // добавить в хвост списка
ent get(); // вернуться и убрать голову списка
void clear(); // убрать все звенья
slist() { last=0; }
slist(ent a) { last=new slink(a,0); last->next=last; }
~slist() { clear(); }
};
Хотя список очевидным образом реализуется как связанный список,
реализацию можно изменить так, чтобы использовался вектор из
ent'ов, не повлияв при этом на пользователей. То есть, применение
slink'ов никак не видно в описаниях открытых функций slist'ов, а
видно только в закрытой части и определениях функций.
7.3.2 Реализация
int slist::insert(ent a)
{
if (last)
last->next = new slink(a,last->next);
else {
last = new slink(a,0);
last->next = last;
}
return 0;
}
int slist::append(ent a)
{
if (last)
last = last->next = new slink(a,last->next);
else {
last = new slink(a,0);
last->next = last;
}
return 0;
}
ent slist::get()
{
if (last == 0) slist_handler("get fromempty list");
// взять из пустого списка
slink* f = last->next;
ent r f->e;
if (f == last)
last = 0;
else
last->next = f->next;
delete f;
return f;
}
Обратите внимание, как вызывается slist_handler (его описание можно
найти в #7.3.4). Этот указатель на имя функции используется точно так же, как если бы он был именем функции. Это является краткой
формой более явной записи вызова:
(*slist_handler)("get fromempty list"); И slist::clear(), наконец, удаляет из списка все элементы: void slist::clear() { slink* l = last; if (l == 0) return; do { slink* ll = l; l = l->next; delete ll; } while (l!=last); }
class slist_iterator { slink* ce; slist* cs; public: slist_iterator(slist& s) { cs = &s; ce = cs->last; } ent operator()() { // для индикации конца итерации возвращает 0 // для всех типов не идеален, хорош для указателей ent ret = ce ? (ce=ce->next)->e : 0; if (ce == cs->last) ce= 0; return ret; } };
Фактически класс slist в написанном виде бесполезен. В конечном счете, зачем можно использовать список указателей void*? Штука в том, чтобы вывести класс из slist и получить список тех объектов, которые представляют интерес в конкретной программе. Представим компилятор языка вроде C++. В нем широко будут использоваться списки имен; имя - это нечто вроде
struct name { char* string; // ... };
#include "slist.h" #include "name.h" struct nlist : slist { void insert(name* a) { slist::insert(a); } void append(name* a) { slist::append(a); } name* get() {} nlist(name* a) : (a) {} };
struct classdef { nlist friends; nlist constructors; nlist destructors; nlist members; nlist operators; nlist virtuals; // ... void add_name(name*); classdef(); ~classdef(); };
void classdef::add_name(name* n) { if (n->is_friend()) { if (find(&friends,n)) error("friend redeclared"); else if (find(&members,n)) error("friend redeclared as member"); else friends.append(n); } if (n->is_operator()) operators.append(n); // ... }
int find(nlist* ll, name* n) { slist_iterator ff(*(slist*)ll); ent p; while ( p=ff() ) if (p==n) return 1; return 0; }
void print_list(nlist* ll, char* list_name) { slist_iterator count(*(slist*)ll); name* p; int n = 0; while ( count() ) n++; cout << list_name << "\n" << n << "members\n"; slist_iterator print(*(slist*)ll); while ( p=(name*)print() ) cout << p->string << "\n"; }
Есть четыре подхода к проблеме, что же делать, когда во время
выполнения общецелевое средство вроде slist сталкивается с ошибкой
(в C++ нет никаких специальных средств языка для обработке ошибок):
[1] Возвращать недопустимое значение и позволить пользователю его
проверять;
[2] Возвращать дополнительное значение состояния и разрешить
пользователю проверять его;
[3] Вызывать функцию ошибок, заданную как часть класса slist; или
[4] Вызывать функцию ошибок, которую предположительно
предоставляет пользователь.
Для небольшой программы, написанной ее единственным
пользователем, нет фактически никаких особенных причин предпочесть
одно из этих решений другим. Для средства общего назначения
ситуация совершенно иная.
Первый подход, возвращать недопустимое значение, неосуществим.
Нет совершенно никакого способа узнать, что некоторое конкретное
значение будет недопустимым во всех применениях slist.
Второй подход, возвращать значение состояния, можно использовать
в некоторых классах (один из вариантов этого плана применяется в
стандартных потоках ввода/вывода istream и ostream; как -
объясняется в #8.4.2). Здесь, однако, имеется серьезная проблема, вдруг пользователь не позаботится проверить значение состояния,
если средство не слишком часто подводит. Кроме того, средство может
использоваться в сотнях или даже тысячах мест программы. Проверка
значения в каждом месте сильно затруднит чтение программы.
Третьему подходу, предоставлять функцию ошибок, недостает
гибкости. Тот, кто реализует общецелевое средство, не может узнать,
как пользователи захотят, чтобы обрабатывались ошибки. Например,
пользователь может предпочитать сообщения на датском или
венгерском.
Четвертый подход, позволить пользователю задавать функцию ошибок,
имеет некоторую привлекательность при условии, что разработчик
предоставляет класс в виде библиотеки (#4.5), в которой содержатся стандартные функции обработки ошибок. Решения 3 и 4 можно сделать более гибкими (и по сути эквивалентными), задав указатель на функцию, а не саму функцию. Это
позволит разработчику такого средства, как slist, предоставить
функцию ошибок, действующую по умолчанию, и при этом программистам,
которые будут использовать списки, будет легко задать свои
собственные функции ошибок, если нужно, и там, где нужно.
Например:
typedef void (*PFC)(char*); // указатель на тип функция extern PFC slist_handler; extern PFC set_slist_handler(PFC);
#include "slist.h" #include void default_error(char* s) { cerr << s << "\n"; exit(1); }
PFC slist_handler = default_error; PFC set_slist_handler(PFC handler); { PFC rr = slist_handler; slist_handler = handler; return rr; }
{ PFC old = set_slist_handler(my_handler); // код, в котором в случае ошибок в slist // будет использоваться мой обработчик my_handler set_slist_handler(old); // восстановление }
Очевидно, можно было бы определить списки других типов
(classdef*, int, char* и т.д.) точно так же, как был определен
класс nlist: простым выводом из класса slist. Процесс определения
таких новых типов утомителен (и потому чреват ошибками), но с
помощью макросов его можно "механизировать". К сожалению, если
пользоваться стандартным C препроцессором (#4.7 и #с.11.1), это тоже может оказаться тягостным. Однако полученными в результате
макросами пользоваться довольно просто.
Вот пример того, как обобщенный (generic) класс slist, названный
gslist, может быть задан как макрос. Сначала для написания такого
рода макросов включаются некоторые инструменты из :
#include "slist.h" #ifndef GENERICH #include #endif
#define gslist(type) name2(type,gslist) #define gslist_iterator(type) name2(type,gslist_iterator)
#define gslistdeclare(type) \ struct gslist(type) : slist { \ int insert(type a) \ { return slist::insert( ent(a) ); } \ int append(type a) \ { return slist::append( ent(a) ); } \ type get() { return type( slist::get() ); } \ gslist(type)() { } \ gslist(type)(type a) : (ent(a)) { } \ ~gslist(type)() { clear(); } \ }; \ \ struct gslist_iterator(type) : slist_iterator { \ gslist_iterator(type)(gslist(type)& a) \ : ( (slist&)s ) {} \ type operator()() \ { return type( slist_iterator::operator()() ); } \ }
#include "name.h" typedef name* Pname; declare(gslist,Pname); // описать класс gslist(Pname) gslist(Pname) nl; // описать один gslist(Pname)
Класс slist - довольно общего характера. Иногда подобная общность не требуется или даже нежелательна. Ограниченные виды списков, такие как стеки и очереди, даже более обычны, чем сам обобщенный список. Такие структуры данных можно задать, не описав базовый класс как открытый. Например, очередь целых можно определить так:
#include "slist.h" class iqueue : slist { //предполагается sizeof(int)<=sizeof(void*) public: void put(int a) { slist::append((void*)a); } int det() { return int(slist::get()); } iqueue() {} };
#include "slist.h" class stack : slist { public: slist::insert; slist::get; stack() {} stack(ent a) : (a) {} };
#include "stack.h" class cp : stack { public: void push(char* a) { slist::insert(a); } char* pop() { return (char*)slist::get(); } nlist() {} };
В предыдущих примерах производный класс ничего не добавлял к
базовому классу. Для производного класса функции определялись
только чтобы обеспечить преобразование типа. Каждый производный
класс просто задавал альтернативный интерфейс к общему множеству
программ. Этот специальный случай важен, но наиболее обычная
причина определения новых классов как производных классов в том, что
кто-то хочет иметь то, что предоставляет базовый класс, плюс еще
чуть-чуть.
Для производного класса можно определить данные и функции
дополнительно к тем, которые наследуются из его базового класса.
Это дает альтернативную стратегию обеспечить средства связанного
списка. Заметьте, когда в тот slist, который определялся выше,
помещается элемент, то создается slink, содержащий два указателя.
На их создание тратится время, а ведь без одного из указателей
можно обойтись, при условии, что нужно только чтобы объект мог
находиться в одном списке. Так что указатель next на следующий
можно поместить в сам объект, вместо того, чтобы помещать его в
отдельный объект slink. Идея состоит в том, чтобы создать класс
olink с единственным полем next, и класс olist, который может
обрабатывать указателями на такие звенья olink. Тогда olist сможет
манипулировать объектами любого класса, производного от olink.
Буква "o" в названиях стоит для того, чтобы напоминать вам, что
объект может находиться одновременно только в одном списке olist:
struct olink { olink* next; };
class olist { olink* last; public: void insert(olink* p); void append(olink* p); olink* get(); // ... };
class name : public olink { // ... };
void f() { olist ll; name nn; ll.insert(&nn); // тип &nn потерян name* pn = (name*)ll.get(); // и восстановлен }
class olist : public olist { // ... name* get() { return (name*)olist::get(); } };
Предыдущие списки были однородными. То есть, в список помещались
только объекты одного типа. Это обеспечивалось аппаратом
производных классов. Списки не обязательно должны быть
однородными. Список, заданный в виде указателей на класс, может
содержать объекты любого класса, производного от этого класса. То
есть, список может быть неоднородным. Вероятно, это единственный
наиболее важный и полезный аспект производных классов, и он весьма
существенно используется в стиле программирования, который
демонстрируется приведенным выше примером. Этот стиль
программирования часто называют объектно-основанным или
объектно-ориентированным. Он опирается на то, что действия над
объектами неоднородных списков выполняются одинаковым образом.
Смысл этих действий зависит от фактического типа объектов,
находящихся в списке (что становится известно только на стадии
выполнения), а не просто от типа элементов списка (который
компилятору известен).
7.6 Законченная Программа
7.6.1 Администратор Экрана | |
7.6.2 Библиотека Фигур | |
7.6.3 Прикладная Программа |
Разберем процесс написания программы для рисования на экране
геометрических фигур. Она естественным образом разделяется на три
части:
Вначале было намерение написать администратор экрана на C (а не на
C++), чтобы подчеркнуть разделение уровней реализации. Это
оказалось слишком утомительным, поэтому пришлось пойти на
компромисс: используется стиль C (нет функций членов, виртуальных
функций, определяемых пользователем операций и т.п.), однако
применяются конструкторы, надлежащим образом описываются и
проверяются параметры функций и т.д. Оглядываясь назад, можно
сказать, что администратор экрана очень похож на C программу,
которую потом модифицировали, чтобы воспользоваться средствами C++
не переписывая все полностью.
Нам нужно определить общее понятие фигуры (shape). Это надо
сделать таким образом, чтобы оно использовалось (как базовый класс)
всеми конкретными фигурами (например, кругами и квадратами), и так,
чтобы любой фигурой можно было манипулировать исключительно через
интерфейс, предоставляемый классом shape:
[1] Администратор экрана: подпрограммы низкого уровня и структуры
данных, определяющие экран; он ведает только точками и прямыми
линиями;
[2] Библиотека фигур: набор определений основных фигур вроде
прямоугольника и круга и стандартные программы для работы с
ними; и
[3] Прикладная программа: множество определений,
специализированных для данного приложения, и код, в котором
они используются.
Эти три части скорее всего будут писать разные люди (в разных
организациях и в разное время). При этом части будут скорее всего
писать именно в указанном порядке с тем осложняющим
обстоятельством, что у разработчиков нижнего уровня не будет
точного представления, для чего их код в конечном счете будет
использоваться. Это отражено в приводимом примере. Чтобы пример был
короче, графическая библиотека предоставляет только весьма
ограниченный сервис, а сама прикладная программа очень проста.
Чтобы читатель смог испытать программу, даже если у него нет совсем
никаких графических средств, используется чрезвычайно простая
концепция экрана. Не должно составить труда заменить эту экранную
часть программы чем-нибудь подходящим, не изменяя код библиотеки
фигур и прикладной программы.
7.6.1 Администратор Экрана
Экран представляется как двумерный массив символов, работу с
которым осуществляют функции put_point() и put_line(), использующие
при ссылке на экран структуру point:
// файл screen.h
const XMAX=40, YMAX=24;
struct point {
int x,y;
point() {}
point(int a, int b) { x=a; y=b; }
};
overload put_point;
extern void put_point(int a, int b);
inline void put_point(point p) { put_point(p.x,p.y); }
overload put_line;
extern void put_line(int, int, int, int);
inline void put_line(point a, point b)
{ put_line(a.x,a.y,b.x,b.y); }
extern void screen_init();
extern void screen_refresh();
extern void screen_clear();
#include
Перед первым использованием функции put экран надо
инициализировать с помощью screen_init(), а изменения в структуре
данных экрана отображаются на экране только после вызова
screen_refresh(). Как увидит пользователь, это "обновление"
("refresh") осуществляется просто посредством печати новой копии
экрана под его предыдущим вариантом. Вот функции и определения
данных для экрана:
#include "screen.h"
#include
enum color { black='*', white=' ' };
char screen[XMAX][YNAX];
void screen_init()
{
for (int y=0; y=a || a<=b) y0 += dy, eps -= two_a;
}
}
Предоставляются функции для очистки экрана и его обновления:
void screen_clear() { screen_init(); } // очистка
void screen_refresh() // обновление
{
for (int y=YMAX-1; 0<=y; y--) { // сверху вниз
for (int x=0; x
7.6.2 Библиотека Фигур
struct shape {
shape() { shape_list.append(this); }
virtual point north() { return point(0,0); } // север
virtual point south() { return point(0,0); } // юг
virtual point east() { return point(0,0); } // восток
virtual point neast() { return point(0,0); } // северо-восток
virtual point seast() { return point(0,0); } // юго-восток
virtual void draw() {}; // нарисовать
virtual void move(int, int) {}; // переместить
};
Идея состоит в том, что расположение фигуры задается с помощью
move(), и фигура помещается на экран с помощью draw(). Фигуры можно
располагать относительно друг друга, используя понятие точки
соприкосновения, и эти точки перечисляются после точек на компасе
(сторон света). Каждая конкретная фигура определяет свой смысл этих
точек, и каждая определяет способ, которым она рисуется. Для
экономии места здесь на самом деле определяются только необходимые
в этом примере стороны света. Конструктор shape::shape() добавляет
фигуру в список фигур shape_list. Этот список является gslist, то
есть, одним из вариантов обобщенного односвязанного списка,
определенного в #7.3.5. Он и соответствующий итератор были сделаны так:
typedef shape* sp; declare(gslist,sp); typedef gslist(sp) shape_lst; typedef gslist_iterator(sp) sp_iterator;
shape_lst shape_list;
class line : public shape { /* линия из 'w' в 'e' north() определяется как ``выше центра и на север как до самой северной точки'' */ point w,e; public: point north() { return point((w.x+e.x)/2,e.ydraw(); screen_refresh(); }
void stack(shape* q, shape* p) // ставит p на верх q { point n = p->north(); point s = q->south(); q->move(n.x-s.x,n.y-s.y+1); }
Прикладная программа чрезвычайно проста. Определяется новая фигура my_shape (на печати она немного похожа на рожицу), а потом пишется главная программа, которая надевает на нее шляпу. Вначале описание my_shape:
#include "shape.h" class myshape : public rectangle { line* l_eye; // левый глаз line* r_eye; // правый глаз line* mouth; // рот public: myshape(point, point); void draw(); void move(int, int); };
myshape::myshape(point a, point b) : (a,b) { int ll = neast().x-swest().x+1; int hh = neast().y-swest().y+1; l_eye = new line( point(swest().x+2,swest().y+hh*3/4),2); r_eye = new line( point(swest().x+ll-4,swest().y+hh*3/4),2); mouth = new line( point(swest().x+2,swest().y+hh/4),ll-4); }
void myshape::draw() { rectangle::draw(); put_point(point( (swest().x+neast().x)/2,(swest().y+neast().y)/2)); }
void myshape::move() { rectangle::move(); l_eye->move(a,b); r_eye->move(a,b); mouth->move(a,b); }
main() { shape* p1 = new rectangle(point(0,0),point(10,10)); shape* p2 = new line(point(0,15),17); shape* p3 = new myshape(point(15,10),point(27,18)); shape_refresh(); p3->move(-10,-10); stack(p2,p3); stack(p1,p2); shape_refresh(); return 0; }
Если вы пользовались классом slist, вы могли обнаружить, что ваша
программа тратит на заметное время на размещение и освобождение
объектов класса slink. Класс slink - это превосходный пример
класса, который может значительно выиграть от того, что программист
возьмет под контроль управление свободной памятью. Для этого вида
объектов идеально подходит оптимизирующий метод, который описан в
#5.5.6. Поскольку каждый slink создается с помощью new и уничтожается с помощью delete членами класса slist, другой способ
выделения памяти не представляет никаких проблем.
***********
* *
* *
* *
* *
* *
* *
* *
* *
* *
***********
*****************
*************
* *
* ** ** *
* *
* * *
* *
* ********* *
* *
*************
7.7 Свободная Память
Если производный класс осуществляет присваивание указателю this,
то конструктор его базового класса будет вызываться только после
этого присваивания, и значение указателя this в конструкторе
базового класса будет тем, которое присвоено конструктором
производного класса. Если базовый класс присваивает указателю this,
то будет присвоено то значение, которое использует конструктор
производного класса. Например:
#include
struct base { base(); };
struct derived : base { derived(); }
base::base()
{
cout << "\tbase 1: this=" << int(this) << "\n";
if (this == 0) this = (base*)27;
cout << "\tbase 2: this=" << int(this) << "\n";
}
derived::derived()
{
cout << "\tderived 1: this=" << int(this) << "\n";
this = (this == 0) ? (derived*)43 : this;
cout << "\tderived 2: this=" << int(this) << "\n";
}
main()
{
cout << "base b;\n";
base b;
cout << "new base b;\n";
new base;
cout << "derived d;\n";
derived d;
cout << "new derived d;\n";
new derived;
cout << "at the end\n";
}
порождает вывод
base b;
base 1: this=2147478307
base 2: this=2147478307
new base;
base 1: this=0
base 2: this=27
derived d;
derived 1: this=2147478306
base 1: this=2147478306
base 2: this=2147478306
derived 1: this=2147478306
new derived;
derived 1: this=0
base 1: this=43
base 2: this=43
derived 1: this=43
at the end
Если деструктор производного класса осуществляет присваивание
указателю this, то будет присвоено то значение, которое встретил
деструктор его базового класса. Когда кто-либо делает в
конструкторе присваивание указателю this, важно, чтобы присваивание
указателю this встречалось на всех путях в конструкторе*1.
7.8 Упражнения
class base {
public:
virtual void iam() { cout << "base\n"; }
};
Выведите из base два класса и для каждого определите iam() ("я
есть"), которая выводит имя класса на печать. Создайте объекты
этих классов и вызовите для них iam(). Присвойте адреса
объектов производных классов указателям base* и вызовите iam()
через эти указатели.