|
|||||
Категории: АстрономияБиология География Другие языки Интернет Информатика История Культура Литература Логика Математика Медицина Механика Охрана труда Педагогика Политика Право Психология Религия Риторика Социология Спорт Строительство Технология Транспорт Физика Философия Финансы Химия Экология Экономика Электроника |
Знакомство и краткий обзорГлава 5 Классы Эти типы не "абстрактны", они столь же реальны, как int и float. - Дуг МакИлрой В этой главе описываются возможности определения новых типов в C++, для которых доступ к данным ограничен заданным множеством функций доступа. Объясняются способы защиты структуры данных, ее инициализации, доступа к ней и, наконец, ее уничтожения. Примеры содержат простые классы для работы с таблицей имен, манипуляции стеком, работу с множеством и реализацию дискриминирующего (то есть, "надежного") объединения. Две следующие главы дополнят описание возможностей определения новых типов в C++ и познакомят читателя еще с некоторыми интересными примерами. Знакомство и краткий обзор Предназначение понятия класса, состоит в том, чтобы предоставить программисту инструмент для создания новых типов, столь же удобных в обращении сколь и встроенные типы. В идеале тип, определяемый пользователем, способом использования не должен отличаться от встроенных типов, только способом создания.
Классы и Члены Класс - это определяемый пользователем тип. Этот раздел знакомит с основными средствами определения класса, создания объекта класса, работы с такими объектами и, наконец, уничтожения таких объектов после использования. Функции Члены Рассмотрим реализацию понятия даты с использованием 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*); // ...
Классы Описание 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(); };
Однако функции не члены отгорожены от использования закрытых членов класса date. Например: void backdate() { today.day--; // ошибка } В том, что доступ к структуре данных ограничен явно описаннымсписком функций, есть несколько преимуществ. Любая ошибка, котораяприводит к тому, что дата принимает недопустимое значение(например, Декабрь 36, 1985) должна быть вызвана кодом функциичлена, поэтому первая стадия отладки, локализация, выполняется ещедо того, как программа будет запущена. Это частный случай общегоутверждения, что любое изменение в поведении типа date может идолжно вызываться изменениями в его членах. Другое преимущество -это то, что потенциальному пользователю такого типа нужно будеттолько узнать определение функций членов, чтобы научиться импользоваться. Защита закрытых данных связана с ограничением использования именчленов класса. Это можно обойти с помощью манипуляции адресами, ноэто уже, конечно, жульничество.Ссылки на Себя В функции члене на члены объекта, для которого она была вызвана, можно ссылаться непосредственно. Например: class x { int m; public: int readm() { return m; } }; x aa; x bb; void f() { int a = aa.readm(); int b = bb.readm(); // ... } В первом вызове члена member() m относится к aa.m, а во втором -к bb.m. Указатель на объект, для которого вызвана функция член, являетсяскрытым параметром функции. На этот неявный параметр можноссылаться явно как на this. В каждой функции класса x указательthis неявно описан как x* this; и инициализирован так, что он указывает на объект, для которогобыла вызвана функция член. 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); } Цепочки такой общей природы являются основой для списковыхклассов, которые описываются в Главе 7. Чтобы присоединить звено к списку необходимо обновить объекты, на которые указывают указателиthis, pre и suc (текущий, предыдущий и последующий). Все они типаdlink, поэтому функция член dlink::append() имеет к ним доступ.Единицей защиты в C++ является class, а не отдельный объект класса.Инициализация Использование для обеспечения инициализации объекта класса функций вроде 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(); // дата по умолчанию: сегодня }; Конструкторы подчиняются тем же правилам относительно типовпараметров, что и перегруженные функции (#4.6.7). Если конструкторы существенно различаются по типам своих параметров, то компилятор прикаждом использовании может выбрать правильный: date today(4); date july4("Июль 4, 1983"); date guy("5 Ноя"); date now; // инициализируется по умолчанию Заметьте, что функции члены могут быть перегружены без явногоиспользования ключевого слова overload. Поскольку полный списокфункций членов находится в описании класса и как правило короткий,то нет никакой серьезной причины требовать использования словаoverload для предотвращения случайного повторного использованияимени. Размножение конструкторов в примере с date типично. Приразработке класса всегда есть соблазн обеспечить "все", посколькукажется проще обеспечить какое-нибудь средство просто на случай,что оно кому-то понадобится или потому, что оно изящно выглядит,чем решить, что же нужно на самом деле. Последнее требует большихразмышлений, но обычно приводит к программам, которые меньше поразмеру и более понятны. Один из способов сократить числородственных функций - использовать параметры по умолчанию. В случаеdate для каждого параметра можно задать значение по умолчанию,интерпретируемое как "по умолчанию принимать: today" (сегодня). 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; // проверка, что дата допустимая // ... } Когда используется значение параметра, указывающее "брать поумолчанию", выбранное значение должно лежать вне множествавозможных значений параметра. Для дня day и месяца mounth ясно, чтоэто так, но для года year выбор нуля неочевиден. К счастью, вевропейском календаре нет нулевого года . Сразу после 1 г. до н.э.(year==-1) идет 1 г. н.э. (year==1), но для реальной программы этоможет оказаться слишком тонко. Объект класса без конструкторов можно инициализировать путемприсваивания ему другого объекта этого класса. Это можно делать итогда, когда конструкторы описаны. Например: date d = today; // инициализация посредством присваивания По существу, имеется конструктор по умолчанию, определенный какпобитовая копия объекта того же класса. Если для класса X такойконструктор по умолчанию нежелателен, его можно переопределитьконструктором с именем X(X&). Это будет обсуждаться в #6.6.Очистка Определяемый пользователем тип чаще имеет, чем не имеет, конструктор, который обеспечивает надлежащую инициализацию. Для многих типов также требуется обратное действие, деструктор, чтобы обеспечить соответствующую очистку объектов этого типа. Имя деструктора для класса X есть ~X() ("дополнение конструктора"). В частности, многие типы используют некоторый объем памяти из свободной памяти (см. #3.2.6), который выделяется конструктором и освобождается деструктором. Вот, например, традиционный стековый тип, из которого для краткости полностью выброшена обработка ошибок: class char_stack { int size; char* top; char* s; public: char_stack(int sz) { top=s=new char[size=sz]; } ~char_stack() { delete s; } // деструктор void push(char c) { *top++ = c; } char 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 << chr(ch) << "\n"; }Когда вызывается f(), конструктор char_stack вызывается для s1, чтобы выделить вектор из 100 символов, и для s2, чтобы выделить вектор из 200 символов. При возврате из f() эти два вектора будут освобождены. Inline При программировании с использованием классов очень часто используется много маленьких функций. По сути, везде, где в программе традиционной структуры стояло бы просто какое-нибудь обычное использование структуры данных, дается функция. То, что было соглашением, стало стандартом, который распознает компилятор. Это может страшно понизить эффективность, потому что стоимость вызова функции (хотя и вовсе не высокая по сравнению с другими языками) все равно намного выше, чем пара ссылок по памяти, необходимая для тела функции. Интерфейсы и Реализации
Что представляет собой хороший класс? Нечто, имеющее небольшое и хорошо определенное множество действий. Нечто, что можно рассматривать как "черный ящик", которым манипулируют только посредством этого множества действий. Нечто, чье фактическое представление можно любым мыслимым способом изменить, не повлияв на способ использования множества действий. Нечто, чего можно хотеть иметь больше одного. Альтернативные Реализации Пока описание открытой части класса и описание функций членов остаются неизменными, реализацию класса можно модифицировать не влияя на ее пользователей. Как пример этого рассмотрим таблицу имен, которая использовалась в настольном калькуляторе в Главе 3. Это таблица имен: struct name { char* string; char* 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 тем, что это настоящий тип. Можно описать более чем одну table, можноиметь указатель на table и т.д. Например: #include "table.h" table globals; table keywords; table* locals; main() { locals = new table; // ... } Вот реализация table::look(), которая использует линейный поиск всвязанном списке имен name в таблице: #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; } Теперь рассмотрим класс 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;" inext) { delete n->string; delete n; } delete tbl; } Описав деструктор для класса name можно получить более простой иясный вариант table::~table(). Функция просмотра практическиидентична той, которая использовалась в примере настольногокалькулятора (#3.1.3): #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; } Очевидно, что функции члены класса должны заново компилироватьсявсегда, когда вносится какое-либо изменение в описание класса. Видеале такое изменение никак не должно отражаться на пользователяхкласса. К сожалению, это не так. Для размещения переменнойклассового типа компилятор должен знать размер объекта класса. Еслиразмер этих объектов меняется, то файлы, в которых классиспользуется, нужно компилировать заново. Можно написать такуюпрограмму (и она уже написана), которая определяет множество(минимальное) файлов, которое необходимо компилировать заново послеизменения описания класса, но пока что широкого распространения онане получила. Почему, можете вы спросить, C++ разработан так, что послеизменения закрытой части необходима новая компиляция пользователейкласса? И действительно, почему вообще закрытая часть должна бытьпредставлена в описании класса? Другими словами, раз пользователямкласса не разрешается обращаться к закрытым членам, почему ихописания должны приводиться в заголовочных файлах, которые, какпредполагается, пользователь читает? Ответ - эффективность. Вомногих системах и процесс компиляции, и последовательностьопераций, реализующих вызов функции, проще, когда размеравтоматических объектов (объектов в стеке) известен во времякомпиляции. Этой сложности можно избежать, представив каждый объект классакак указатель на "настоящий" объект. Так как все эти указателибудут иметь одинаковый размер, а размещение "настоящих" объектовможно определить в файле, где доступна закрытая часть, то это можетрешить проблему. Однако решение подразумевает дополнительные ссылкипо памяти при обращении к членам класса, а также, что еще хуже,каждый вызов функции с автоматическим объектом класса включает поменьшей мере один вызов программ выделения и освобождения свободнойпамяти. Это сделало бы также невозможным реализацию inline-функцийчленов, которые обращаются к данным закрытой части. Более того,такое изменение сделает невозможным совместную компоновку C и C++программ (поскольку C компилятор обрабатывает struct не так, какэто будет делать C++ компилятор). Для C++ это было сочтенонеприемлемым.Законченный Класс Программирование без скрытия данных (с применением структур) требует меньшей продуманности, чем программирование со скрытием данных (с использованием классов). Структуру можно определить не слишком задумываясь о том, как ее предполагается использовать. А когда определяется класс, все внимание сосредотачивается на обеспечении нового типа полным множеством операций; это важное смещение акцента. Время, потраченное на разработку нового типа, обычно многократно окупается при разработке и тестировании программы. Класс intset используется в main(), которая предполагает два целых параметра. Первый параметр задает число случайных чисел, которые нужно сгенерировать. Второй параметр указывает диапазон, в котором должны лежать случайные целые: 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; // не найдено } И, наконец, нам нужно обеспечить множество операций, чтобыпользователь мог осуществлять цикл по множеству в некоторомпорядке, поскольку представление intset от пользователя скрыто.Множество внутренней упорядоченности не имеет, поэтому мы не можемпросто дать возможность обращаться к вектору (завтра я, наверное,реализую intset по-другому, в виде связанного списка). Дается три функции: iterate() для инициализации итерации, ok()для проверки, есть ли следующий элемент, и next() для того, чтобывзять следующий элемент: class intset { // ... void iterate(int& i) { i = 0; } int ok(int& i) { return iiterate(var); while (set->ok(var)) cout << set->next(var) << "\n"; } |