Присваивание и инициализация




Рассмотрим простой строковый класс string: struct string { char* p; int size; // размер вектора, на который указывает p string(int size) { p = new char[size=sz]; } ~string() { delete p; } }; Строка - это структура данных, содержащая указатель на вектор символов и размер этого вектора. Вектор создается конструктором и удаляется деструктором. Но как мы видели в $$5.5.1 здесь могут возникнуть проблемы: void f() { string s1(10); string s2(20) s1 = s2; } Здесь будут размещены два символьных вектора, но в результате присваивания s1 = s2 указатель на один из них будет уничтожен, и заменится копией второго. По выходе из f() будет вызван для s1 и s2 деструктор, который дважды удалит один и тот же вектор, результаты чего по всей видимости будут плачевны. Для решения этой проблемы нужно определить соответствующее присваивание объектов типа string: struct string { char* p; int size; // размер вектора, на который указывает p string(int size) { p = new char[size=sz]; } ~string() { delete p; } string& operator=(const string&); }; string& string::operator=(const string& a) { if (this!=&a) { // опасно, когда s=s delete p; p = new char[size=a.size]; strcpy(p,a.p); } return *this; } При таком определении string предыдущий пример пройдет как задумано. Но после небольшого изменения в f() проблема возникает снова, но в ином обличии: void f() { string s1(10); string s2 = s1; // инициализация, а не присваивание } Теперь только один объект типа string строится конструктором string::string(int), а уничтожаться будет две строки. Дело в том, что пользовательская операция присваивания не применяется к неинициализированному объекту. Достаточно взглянуть на функцию string::operator(), чтобы понять причину этого: указатель p будет тогда иметь неопределенное, по сути случайное значение. Как правило, в операции присваивания предполагается, что ее параметры проинициализированы. Для инициализации типа той, что приведена в этом примере это не так по определению. Следовательно, чтобы справиться с инициализацией нужна похожая, но своя функция: struct string { char* p; int size; // размер вектора, на который указывает p string(int size) { p = new char[size=sz]; } ~string() { delete p; } string& operator=(const string&); string(const string&); }; string::string(const string& a) { p=new char[size=sz]; strcpy(p,a.p); } Инициализация объекта типа X происходит с помощью конструктора X(const X&). Мы не перестаем повторять, что присваивание и инициализация являются разными операциями. Особенно это важно в тех случаях, когда определен деструктор. Если в классе X есть нетривиальный деструктор, например, производящий освобождение объекта в свободной памяти, вероятнее всего, в этом классе потребуется полный набор функций, чтобы избежать копирования объектов по членам: class X { //... X(something); // конструктор, создающий объект X(const X&); // конструктор копирования operator=(const X&); // присваивание: // удаление и копирование ~X(); // деструктор, удаляющий объект }; Есть еще два случая, когда приходится копировать объект: передача параметра функции и возврат ею значения. При передаче параметра неинициализированная переменная, т.е. формальный параметр инициализируется. Семантика этой операции идентична другим видам инициализации. Тоже происходит и при возврате функцией значения, хотя этот случай не такой очевидный. В обоих случаях используется конструктор копирования: string g(string arg) { return arg; } main() { string s = "asdf"; s = g(s); } Очевидно, после вызова g() значение s должно быть "asdf". Не трудно записать в параметр s копию значения s, для этого надо вызвать конструктор копирования для string. Для получения еще одной копии значения s по выходе из g() нужен еще один вызов конструктора string(const string&). На этот раз инициализируется временная переменная, которая затем присваивается s. Для оптимизации одну, но не обе, из подобных операций копирования можно убрать. Естественно, временные переменные, используемые для таких целей, уничтожаются надлежащим образом деструктором string::~string() (см. $$R.12.2). Если в классе X операция присваивания X::operator=(const X&) и конструктор копирования X::X(const X&) явно не заданы программистом, недостающие операции будут созданы транслятором. Эти созданные функции будут копировать по членам для всех членов класса X. Если члены принимают простые значения, как в случае комплексных чисел, это, то, что нужно, и созданные функции превратятся в простое и оптимальное поразрядное копирование. Если для самих членов определены пользовательские операции копирования, они и будут вызываться соответствующим образом: class Record { string name, address, profession; //... }; void f(Record& r1) { Record r2 = r1; } Здесь для копирования каждого члена типа string из объекта r1 будет вызываться string::operator=(const string&). В нашем первом и неполноценном варианте строковый класс имеет член-указатель и деструктор. Поэтому стандартное копирование по членам для него почти наверняка неверно. Транслятор может предупреждать о таких ситуациях.

Индексация

Операторная функция operator[] задает для объектов классов интерпретацию индексации. Второй параметр этой функций (индекс) может иметь произвольный тип. Это позволяет, например, определять ассоциативные массивы. В качестве примера можно переписать определение из $$2.3.10, где ассоциативный массив использовался в небольшой программе, подсчитывающей число вхождений слов в файле. Там для этого использовалась функция. Мы определим настоящий тип ассоциативного массива: class assoc { struct pair { char* name; int val; }; pair* vec; int max; int free; assoc(const assoc&); // предотвращает копирование assoc& operator=(const assoc&); // предотвращает копирование public: assoc(int); int& operator[](const char*); void print_all(); }; В объекте assoc хранится вектор из структур pair размером max. В переменной free хранится индекс первого свободного элемента вектора. Чтобы предотвратить копирование объектов assoc, конструктор копирования и операция присваивания описаны как частные. Конструктор выглядит так: assoc::assoc(int s) { max = (s<16)? 16: s; free = 0; vec = new pair[max]; } В реализации используется все тот же неэффективный алгоритм поиска, что и в $$2.3.10. Но теперь, если вектор переполняется, объект assoc увеличивается: #include <string.h> int& assoc::operator[](const char* p) /* работает с множеством пар (структур pair): проводит поиск p, возвращает ссылку на целое значение из найденной пары, создает новую пару, если p не найдено */ { register pair* pp; for (pp=&vec[free-1]; vec<=pp; pp--) if (strcmp(p,pp->name) == 0) return pp->val; if (free == max) { //переполнение: вектор увеличивается pair* nvec = new pair[max*2]; for (int i=0; i<max; i++) nvec[i] = vec[i]; delete vec; vec = nvec; max = 2*max; } pp = &vec[free++]; pp->name = new char[strlen(p)+1]; strcpy(pp->name,p); pp->val = 0; // начальное значение = 0 return pp->val; } Поскольку представление объекта assoc скрыто от пользователя, нужно иметь возможность напечатать его каким-то образом. В следующем разделе будет показано как определить настоящий итератор для такого объекта. Здесь же мы ограничимся простой функцией печати: void assoc::print_all() { for (int i = 0; i<free; i++) cout << vec[i].name << ": " << vec[i].val << '\n'; } Наконец, можно написать тривиальную программу: main() // подсчет числа вхождений во входной // поток каждого слова { const MAX = 256; // больше длины самого длинного слова char buf[MAX]; assoc vec(512); while (cin>>buf) vec[buf]++; vec.print_all(); } Опытные программисты могут заметить, что второй комментарий можно легко опровергнуть. Решить возникающую здесь проблему предлагается в упражнении $$7.14 [20]. Дальнейшее развитие понятие ассоциативного массива получит в $$8.8. Функция operator[]() должна быть членом класса. Отсюда следует, что эквивалентность x[y] == y[x] может не выполняться, если x объект класса. Обычные отношения эквивалентности, справедливые для операций со встроенными типами, могут не выполняться для пользовательских типов ($$7.2.2, см. также $$7.9).

Вызов функции

Вызов функции, т.е. конструкцию выражение(список-выражений), можно рассматривать как бинарную операцию, в которой выражение является левым операндом, а список-выражений - правым. Операцию вызова можно перегружать как и другие операции. В функции operator()() список фактических параметров вычисляется и проверяется по типам согласно обычным правилам передачи параметров. Перегрузка операции вызова имеет смысл прежде всего для типов, с которыми возможна только одна операция, а также для тех типов, одна из операций над которыми имеет настолько важное значение, что все остальные в большинстве случаев можно не учитывать. Мы не дали определения итератора для ассоциативного массива типа assoc. Для этой цели можно определить специальный класс assoc_iterator, задача которого выдавать элементы из assoc в некотором порядке. В итераторе необходимо иметь доступ к данным, хранимым в assoc, поэтому он должен быть описан как friend: class assoc { friend class assoc_iterator; pair* vec; int max; int free; public: assoc(int); int& operator[](const char*); }; Итератор можно определить так: class assoc_iterator { const assoc* cs; // массив assoc int i; // текущий индекс public: assoc_iterator(const assoc& s) { cs = &s; i = 0; } pair* operator()() { return (i<cs->free)? &cs->vec[i++]: 0; } }; Массив assoc объекта assoc_iterator нужно инициализировать, и при каждомобращении к нему с помощью операторной функции () будет возвращатьсяуказатель на новую пару (структура pair) из этого массива. При достиженииконца массива возвращается 0: main() // подсчет числа вхождений во входной // поток каждого слова { const MAX = 256; // больше длины самого длинного слова char buf[MAX]; assoc vec(512); while (cin>>buf) vec[buf]++; assoc_iterator next(vec); pair* p; while (p = next(vec)) cout << p->name << ": " << p->val << '\n'; } Итератор подобного вида имеет преимущество перед наборомфункций, решающим ту же задачу: итератор может иметь собственныечастные данные, в которых можно хранить информацию о ходе итерации.Обычно важно и то, что можно одновременно запустить сразу несколькоитераторов одного типа. Конечно, использование объектов для представления итераторовнепосредственно никак не связано с перегрузкой операций. Однипредпочитают использовать тип итератора с такими операциями, какfirst(), next() и last(), другим больше нравится перегрузка операции++, которая позволяет получить итератор, используемый как указатель(см. $$8.8). Кроме того, операторная функция operator() активноиспользуется для выделения подстрок и индексации многомерных массивов. Функция operator() должна быть функцией-членом.

Косвенное обращение

Операцию косвенного обращения к члену -> можно определить как унарнуюпостфиксную операцию. Это значит, если есть класс class Ptr { //... X* operator->(); }; объекты класса Ptr могут использоваться для доступа к членам классаX также, как для этой цели используются указатели: void f(Ptr p) { p->m = 7; // (p.operator->())->m = 7 } Превращение объекта p в указатель p.operator->() никак не зависит отчлена m, на который он указывает. Именно по этой причине operator->()является унарной постфиксной операцией. Однако, мы не вводим новыхсинтаксических обозначений, так что имя члена по-прежнему должноидти после ->: void g(Ptr p) { X* q1 = p->; // синтаксическая ошибка X* q2 = p.operator->(); // нормально } Перегрузка операции -> прежде всего используется для создания"хитрых указателей", т.е. объектов, которые помимо использования какуказатели позволяют проводить некоторые операции при каждом обращениик указуемому объекту с их помощью. Например, можно определить классRecPtr для организации доступа к объектам класса Rec, хранимым надиске. Параметром конструктора RecPtr является имя, которое будетиспользоваться для поиска объекта на диске. При обращении к объектус помощью функции RecPtr::operator->() он переписывается в основнуюпамять, а в конце работы деструктор RecPtr записывает измененныйобъект обратно на диск. class RecPtr { Rec* in_core_address; const char* identifier; //... public: RecPtr(const char* p): identifier(p) { in_core_address = 0; } ~RecPtr() { write_to_disc(in_core_address,identifier); } Rec* operator->(); }; Rec* RecPtr::operator->() { if (in_core_address == 0) in_core_address = read_from_disc(identifier); return in_core_address; } Использовать это можно так: main(int argc, const char* argv) { for (int i = argc; i; i--) { RecPtr p(argv[i]); p->update(); } } На самом деле, тип RecPtr должен определяться как шаблон типа(см. $$8), а тип структуры Record будет его параметром. Крометого, настоящая программа будет содержать обработку ошибок ивзаимодействие с диском будет организовано не столь примитивно. Для обычных указателей операция -> эквивалентна операциям,использующим * и []. Так, если описано Y* p; то выполняется соотношение p->m == (*p).m == p[0].m Как всегда, для определенных пользователем операций такие соотношенияне гарантируются. Там, где все-таки такая эквивалентность требуется,ее можно обеспечить: class X { Y* p; public: Y* operator->() { return p; } Y& operator*() { return *p; } Y& operator[](int i) { return p[i]; } }; Если в вашем классе определено более одной подобной операции,разумно будет обеспечить эквивалентность, точно так же, как разумнопредусмотреть для простой переменной x некоторого класса, в котороместь операции ++, += = и +, чтобы операции ++x и x+=1 былиэквивалентны x=x+1. Перегрузка -> как и перегрузка [] может играть важную роль дляцелого класса настоящих программ, а не является просто экспериментомради любопытства. Дело в том, что в программировании понятиекосвенности является ключевым, а перегрузка -> дает ясный, прямойи эффективный способ представления этого понятия в программе.Есть другая точка зрения на операцию ->, как на средство задатьв С++ ограниченный, но полезный вариант понятия делегирования(см. $$12.2.8 и 13.9).

Инкремент и декремент

Если мы додумались до "хитрых указателей", то логично попробоватьпереопределить операции инкремента ++ и декремента --, чтобыполучить для классов те возможности, которые эти операции дают длявстроенных типов. Такая задача особенно естественна и необходима, еслиставится цель заменить тип обычных указателей на тип "хитрых указателей",для которого семантика остается прежней, но появляются некоторыедействия динамического контроля. Пусть есть программа с распространеннойошибкой: void f1(T a) // традиционное использование { T v[200]; T* p = &v[10]; p--; *p = a; // Приехали: `p' настроен вне массива, // и это не обнаружено ++p; *p = a; // нормально } Естественно желание заменить указатель p на объект классаCheckedPtrToT, по которому косвенное обращение возможно толькопри условии, что он действительно указывает на объект. Применятьинкремент и декремент к такому указателю будет можно только в томслучае, что указатель настроен на объект в границах массива ив результате этих операций получится объект в границах того жемассива: class CheckedPtrToT { //... }; void f2(T a) // вариант с контролем { T v[200]; CheckedPtrToT p(&v[0],v,200); p--; *p = a; // динамическая ошибка: // `p' вышел за границы массива ++p; *p = a; // нормально } Инкремент и декремент являются единственными операциями в С++,которые можно использовать как постфиксные и префиксные операции.Следовательно, в определении класса CheckedPtrToT мы должныпредусмотреть отдельные функции для префиксных и постфиксных операцийинкремента и декремента: class CheckedPtrToT { T* p; T* array; int size; public: // начальное значение `p' // связываем с массивом `a' размера `s' CheckedPtrToT(T* p, T* a, int s); // начальное значение `p' // связываем с одиночным объектом CheckedPtrToT(T* p); T* operator++(); // префиксная T* operator++(int); // постфиксная T* operator--(); // префиксная T* operator--(int); // постфиксная T& operator*(); // префиксная }; Параметр типа int служит указанием, что функция будет вызыватьсядля постфиксной операции. На самом деле этот параметр являетсяискусственным и никогда не используется, а служит только для различияпостфиксной и префиксной операции. Чтобы запомнить, какая версияфункции operator++ используется как префиксная операция, достаточнопомнить, что префиксной является версия без искусственного параметра,что верно и для всех других унарных арифметических и логическихопераций. Искусственный параметр используется только для "особых"постфиксных операций ++ и --. С помощью класса CheckedPtrToT пример можно записать так: void f3(T a) // вариант с контролем { T v[200]; CheckedPtrToT p(&v[0],v,200); p.operator--(1); p.operator*() = a; // динамическая ошибка: // `p' вышел за границы массива p.operator++(); p.operator*() = a; // нормально } В упражнении $$7.14 [19] предлагается завершить определение классаCheckedPtrToT, а другим упражнением ($$9.10[2]) являетсяпреобразование его в шаблон типа, в котором для сообщений одинамических ошибках используются особые ситуации. Примеры использованияопераций ++ и -- для итераций можно найти в $$8.8.

Строковый класс

Теперь можно привести более осмысленный вариант класса string. В нем подсчитывается число ссылок на строку, чтобы минимизировать копирование, и используются как константы стандартные строки C++. #include <iostream.h> #include <string.h> class string { struct srep { char* s; // указатель на строку int n; // счетчик числа ссылок srep() { n = 1; } }; srep *p; public: string(const char *); // string x = "abc" string(); // string x; string(const string &); // string x = string... string& operator=(const char *); string& operator=(const string &); ~string(); char& operator[](int i); friend ostream& operator<<(ostream&, const string&); friend istream& operator>>(istream&, string&); friend int operator==(const string &x, const char *s) { return strcmp(x.p->s,s) == 0; } friend int operator==(const string &x, const string &y) { return strcmp(x.p->s,y.p->s) == 0; } friend int operator!=(const string &x, const char *s) { return strcmp(x.p->s,s)!= 0; } friend int operator!=(const string &x, const string &y) { return strcmp(x.p->s,y.p->s)!= 0; } }; Конструкторы и деструкторы тривиальны: string::string() { p = new srep; p->s = 0; } string::string(const string& x) { x.p->n++; p = x.p; } string::string(const char* s) { p = new srep; p->s = new char[ strlen(s)+1 ]; strcpy(p->s, s); } string::~string() { if (--p->n == 0) { delete[] p->s; delete p; } } Как и всегда операции присваивания похожи на конструкторы. В них нужно позаботиться об удалении первого операнда, задающего левую часть присваивания: string& string::operator=(const char* s) { if (p->n > 1) { // отсоединяемся от старой строки p->n--; p = new srep; } else // освобождаем строку со старым значением delete[] p->s; p->s = new char[ strlen(s)+1 ]; strcpy(p->s, s); return *this; } string& string::operator=(const string& x) { x.p->n++; // защита от случая ``st = st'' if (--p->n == 0) { delete[] p->s; delete p } p = x.p; return *this; } Операция вывода показывает как используется счетчик числа ссылок. Она сопровождает как эхо каждую введенную строку (ввод происходит с помощью операции <<, приведенной ниже): ostream& operator<<(ostream& s, const string& x) { return s << x.p->s << " [" << x.p->n << "]\n"; } Операция ввода происходит с помощью стандартной функции ввода символьной строки ($$10.3.1): istream& operator>>(istream& s, string& x) { char buf[256]; s >> buf; // ненадежно: возможно переполнение buf // правильное решение см. в $$10.3.1 x = buf; cout << "echo: " << x << '\n'; return s; } Операция индексации нужна для доступа к отдельным символам. Индекс контролируется: void error(const char* p) { cerr << p << '\n'; exit(1); } char& string::operator[](int i) { if (i<0 || strlen(p->s)<i) error("недопустимое значение индекса"); return p->s[i]; } В основной программе просто даны несколько примеров применения строковых операций. Слова из входного потока читаются в строки, а затем строки печатаются. Это продолжается до тех пор, пока не будет обнаружена строка done, или закончатся строки для записи слов, или закончится входной поток. Затем печатаются все строки в обратном порядке и программа завершается. int main() { string x[100]; int n; cout << " здесь начало \n"; for (n = 0; cin>>x[n]; n++) { if (n==100) { error("слишком много слов"); return 99; } string y; cout << (y = x[n]); if (y == "done") break; } cout << "теперь мы идем по словам в обратном порядке \n"; for (int i=n-1; 0<=i; i--) cout << x[i]; return 0; }

Друзья и члены

В заключении можно обсудить, когда при обращении в закрытую часть пользовательского типа стоит использовать функции-члены, а когда функции-друзья. Некоторые функции, например конструкторы, деструкторы и виртуальные функции ($$R.12), обязаны быть членами, но для других есть возможность выбора. Поскольку, описывая функцию как член, мы не вводим нового глобального имени, при отсутствии других доводов следует использовать функции-члены. Рассмотрим простой класс X: class X { //... X(int); int m1(); int m2() const; friend int f1(X&); friend int f2(const X&); friend int f3(X); }; Вначале укажем, что члены X::m1() и X::m2() можно вызывать только для объектов класса X. Преобразование X(int) не будет применяться к объекту, для которого вызваны X::m1() или X::m2(): void g() { 1.m1(); // ошибка: X(1).m1() не используется 1.m2(); // ошибка: X(1).m2() не используется } Глобальная функция f1() имеет то же свойство ($$4.6.3), поскольку ее параметр - ссылка без спецификации const. С функциями f2() и f3() ситуация иная: void h() { f1(1); // ошибка: f1(X(1)) не используется f2(1); // нормально: f2(X(1)); f3(1); // нормально: f3(X(1)); } Следовательно операция, изменяющая состояние объекта класса, должна быть членом или глобальной функцией с параметром-ссылкой без спецификации const. Операции над основными типами, которые требуют в качестве операндов адреса (=, *, ++ и т.д.), для пользовательских типов естественно определять как члены. Обратно, если требуется неявное преобразование типа для всех операндов некоторой операции, то реализующая ее функция должна быть не членом, а глобальной функцией и иметь параметр типа ссылки со спецификацией const или нессылочный параметр. Так обычно обстоит дело с функциями, реализующими операции, которые для основных типов не требуют адресов в качестве операндов (+, -, || и т.д.). Если операции преобразования типа не определены, то нет неопровержимых доводов в пользу функции-члена перед функцией-другом с параметром-ссылкой и наоборот. Бывает, что программисту просто одна форма записи вызова нравится больше, чем другая. Например, многим для обозначения функции обращения матрицы m больше нравится запись inv(m), чем m.inv(). Конечно, если функция inv() обращает саму матрицу m, а не возвращает новую, обратную m, матрицу, то inv() должна быть членом. При всех прочих равных условиях лучше все-таки остановиться на функции-члене. Можно привести такие доводы. Нельзя гарантировать, что когда-нибудь не будет определена операция обращения. Нельзя во всех случаях гарантировать, что будущие изменения не повлекут за собой изменения в состоянии объекта. Запись вызова функции-члена ясно показывает программисту, что объект может быть изменен, тогда как запись с параметром-ссылкой далеко не столь очевидна. Далее, выражения допустимые в функции-члене могут быть существенно короче эквивалентных выражений в глобальной функции. Глобальная функция должна использовать явно заданные параметры, а в функции-члене можно неявно использовать указатель this. Наконец, поскольку имена членов не являются глобальными именами, они обычно оказываются короче, чем имен глобальных функций.

Предостережения

Как и всякое другое языковое средство, перегрузка операций может использоваться разумно и неразумно. В частности, возможностью придавать новый смысл обычным операциям можно воспользоваться так, что программа будет совершенно непостижимой. Представьте, каково будет читателю, если в своей программе вы переопределили операцию + так, чтобы она обозначала вычитание. Описанный здесь механизм перегрузки будет защищать программиста и пользователя от таких безрассудств. Поэтому программист не может изменить ни смысл операций над основными типами данных, такими, как int, ни синтаксис выражений и приоритеты операций для них. По всей видимости перегрузку операций имеет смысл использовать для подражания традиционному использованию операций. Запись с обычным вызовом функции можно использовать в тех случаях, когда традиционной записи с базовой операцией не существует, или, когда набор операций, которые допускают перегрузку, не достаточен, чтобы записать с его помощью нужные действия.

Упражнения

1. (*2) Определите итератор для класса string. Определите операцию конкатенации + и операцию +=, значащую "добавить в конец строки". Какие еще операции вы хотели бы и смогли определить для этого класса? 2. (*1.5) Определите для строкового класса операцию выделения подстроки с помощью перегрузки (). 3. (*3) Определите класс string таким образом, чтобы операцию выделения подстроки можно было применять к левой части присваивания. Вначале напишите вариант, в котором строку можно присваивать подстроке той же длины, а затем вариант с различными длинами строк. 4. (*2) Разработайте класс string таким образом, чтобы объекты его трактовались при передаче параметров и присваивании как значения, т.е. чтобы в классе string копировались сами представления строк, а не только управляющие структуры. 5. (*3) Измените класс string из предыдущего упражнения так, чтобы строки копировались только при необходимости. Это значит, что нужно хранить одно общее представления двух одинаковых строк до тех пор, пока одна из них не изменится. Не пытайтесь задать операцию выделения подстроки, которую одновременно можно применять и к левой части присваивания. 6. (*4) Определите класс string, обладающий перечисленными в предыдущих упражнениях свойствами: объекты его трактуются как значения, копирование является отложенным (т.е. происходит только при необходимости) и операцию выделения подстроки можно применять к левой части присваивания. 7. (*2) Какие преобразования типа используются в выражениях следующей программы? struct X { int i; X(int); operator+(int); }; struct Y { int i; Y(X); operator+(X); operator int(); }; extern X operator*(X,Y); extern int f(X); X x = 1; Y y = x; int i = 2; int main() { i + 10; y + 10; y + 10 * y; x + y + i; x * X +i; f(7); f(y); y + y; 106 + y; } Определите X и Y как целые типы. Измените программу так, чтобы ее можно было выполнить и она напечатала значения всех правильных выражений. 8. (*2) Определите класс INT, который будет эквивалентен типу int. Подсказка: определите функцию INT::operator int(). 9. (*1) Определите класс RINT, который будет эквивалентен типу int, за исключением того, что допустимыми будут только операции: + (унарный и бинарный), - (унарный и бинарный), *, / и %. Подсказка: не надо определять RINT::operator int(). 10. (*3) Определите класс LINT, эквивалентный классу RINT, но в нем для представления целого должно использоваться не менее 64 разрядов. 11. (*4) Определите класс, реализующий арифметику с произвольной точностью. Подсказка: Придется использовать память так, как это делается в классе string. 12. (*2) Напишите программу, в которой благодаря макрокомандам и перегрузке будет невозможно разобраться. Совет: определите для типа INT + как -, и наоборот; с помощью макроопределения задайте int как INT. Кроме того, большую путаницу можно создать, переопределяя широко известные функции, и используя параметры типа ссылки и задавая вводящие в заблуждение комментарии. 13. (*3) Обменяйтесь решениями упражнения [12] с вашим другом. Попробуйте понять, что делает его программа, не запуская ее. Если вы сделаете это упражнение, вам станет ясно, чего надо избегать. 14. (*2) Перепишите примеры с классами complex ($$7.3), tiny ($$7.3.2) и string ($$7.11), не используя дружественные функции. Используйте только функции-члены. Проверьте новые версии этих классов. Сравните их с версиями, в которых используются дружественные функции. Обратитесь к упражнению 5.3. 15. (*2) Определите тип vec4 как вектор из четырех чисел с плавающей точкой. Определите для него функцию operator[]. Для комбинаций векторов и чисел с плавающей точкой определите операции: +, -, *, /, =, +=, -=, *= и /=. 16. (*3) Определите класс mat4 как вектор из четырех элементов типа vec4. Определите для него функцию operator[], возвращающую vec4. Определите для этого типа обычные операции с матрицами. Определите в mat4 функцию, производящую преобразование Гаусса с матрицей. 17. (*2) Определите класс vector, аналогичный классу vec4, но здесь размер вектора должен задаваться как параметр конструктора vector::vector(int). 18. (*3) Определите класс matrix, аналогичный классу mat4, но здесь размерности матрицы должны задаваться как параметры конструктора matrix::matrix(int,int). 19. (*3) Завершите определение класса CheckedPtrToT из $$7.10 и проверьте его. Чтобы определение этого класса было полным, необходимо определить, по крайней мере, такие операции: *, ->, =, ++ и --. Не выдавайте динамическую ошибку, пока действительно не произойдет обращение по указателю с неопределенным значением. 20. (*1.5) Перепишите пример с программой подсчета слов из $$7.7 так, чтобы в ней не было заранее заданной максимальной длины слова.

* ГЛАВА 8. ШАБЛОНЫТИПА

Вот ваша цитата - Бьерн Страуструп В этой главе вводится понятие шаблона типа. С его помощью можно достаточно просто определить и реализовать без потерь в эффективности выполнения программы и, не отказываясь от статического контроля типов, такие контейнерные классы, как списки и ассоциативные массивы. Кроме того, шаблоны типа позволяют определить сразу для целого семейства типов обобщенные (генерические) функции, например, такие, как sort (сортировка). В качестве примера шаблона типов и его связи с другими конструкциями языка приводится семейство списочных классов. Чтобы показать способы получения программы из в значительной степени независимых частей, приводится несколько вариантов шаблонной функции sort(). В конце определяется простой шаблон типа для ассоциативного массива и показывается на двух небольших демонстрационных программах, как им пользоваться.

Введение

Одним из самых полезных видов классов является контейнерный класс, т.е. такой класс, который хранит объекты каких-то других типов. Списки, массивы, ассоциативные массивы и множества - все это контейнерные классы. С помощью описанных в главах 5 и 7 средств можно определить класс, как контейнер объектов единственного, известного типа. Например, в $$5.3.2 определяется множество целых. Но контейнерные классы обладают тем интересным свойством, что тип содержащихся в них объектов не имеет особого значения для создателя контейнера, но для пользователя конкретного контейнера этот тип является существенным. Следовательно, тип содержащихся объектов должен параметром контейнерного класса, и создатель такого класса будет определять его с помощью типа-параметра. Для каждого конкретного контейнера (т.е. объекта контейнерного класса) пользователь будет указывать каким должен быть тип содержащихся в нем объектов. Примером такого контейнерного класса был шаблон типа Vector из $$1.4.3. В этой главе исследуется простой шаблон типа stack (стек) и в результате вводится понятие шаблонного класса. Затем рассматриваются более полные и правдоподобные примеры нескольких родственных шаблонов типа для списка. Вводятся шаблонные функции и формулируются правила, что может быть параметром таких функций. В конце приводится шаблон типа для ассоциативного массива.

Простой шаблон типа

Шаблон типа для класса задает способ построения отдельных классов, подобно тому, как описание класса задает способ построения его отдельных объектов. Можно определить стек, содержащий элементы произвольного типа: template<class T> class stack { T* v; T* p; int sz; public: stack(int s) { v = p = new T[sz=s]; } ~stack() { delete[] v; } void push(T a) { *p++ = a; } T pop() { return *--p; } int size() const { return p-v; } }; Для простоты не учитывался контроль динамических ошибок. Не считая этого, пример полный и вполне правдоподобный. Префикс template<class T> указывает, что описывается шаблон типа с параметром T, обозначающим тип, и что это обозначение будет использоваться в последующем описании. После того, как идентификатор T указан в префиксе, его можно использовать как любое другое имя типа. Область видимости T продолжается до конца описания, начавшегося префиксом template<class T>. Отметим, что в префиксе T объявляется типом, и оно не обязано быть именем класса. Так, ниже в описании объекта sc тип T оказывается просто char. Имя шаблонного класса, за которым следует тип, заключенный в угловые скобки <>, является именем класса (определяемым шаблоном типа), и его можно использовать как все имена класса. Например, ниже определяется объект sc класса stack<char>: stack<char> sc(100); // стек символов Если не считать особую форму записи имени, класс stack<char> полностью эквивалентен классу определенному так: class stack_char { char* v; char* p; int sz; public: stack_char(int s) { v = p = new char[sz=s]; } ~stack_char() { delete[] v; } void push(char a) { *p++ = a; } char pop() { return *--p; } int size() const { return p-v; } }; Можно подумать, что шаблон типа - это хитрое макроопределение, подчиняющееся правилам именования, типов и областей видимости, принятым в С++. Это, конечно, упрощение, но это такое упрощение, которое помогает избежать больших недоразумений. В частности, применение шаблона типа не предполагает каких-либо средств динамической поддержки помимо тех, которые используются для обычных "ручных" классов. Не следует так же думать, что оно приводит к сокращению программы. Обычно имеет смысл вначале отладить конкретный класс, такой, например, как stack_char, прежде, чем строить на его основе шаблон типа stack<T>. С другой стороны, для понимания шаблона типа полезно представить себе его действие на конкретном типе, например int или shape*, прежде, чем пытаться представить его во всей общности. Имея определение шаблонного класса stack, можно следующим образом определять и использовать различные стеки: stack<shape*> ssp(200); // стек указателей на фигуры stack<Point> sp(400); // стек структур Point void f(stack<complex>& sc) // параметр типа `ссылка на // complex' { sc.push(complex(1,2)); complex z = 2.5*sc.pop(); stack<int>*p = 0; // указатель на стек целых p = new stack<int>(800); // стек целых размещается // в свободной памяти for (int i = 0; i<400; i++) { p->push(i); sp.push(Point(i,i+400)); } //... } Поскольку все функции-члены класса stack являются подстановками, и в этом примере транслятор создает вызовы функций только для размещения в свободной памяти и освобождения. Функции в шаблоне типа могут и не быть подстановками, шаблонный класс stack с полным правом можно определить и так: template<class T> class stack { T* v; T* p; int sz; public: stack(int); ~stack(); void push(T); T pop(); int size() const; }; В этом случае определение функции-члена stack должно быть дано где-то в другом месте, как это и было для функций- членов обычных, нешаблонных классов. Подобные функции так же параметризируются типом, служащим параметром для их шаблонного класса, поэтому определяются они с помощью шаблона типа для функции. Если это происходит вне шаблонного класса, это надо делать явно: template<class T> void stack<T>::push(T a) { *p++ = a; } template<class T> stack<T>::stack(int s) { v = p = new T[sz=s]; } Отметим, что в пределах области видимости имени stack<T> уточнение <T> является избыточным, и stack<T>::stack - имя конструктора. Задача системы программирования, а вовсе не программиста, предоставлять версии шаблонных функций для каждого фактического параметра шаблона типа. Поэтому для приводившегося выше примера система программирования должна создать определения конструкторов для классов stack<shape*>, stack<Point> и stack<int>, деструкторов для stack<shape*> и stack<Point>, версии функций push() для stack<complex>, stack<int> и stack<Point> и версию функции pop() для stack<complex>. Такие создаваемые функции будут совершенно обычными функциями-членами, например: void stack<complex>::push(complex a) { *p++ = a; } Здесь отличие от обычной функции-члена только в форме имени класса. Точно так же, как в программе может быть только одно определение функции-члена класса, возможно только одно определение шаблона типа для функции-члена шаблонного класса. Если требуется определение функции-члена шаблонного класса для конкретного типа, то задача системы программирования найти шаблон типа для этой функции-члена и создать нужную версию функции. В общем случае система программирования может рассчитывать на указания от программиста, которые помогут найти нужный шаблон типа. Важно составлять определение шаблона типа таким образом, чтобы его зависимость от глобальных данных была минимальной. Дело в том, шаблон типа будет использоваться для порождения функций и классов на основе заранее неизвестного типа и в неизвестных контекстах. Практически любая, даже слабая зависимость от контекста может проявиться как проблема при отладке программы пользователем, который, вероятнее всего, не был создателем шаблона типа. К совету избегать, насколько это возможно, использований глобальных имен, следует относиться особенно серьезно при разработке шаблона типа.

Шаблоны типа для списка

На практике при разработке класса, служащего коллекцией объектов, часто приходится учитывать взаимоотношения использующихся в реализации классов, управление памятью и необходимость определить итератор по содержимому коллекции. Часто бывает так, что несколько родственных классов разрабатываются совместно ($$12.2). В качестве примера мы предложим семейство классов, представляющих односвязные списки и шаблоны типа для них.


Поделиться:




Поиск по сайту

©2015-2024 poisk-ru.ru
Все права принадлежать их авторам. Данный сайт не претендует на авторства, а предоставляет бесплатное использование.
Дата создания страницы: 2016-02-16 Нарушение авторских прав и Нарушение персональных данных


Поиск по сайту: