В этой главе описываются возможности определения новых типов в C++, для которых доступ к данным ограничен заданным множеством функций доступа. Объясняются способы защиты структуры данных, ее инициализации, доступа к ней и, наконец, ее уничтожения. Примеры содержат простые классы для работы с таблицей имен, манипуляции стеком, работу с множеством и реализацию дискриминирующего (то есть, "надежного") объединения. Две следующие главы дополнят описание возможностей определения новых типов в C++ и познакомят читателя еще с некоторыми интересными примерами.
Предназначение понятия класса, которому посвящены эта и две
последующие главы, состоит в том, чтобы предоставить программисту
инструмент для создания новых типов, столь же удобных в обращении
сколь и встроенные типы. В идеале тип, определяемый пользователем,
способом использования не должен отличаться от встроенных типов,
только способом создания.
Тип есть конкретное представление некоторой концепции (понятия).
Например, имеющийся в C++ тип float с его операциями +, -, * и т.д.
обеспечивает ограниченную, но конкретную версию математического
понятия действительного числа. Новый тип создается для того, чтобы
дать специальное и конкретное определение понятия, которому ничто
прямо и очевидно среди встроенных типов не отвечает. Например, в
программе, которая работает с телефоном, можно было бы создать тип
trunk_module (элемент линии), а в программе обработки текстов - тип
list_of_paragraphs (список параграфов). Как правило, программу, в
которой создаются типы, хорошо отвечающие понятиям приложения,
понять легче, чем программу, в которой это не делается. Хорошо
выбранные типы, определяемые пользователем, делают программу более
четкой и короткой. Это также позволяет компилятору обнаруживать
недопустимые использования объектов, которые в противном случае
останутся необнаруженными до тестирования программы.
В определении нового типа основная идея - отделить несущественные
подробности реализации (например, формат данных, которые
используются для хранения объекта типа) от тех качеств, которые
существенны для его правильного использования (например, полный
список функций, которые имеют доступ к данным). Такое разделение
можно описать так, что работа со структурой данных и внутренними
административными подпрограммами осуществляется через специальный
интерфейс (каналируется).
Эта глава состоит из четырех практически отдельных частей:
5.2.1 Функции Члены | |
5.2.2 Классы | |
5.2.3 Ссылки на Себя | |
5.2.4 Инициализация | |
5.2.5 Очистка | |
5.2.6 Inline |
Класс - это определяемый пользователем тип. Этот раздел знакомит с основными средствами определения класса, создания объекта класса, работы с такими объектами и, наконец, уничтожения таких объектов после использования.
Рассмотрим реализацию понятия даты с использованием struct для того, чтобы определить представление даты date и множества функций для работы с переменными этого типа:
struct date { int month, day, year; }; // дата: месяц, день, год } date today; void set_date(date*, int, int, int); void next_date(date*); void print_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_burthday; // мой день рождения void f() { my_burthday.set(30,12,1950); today.set(18,1,1985); my_burthday.print(); today.next(); }
void date::next() { if ( ++day > 28 ) { // делает сложную часть работы } }
Описание date в предыдущем подразделе дает множество функций для работы с date, но не указывает, что эти функции должны быть единственными для доступа к объектам типа date. Это ограничение можно наложить используя вместо struct class:
class date { int month, day, year; public: void set(int, int, int); void get(int*, int*, int*); void next(); void print(); };
void date::ptinr() // печатает в записи, принятой в США { cout << month << "/" << day << "/" year; }
void backdate() { today.day--; // ошибка }
В функции члене на члены объекта, для которого она была вызвана, можно ссылаться непосредственно. Например:
class x { int m; public: int readm() { return m; } }; x aa; x bb; void f() { int a = aa.readm(); int b = bb.readm(); // ... }
x* this;
class x { int m; public: int readm() { return this->m; } };
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); }
Использование для обеспечения инициализации объекта класса функций вроде set_date() (установить дату) неэлегантно и чревато ошибками. Поскольку нигде не утверждается, что объект должен быть инициализирован, то программист может забыть это сделать, или (что приводит, как правило, к столь же разрушительным последствиям) сделать это дважды. Есть более хороший подход: дать возможность программисту описать функцию, явно предназначенную для инициализации объектов. Поскольку такая функция конструирует значения данного типа, она называется конструктором. Конструктор распознается по тому, что имеет то же имя, что и сам класс. Например:
class date { // ... date(int, int, int); };
date today = date(23,6,1983); date xmas(25,12,0); // сокращенная форма // (xmas - рождество) date my_burthday; // недопустимо, опущена инициализация
class date { int month, day, year; public: // ... date(int, int, int); // день месяц год date(char*); // дата в строковом представлении date(int); // день, месяц и год сегодняшние date(); // дата по умолчанию: сегодня };
date today(4); date july4("Июль 4, 1983"); date guy("5 Ноя"); date now; // инициализируется по умолчанию
class date { int month, day, year; public: // ... date(int d =0, int m =0, int y =0); date(char*); // дата в строковом представлении }; date::date(int d, int m, int y) { day = d ? d : today.day; month = m ? m : today.month; year = y ? y : today.year; // проверка, что дата допустимая // ... }
date d = today; // инициализация посредством присваивания
Определяемый пользователем тип чаще имеет, чем не имеет, конструктор, который обеспечивает надлежащую инициализацию. Для многих типов также требуется обратное действие, деструктор, чтобы обеспечить соответствующую очистку объектов этого типа. Имя деструктора для класса 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; } char pop() { return *--top;} }
void f() { char_stack s1(100); char_stack s2(200); s1.push('a'); s2.push(s1.pop()); char ch = s2.pop(); cout << chr(ch) << "\n"; }
При программировании с использованием классов очень часто
используется много маленьких функций. По сути, везде, где в
программе традиционной структуры стояло бы просто какое-нибудь
обычное использование структуры данных, дается функция. То, что
было соглашением, стало стандартом, который распознает компилятор.
Это может страшно понизить эффективность, потому что стоимость
вызова функции (хотя и вовсе не высокая по сравнению с другими
языками) все равно намного выше, чем пара ссылок по памяти,
необходимая для тела функции.
Чтобы справиться с этой проблемой, был разработан аппарат inline-
функций. Функция член, определенная (а не просто описанная) в
описании класса, считается inline. Это значит, например, что в
функциях, которые используют приведенные выше char_stack, нет
никаких вызовов функций кроме тех, которые используются для
реализации операций вывода! Другими словами, нет никаких затрат
времени выполнения, которые стоит принимать во внимание при
разработке класса. Любое, даже самое маленькое действие, можно
задать эффективно. Это утверждение снимает аргумент, который чаще
всего приводят чаще всего в пользу открытых членов данных.
Функцию член можно также описать как inline вне описания класса.
Например:
char char_stack { int size; char* top; char* s; public: char pop(); // ... }; inline char char_stack::pop() { return *--top; }
5.3.1 Альтернативные Реализации | |
5.3.2 Законченный Класс |
Что представляет собой хороший класс? Нечто, имеющее небольшое и
хорошо определенное множество действий. Нечто, что можно
рассматривать как "черный ящик", которым манипулируют только
посредством этого множества действий. Нечто, чье фактическое
представление можно любым мыслимым способом изменить, не повлияв на
способ использования множества действий. Нечто, чего можно хотеть
иметь больше одного.
Для всех видов контейнеров существуют очевидные примеры: таблицы,
множества, списки, вектора, словари и т.д. Такой класс имеет
операцию "вставить", обычно он также имеет операции для проверки
того, был ли вставлен данный элемент. В нем могут быть действия для
осуществления проверки всех элементов в определенном порядке, и
кроме всего прочего, в нем может иметься операция для удаления
элемента. Обычно контейнерные (то есть, вмещающие) классы имеют
конструкторы и деструкторы.
Скрытие данных и продуманный интерфейс может дать концепция
модуля (см. например #4.4: файлы как модули). Класс, однако,
является типом. Чтобы использовать его, необходимо создать объекты
этого класса, и таких объектов можно создавать столько, сколько
нужно. Модуль же сам является объектом. Чтобы использовать его, его
надо только инициализировать, и таких объектов ровно один.
Пока описание открытой части класса и описание функций членов остаются неизменными, реализацию класса можно модифицировать не влияя на ее пользователей. Как пример этого рассмотрим таблицу имен, которая использовалась в настольном калькуляторе в Главе 3. Это таблица имен:
struct name { char* string; char* next; double value; };
// файл table.h class table { name* tbl; public: table() { tbl = 0; } name* look(char*, int = 0); name* insert(char* s) { return look(s,1); } };
#include "table.h" table globals; table keywords; table* locals; main() { locals = new table; // ... }
#include 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; }
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; inext) { delete n->string; delete n; } delete tbl; }
#include name* table::look(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; 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[ii]; tbl[ii] = nn; return nn; }
Программирование без скрытия данных (с применением структур)
требует меньшей продуманности, чем программирование со скрытием
данных (с использованием классов). Структуру можно определить не
слишком задумываясь о том, как ее предполагается использовать. А
когда определяется класс, все внимание сосредотачивается на
обеспечении нового типа полным множеством операций; это важное
смещение акцента. Время, потраченное на разработку нового типа,
обычно многократно окупается при разработке и тестировании
программы.
Вот пример законченного типа intset, который реализует понятие
"множество целых":
class intset { int cursize, maxsize; int *x; public: intset(int m, int n); // самое большее, m int'ов в 1..n ~intset(); int member(int t); // является ли t элементом? void insert(int t); // добавить "t" в множество void iterate(int& i) { i = 0; } int ok(int& i) { return i void error(char* s) { cerr << "set: " << s << "\n"; exit(1); }
main(int argc, char* argv[]) { if (argc != 3) error("ожидается два параметра"); int count = 0; int m = atoi(argv[1]); // число элементов множества int n = atoi(argv[2]); // в диапазоне 1..n intset s(m,n); while (count maxsize) error("слищком много элементов"); int i = cursize-1; x[i] = t; while (i>0 && x[i-1]>x[i]) { int t = x[i]; // переставить x[i] и [i-1] x[i] = x[i-1]; x[i-1] = t; i--; } }
int intset::member(int t) // двоичный поиск { int l = 0; int u = cursize-1; while (l <= u) { int m = (l+u)/2; if (t < x[m]) u = m-1; else if (t > x[m]) l = m+1; else return 1; // найдено } return 0; // не найдено }
class intset { // ... void iterate(int& i) { i = 0; } int ok(int& i) { return iiterate(var); while (set->ok(var)) cout << set->next(var) << "\n"; }
Дело в том, что в его постановке и выводах произведена подмена, аналогичная подмене в школьной шуточной задачке на сообразительность, в которой спрашивается:
- Cколько яблок на березе, если на одной ветке их 5, на другой ветке - 10 и так далее
При этом внимание учеников намеренно отвлекается от того основополагающего факта, что на березе яблоки не растут, в принципе.
В эксперименте Майкельсона ставится вопрос о движении эфира относительно покоящегося в лабораторной системе интерферометра. Однако, если мы ищем эфир, как базовую материю, из которой состоит всё вещество интерферометра, лаборатории, да и Земли в целом, то, естественно, эфир тоже будет неподвижен, так как земное вещество есть всего навсего определенным образом структурированный эфир, и никак не может двигаться относительно самого себя.
Удивительно, что этот цирковой трюк овладел на 120 лет умами физиков на полном серьезе, хотя его прототипы есть в сказках-небылицах всех народов всех времен, включая барона Мюнхаузена, вытащившего себя за волосы из болота, и призванных показать детям возможные жульничества и тем защитить их во взрослой жизни. Подробнее читайте в FAQ по эфирной физике.
|
![]() |