Программирование — страница 37 из 57

Векторы, шаблоны и исключения

“Успех никогда не бывает окончательным”.

Уинстон Черчилль (Winston Churchill)


В этой главе мы завершим изучение вопросов проектирования и реализации наиболее известного и полезного контейнера из библиотеки STL: класса

vector
. Мы покажем, как реализовать контейнеры с переменным количеством элементов, как описать контейнеры, в которых тип является параметром, а также продемонстрируем, как обрабатывать ошибки, связанные с выходом за пределы допустимого диапазона. Как обычно, описанные здесь приемы будут носить универсальный характер, выходя далеко за рамки класса
vector
и даже реализации контейнеров. По существу, мы покажем, как безопасно работать с переменным объемом данных разных типов. Кроме того, в примерах проектирования постараемся учесть конкретные реалии. Наша технология программирования основана на шаблонах и исключениях, поэтому мы покажем, как определить шаблоны, и продемонстрируем основные способы управления ресурсами, играющими ключевую роль в эффективной работе с исключениями.

19.1. Проблемы

В конце главы 18 наша разработка класса

vector
достигла этапа, на котором мы могли выполнять следующие операции.

• Создавать объекты класса

vector
, элементами которого являются числа с плавающей точкой двойной точности с любым количеством элементов.

• Копировать объекты класса

vector
с помощью присваивания и инициализации.

• Корректно освобождать память, занятую объектом класса

vector
, когда он выходит за пределы области видимости.

• Обращаться к элементам объекта класса

vector
, используя обычные индексные обозначения (как в правой, так и в левой части оператора присваивания).


Все это хорошо и полезно, но, для того чтобы выйти на ожидаемый уровень сложности (ориентируясь на сложность стандартного библиотечного класса

vector
), мы должны разрешить еще несколько проблем.

• Как изменить размер объекта класса

vector
(изменить количество его элементов)?

• Как перехватить и обработать ошибку, связанную с выходом за пределы объекта класса

vector
?

• Как задать тип элементов в объекте класса

vector
в качестве аргумента?


Например, как определить класс

vector
так, чтобы стало возможным написать следующий код:


vector vd;             // элементы типа double

double d;

while(cin>>d) vd.push_back(d); // увеличить vd, чтобы сохранить

                               // все элементы


vector vc(100);          // элементы типа char

int n;

cin>>n;

vc.resize(n);                  // создать объект vc, содержащий

                               // n элементов


Очевидно, что такие операции над векторами очень полезны, но почему это так важно с программистской точки зрения? Почему это достойно включения в стандартный набор приемов программирования? Дело в том, что эти операции обеспечивают двойную гибкость. У нас есть одна сущность, объект класса

vector
, которую мы можем изменить двумя способами.

• Изменить количество элементов.

• Изменить тип элементов.


Эти виды изменчивости весьма полезны и носят фундаментальный характер. Мы всегда собираем данные. Окидывая взглядом свой письменный стол, я вижу груду банковских счетов, счета за пользование кредитными карточками и телефонные разговоры. Каждый из этих счетов по существу представляет собой список строк, содержащих информацию разного типа: строки букв и чисел. Передо мной лежит телефон; в нем хранится список имен и телефонных номеров. В книжных шкафах на полках стоят книги. Наши программы схожи с ними: в них описаны контейнеры, состоящие из элементов разных типов. Существуют разные контейнеры (класс

vector
просто используется чаще других), содержащие разную информацию: телефонные номера, имена, суммы банковских операций и документы. По существу, все, что лежит на моем столе, было создано с помощью каких-то компьютерных программ.

Очевидным исключением является телефон: он сам является компьютером, и когда я пересматриваю номера телефонов, вижу результаты работы программы, которая похожа на ту, которую мы пишем. Фактически эти номера можно очень удобно хранить в объекте класса

vector
.

 Очевидно, что не все контейнеры содержат одинаковое количество элементов. Можно ли работать с векторами, размер которых фиксируется в момент их инициализации, т.е. могли бы мы написать наш код, не используя функции

push_back()
,
resize()
или другие эквивалентные операции? Конечно, могли бы, но это возложило бы на программиста совершенно ненужную нагрузку: основной трудностью при работе с контейнерами фиксированного размера является перенос элементов в более крупный контейнер, когда их количество становится слишком большим и превышает первоначальный размер. Например, мы могли бы заполнить вектор, не изменяя его размер, с помощью следующего кода:


// заполняем вектор, не используя функцию push_back:

vector* p = new vector(10);

int n = 0;    // количество элементов

double d;

while(cin >> d) {

  if (n==p–>size()) {

    vector* q = new vector(p–>size()*2);

    copy(p–>begin(),p–>end(),q–>begin());

    delete p;

    p = q;

  }

  (*p)[n] = d;

  ++n;

}


Это некрасиво. К тому же вы уверены, что этот код правильно работает? Как можно быть в этом уверенным? Обратите внимание на то, что мы внезапно стали использовать указатели и явное управление памятью. Мы были вынуждены это сделать, чтобы имитировать стиль программирования, близкий к машинному уровню при работе с объектами фиксированного размера (массивами; см. раздел 18.5). Одна из причин, обусловивших использование контейнеров, таких как класс

vector
, заключается в желании сделать нечто лучшее; иначе говоря, мы хотим, чтобы класс
vector
сам изменял размер контейнера, освободив пользователей от этой работы и уменьшив вероятность сделать ошибку. Иначе говоря, мы предпочитаем контейнеры, которые могут увеличивать свой размер, чтобы хранить именно столько элементов, сколько нам нужно. Рассмотрим пример.


vector vd;

double d;

while(cin>>d) vd.push_back(d);


 Насколько распространенным является изменение размера контейнера? Если такая ситуация встречается редко, то предусматривать для этого специальные средства было бы нецелесообразно. Однако изменение размера встречается очень часто. Наиболее очевидный пример — считывание неизвестного количества значений из потока ввода. Другими примерами являются коллекционирование результатов поиска (нам ведь неизвестно заранее, сколько их будет) и удаление элементов из коллекции один за другим. Таким образом, вопрос заключается не в том, стоит ли предпринимать изменение размера контейнера, а в том, как это сделать.

 Почему мы вообще затронули тему, посвященную изменению размера контейнера? Почему бы просто не выделить достаточно памяти и работать с нею?! Эта стратегия выглядит наиболее простой и эффективной. Тем не менее это оправдано лишь в том случае, если мы не запрашиваем слишком много памяти. Программисты, избравшие эту стратегию, вынуждены переписывать свои программы (если они внимательно и систематически отслеживают переполнение памяти) или сталкиваются с катастрофическими последствиями (если они пренебрегли проверкой переполнения памяти).

Очевидно, что объекты класса

vector
должны хранить числа с двойной точностью, значения температуры, записи (разного вида), строки, операции, кнопки графического пользовательского интерфейса, фигуры, даты, указатели на окна и т.д. Перечисление можно продолжать бесконечно. Контейнеры тоже бывают разного вида. Это важное обстоятельство, имеющее значительные последствия, которое обязывает нас хорошенько подумать, прежде чем выбрать конкретный вид контейнера. Почему не все контейнеры представляют собой векторы? Если бы мы имели дело только с одним видом контейнера, то операции над ним можно было бы сделать частью языка программирования. Кроме того, нам не пришлось бы возиться с другими видами контейнеров; мы бы просто всегда использовали класс vector.

Структуры данных играют ключевую роль в большинстве важных приложений. О том, как организовать данные, написано множество толстых и полезных книг. В большинстве из них рассматривается вопрос: “Как лучше хранить данные?” Ответ один — нам нужны многочисленные и разнообразные контейнеры, однако это слишком обширная тема, которую в этой книге мы не можем осветить в должной мере. Тем не менее мы уже широко использовали классы

vector
и
string
(класс
string
— это контейнер символов). В следующих главах мы опишем классы
list
,
map
(класс
map
— это дерево, в котором хранятся пары значений) и матрицы. Поскольку нам нужны разнообразные контейнеры, для их поддержки необходимы соответствующие средства языка и технологии программирования. Технологии хранения данных и организации доступа к ним являются одними из наиболее фундаментальных и наиболее сложных форм вычислений.

 На уровне машинной памяти все объекты имеют фиксированный размер и не имеют типов. Здесь мы рассматриваем средства языка и технологии программирования, позволяющие создавать контейнеры объектов разного типа с переменным количеством элементов. Это обеспечивает значительную гибкость программ и удобство программирования.

19.2. Изменение размера

Какие возможности для изменения размера имеет стандартный библиотечный класс

vector
? В нем предусмотрены три простые операции. Допустим, в программе объявлен следующий объект класса
vector
:


vector v(n); // v.size()==n


Изменить его размер можно тремя способами.


v.resize(10);    // v теперь имеет 10 элементов

v.push_back(7);  // добавляем элемент со значением 7 в конец объекта v

                 // размер v.size() увеличивается на единицу

v = v2;          // присваиваем другой вектор; v — теперь копия v2

                 // теперь v.size() == v2.size()


Стандартный библиотечный класс

vector
содержит и другие операции, которые могут изменять размер вектора, например
erase()
и
insert()
(раздел Б.4.7), но здесь мы просто покажем, как можно реализовать три указанные операции над вектором.

19.2.1. Представление

В разделе 19.1 мы продемонстрировали простейшую стратегию изменения размера: выделить память для нового количества элементов и скопировать туда старые элементы. Но если размер контейнера изменяется часто, то такая стратегия становится неэффективной. На практике, однажды изменив размер, мы обычно делаем это много раз. В частности, в программах редко встречается одиночный вызов функции

push_back()
.

Итак, мы можем оптимизировать наши программы, предусмотрев изменение размера контейнера. На самом деле все реализации класса

vector
отслеживают как количество элементов, так и объем свободной памяти, зарезервированной для будущего расширения. Рассмотрим пример.


class vector {

  int sz;       // количество элементов

  double* elem; // адрес первого элемента

  int space;    // количество элементов плюс свободная

                // память/слоты

                // для новых элементов (текущая память)

public:

  // ...

};


Эту ситуацию можно изобразить графически.



Поскольку нумерация элементов начинается с нуля, мы показываем, что переменная

sz
(количество элементов) ссылается на ячейку, находящуюся за последним элементом, а переменная
space
ссылается на ячейку, расположенную за последним слотом. Им соответствуют указатели, установленные на ячейки
elem+sz
и
elem+space
.

Когда вектор создается впервые, переменная

space
равна
sz
, т.е. “свободного места” нет.



Мы не начинаем выделение дополнительных слотов, пока количество элементов не изменится. Обычно это происходит, когда выполняется условие

space==sz
. Благодаря этому, используя функцию
push_back()
, мы не выходим за пределы памяти.

Конструктор по умолчанию (создающий объект класса

vector
без элементов) устанавливает все три члена класса равными нулю.


vector::vector():sz(0),elem(0),space(0) { }


Эта ситуация выглядит следующим образом:



“Запредельный элемент” является лишь умозрительным. Конструктор по умолчанию не выделяет свободной памяти и занимает минимальный объем (см. упр. 16). Наш класс

vector
иллюстрирует прием, который можно использовать для реализации стандартного вектора (и других структур данных), но стандартные библиотечные реализации отличаются большим разнообразием, поэтому вполне возможно, что в вашей системе класс
std::vector
использует другие стратегии.

19.2.2. Функции reserve и capacity

Самой главной операцией при изменении размера контейнера (т.е. при изменении количества элементов) является функция

vector::reserve()
. Она добавляет память для новых элементов.


void vector::reserve(int newalloc)

{

  if (newalloc<=space) return;             // размер не уменьшается

  double* p = new double[newalloc];        // выделяем новую память

  for (int i=0; i

                                           // элементы

  delete[] elem;    // освобождаем старую память

  elem = p;

  space = newalloc;

}


Обратите внимание на то, что мы не инициализировали элементы в выделенной памяти. Мы просто резервируем память, а как ее использовать — задача функций

push_back()
и
resize()
.

Очевидно, что пользователя может интересовать размер доступной свободной памяти в объекте класса

vector
, поэтому, аналогично стандартному классу, мы предусмотрели функцию-член, выдающую эту информацию.


int vector::capacity() const { return space; }


Иначе говоря, для объекта класса

vector
с именем
v
выражение
v.capacity()–v.size()
возвращает количество элементов, которое можно записать в объект
v
с помощью функции
push_back()
без выделения дополнительной памяти.

19.2.3. Функция resize

Имея функцию

reserve()
, реализовать функцию
resize()
для класса
vector
не представляет труда. Необходимо предусмотреть несколько вариантов.

• Новый размер больше ранее выделенной памяти.

• Новый размер больше прежнего, но меньше или равен ранее выделенной памяти.

• Новый размер равен старому.

• Новый размер меньше прежнего.


Посмотрим, что у нас получилось.


void vector::resize(int newsize)

 // создаем вектор, содержащий newsize элементов

 // инициализируем каждый элемент значением 0.0 по умолчанию

{

  reserve(newsize);

  for (int i=sz; i

                                              // новые элементы

  sz = newsize;

}


Основная работа с памятью поручена функции

reserve()
. Цикл инициализирует новые элементы (если они есть).

Мы не выделяли каждый из этих вариантов явно, но, как легко проверить, все они, тем не менее, обработаны правильно.


ПОПРОБУЙТЕ

Какие варианты следует предусмотреть (и протестировать), если мы хотим убедиться, что данная функция

resize()
работает правильно? Что скажете об условиях
newsize==0
и
newsize==–77
?

19.2.4. Функция push_back

При первом рассмотрении функция

push_back()
может показаться сложной для реализации, но функция
reserve()
все упрощает.


void vector::push_back(double d)

 // увеличивает размер вектора на единицу;

 // инициализирует новый элемент числом d

{

  if (space==0) reserve(8); // выделяет память для 8

                            // элементов

  else if (sz==space) reserve(2*space); // выделяет дополнительную

                                        // память

  elem[sz] = d;  // добавляет d в конец вектора

  ++sz;          // увеличивает размер (sz — количество элементов)

}


Другими словами, если у нас нет свободной памяти, то удваиваем размер выделенной памяти. На практике эта стратегия оказывается очень удачной, поэтому она используется в стандартном библиотечном классе

vector
.

19.2.5. Присваивание

Присваивание векторов можно определить несколькими способами. Например, мы могли бы допускать присваивание, только если векторы имеют одинаковое количество элементов. Однако в разделе 18.2.2 мы решили, что присваивание векторов должно иметь более общий характер и более очевидный смысл: после присваивания

v1=v2
вектор
v1
является копией вектора
v2
. Рассмотрим следующий рисунок.



Очевидно, что мы должны скопировать элементы, но есть ли у нас свободная память? Можем ли мы скопировать вектор в свободную память, расположенную за его последним элементом? Нет! Новый объект класса

vector
будет хранить копии элементов, но поскольку мы еще не знаем, как он будет использоваться, то не выделили свободной памяти в конце вектора.

Простейшая реализация описана ниже.

• Выделяем память для копии.

• Копируем элементы.



• Освобождаем старую память.

• Присваиваем членам

sz
,
elem
и
space
новые значения.


Код будет выглядеть примерно так:


vector& vector::operator=(const vector& a)

 // похож на конструктор копирования,

 // но мы должны работать со старыми элементами

{

  double* p = new double[a.sz];     // выделяем новую память

  for (int i = 0; i

                                                 // элементы

  delete[] elem;     // освобождаем старую память

  space = sz = a.sz; // устанавливаем новый размер

  elem = p;          // устанавливаем новые элементы

  return *this;      // возвращаем ссылку на себя

}


Согласно общепринятому соглашению оператор присваивания возвращает ссылку на целевой объект. Смысл выражения

*this
объяснялся в разделе 17.10. Его реализация является корректной, но, немного поразмыслив, легко увидеть, что мы выполняем избыточные операции выделения и освобождения памяти. Что делать, если целевой вектор содержит больше элементов, чем присваиваемый вектор? Что делать, если целевой вектор содержит столько же элементов, сколько и присваиваемый вектор? Во многих приложениях последняя ситуация встречается чаще всего. В любом случае мы можем просто скопировать элементы в память, уже выделенную ранее целевому вектору.


vector& vector::operator=(const vector& a)

{

  if (this==&a) return *this;  // самоприсваивание, ничего делать

                               // не надо


  if (a.sz<=space) {           // памяти достаточно, новая память

                               // не нужна

  for (int i = 0; i

  sz = a.sz;

  return *this;

}


  double* p = new double[a.sz]; // выделяем новую память

  for (int i = 0; i

                                                 // элементы

  delete[] elem;      // освобождаем старую память

  space = sz = a.sz;  // устанавливаем новый размер

  elem = p;           // устанавливаем указатель на новые

                      // элементы

  return *this;       // возвращаем ссылку на целевой объект

}


В этом фрагменте кода мы сначала проверяем самоприсваивание (например,

v=v
); в этом случае ничего делать не надо. С логической точки зрения эта проверка лишняя, но иногда она позволяет значительно оптимизировать программу. Эта проверка демонстрирует использование указателя
this
, позволяющего проверить, является ли аргумент a тем же объектом, что и объект, из которого вызывается функция-член (т.е.
operator=()
). Убедитесь, что этот код действительно работает, если из него удалить инструкцию
this==&a
. Инструкция
a.sz<=space
также включена для оптимизации. Убедитесь, что этот код действительно работает после удаления из него инструкции
a.sz<=space
.

19.2.6. Предыдущая версия класса vector

Итак, мы получили почти реальный класс

vector
для чисел типа
double
.


// почти реальный вектор чисел типа double

class vector {

/*

 инвариант:

 для 0<=n

 sz<=space;

 если sz

 для (space–sz) чисел типа double

*/

  int sz;       // размер

  double* elem; // указатель на элементы (или 0)

  int space;    // количество элементов плюс количество слотов

public:

  vector():sz(0),elem(0),space(0) { }

  explicit vector(int s):sz(s),elem(new double[s]),space(s)

  {

    for (int i=0; i

                                        // инициализированы

  }


  vector(const vector&);             // копирующий конструктор

  vector& operator=(const vector&);  // копирующее присваивание


  ~vector() { delete[] elem; }       // деструктор

  double& operator[ ](int n) { return elem[n]; }  // доступ

  const double& operator[](int n) const { return elem[n]; }


  int size() const { return sz; }

  int capacity() const { return space; }


  void resize(int newsize);           // увеличение

  void push_back(double d);

  void reserve(int newalloc);

};


Обратите внимание на то, что этот класс содержит все основные операции (см. раздел 18.3): конструктор, конструктор по умолчанию, копирующий конструктор, деструктор. Он также содержит операции для доступа к данным (индексирование

[]
), получения информации об этих данных (
size()
и
capacity()
), а также для управления ростом вектора (
resize()
,
push_back()
и
reserve()
).

19.3. Шаблоны

Однако нам мало иметь вектор, состоящий из чисел типа

double
; мы хотим свободно задавать тип элементов наших векторов. Рассмотрим пример.


vector

vector

vector

vector          // вектор указателей на объекты класса Window

vector< vector> // вектор векторов из объектов класса Record

vector


 Для этого мы должны научиться определять шаблоны. На самом деле мы с самого начала уже использовали шаблоны, но до сих пор нам не приходилось определять их самостоятельно. Стандартная библиотека содержит все необходимое, но мы не должны полагаться на готовые рецепты, поэтому следует разобраться, как спроектирована и реализована стандартная библиотека, например класс

vector
и функция
sort()
(разделы 21.1 и Б.5.4). Это не просто теоретический интерес, поскольку, как обычно, средства и методы, использованные при создании стандартной библиотеки, могут помочь при работе над собственными программами. Например, в главах 21-22 мы покажем, как с помощью шаблонов реализовать стандартные контейнеры и алгоритмы, а в главе 24 продемонстрируем, как разработать класс матриц для научных вычислений.

 По существу, шаблон (template) — это механизм, позволяющий программисту использовать типы как параметры класса или функции. Получив эти аргументы, компилятор генерирует конкретный класс или функцию. 

19.3.1. Типы как шаблонные параметры

 Итак, мы хотим, чтобы тип элементов был параметром класса

vector
. Возьмем класс
vector
и заменим ключевое слово
double
буквой
T
, где
T
— параметр, который может принимать значения, такие как
double
,
int
,
string
, vector и Window*. В языке С++ для описания параметра
T
, задающего тип, используется префикс
template
, означающий “для всех типов
T
”.

Рассмотрим пример.


// почти реальный вектор элементов типа T

template class vector {

  // читается как "для всех типов T" (почти так же, как

  // в математике)

  int sz;      // размер

  T* elem;     // указатель на элементы

  int space;   // размер + свободная память

public:

  vector():sz(0),elem(0),space(0) { }

  explicit vector(int s);


  vector(const vector&);            // копирующий
 конструктор

  vector& operator=(const vector&); // копирующее
 присваивание

  ~vector() { delete[] elem; }      // деструктор


  T& operator[](int n) { return elem[n]; } // доступ: возвращает

                                           // ссылку

  const T& operator[](int n) const { return elem[n]; }


  int size() const { return sz; }   // текущий размер

  int capacity() const { return space; }


  void resize(int newsize);         // увеличивает вектор

  void push_back(const T& d);

  void reserve(int newalloc);

};


Это определение класса

vector
совпадает с определением класса
vector
, содержащего элементы типа
double
(см. раздел 19.2.6), за исключением того, что ключевое слово
double
теперь заменено шаблонным параметром
T
. Этот шаблонный класс
vector
можно использовать следующим образом:


vector vd;         // T — double

vector vi;            // T — int

vector vpd;       // T — double*

vector< vector> vvi; // T — vector, в котором T — int 


 Можно просто считать, что компилятор генерирует класс конкретного типа (соответствующего шаблонному аргументу), подставляя его вместо шаблонного параметра. Например, когда компилятор видит в программе конструкцию

vector
, он генерирует примерно такой код:


class vector_char {

  int sz;      // размер

  char* elem;  // указатель на элементы

  int space;   // размер + свободная память

public:

  vector_char();

  explicit vector_char(int s);


  vector_char(const vector_char&);  // копирующий конструктор

  vector_char& operator=(const vector_char &); // копирующее

                                               // присваивание


  ~vector_char ();            // деструктор


  char& operator[] (int n);   // доступ: возвращает ссылку

  const char& operator[] (int n) const;


  int size() const;           // текущий размер

  int capacity() const;


  void resize(int newsize);   // увеличение

  void push_back(const char& d);

  void reserve(int newalloc);

};


Для класса

vector
компилятор генерирует аналог класса
vector
, содержащий элементы типа
double
(см. раздел 19.2.6), используя соответствующее внутреннее имя, подходящее по смыслу конструкции
vector
).

 Иногда шаблонный класс называют порождающим типом (type generator). Процесс генерирования типов (классов) с помощью шаблонного класса по заданным шаблонным аргументам называется специализацией (specialization) или конкретизацией шаблона (template instantiation). Например, классы

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

Конкретизация шаблона (генерирование шаблонных специализаций) осуществляется на этапе компиляции или редактирования связей, а не во время выполнения программы.

Естественно, шаблонный класс может иметь функции-члены. Рассмотрим пример.


void fct(vector& v)

{

  int n = v.size();

  v.push_back("Norah");

  // ...

}


При вызове такой функции-члена шаблонного класса компилятор генерирует соответствующую конкретную функцию. Например, когда компилятор видит вызов


v.push_back("Norah"), он генерирует функцию

void vector::push_back(const string& d) { /* ... */ }


используя шаблонное определение


template void vector::push_back(const T& d) { /* ... */ };


Итак, вызову

v.push_back("Norah")
соответствует конкретная функция. Иначе говоря, если вам нужна функция с конкретным типом аргумента, компилятор сам напишет ее, основываясь на вашем шаблоне.

Вместо префикса

template
можно использовать префикс
template 
. Эти две конструкции означают одно и то же, но некоторые программисты все же предпочитают использовать ключевое слово
typename
, “потому, что оно яснее, и потому, что никто не подумает, что оно запрещает использовать встроенные типы, например тип
int
, в качестве шаблонного аргумента”. Мы считаем, что ключевое слово
class
уже означает “тип”, поэтому никакой разницы между этими конструкциями нет. Кроме того, слово
class
короче.

19.3.2. Обобщенное программирование

 Шаблоны — это основа для обобщенного программирования на языке С++. По существу, простейшее определение обобщенного программирования на языке С++ — это программирование с помощью шаблонов. Хотя, конечно, это определение носит слишком упрощенный характер. Не следует давать определения фундаментальных понятий программирования в терминах конструкций языка программирования. Эти конструкции существуют для того, чтобы поддерживать технологии программирования, а не наоборот. Как и большинство широко известных понятий, обобщенное программирование имеет несколько определений. Мы считаем наиболее полезным самое простое из них.

Обобщенное программирование — это создание кода, работающего с разными типами, заданными в виде аргументов, причем эти типы должны соответствовать специфическим синтаксическим и семантическим требованиям.


 Например, элементы вектора должны иметь тип, который можно копировать (с помощью копирующего конструктора и копирующего присваивания). В главах 20-21 будут представлены шаблоны, у которых аргументами являются арифметические операции. Когда мы производим параметризацию класса, мы получаем шаблонный класс (class template), который часто называют также параметризованным типом (parameterized type) или параметризованным классом (parameterized class). Когда мы производим параметризацию функции, мы получаем шаблонную функцию (function template), которую часто называют параметризованной функцией (parameterized function), а иногда алгоритмом (algorithm). По этой причине обобщенное программирование иногда называют алгоритмически ориентированным программированием (algorithm-oriented programming); в этом случае основное внимание при проектировании переносится на алгоритмы, а не на используемые типы.

 Поскольку понятие параметризованных типов играет такую важную роль в программировании, мы попытаемся в дальнейшем немного разобраться в этой запутанной терминологии. Это даст нам возможность избежать недоразумений, когда мы встретим знакомые понятия в другом контексте.

 Данную форму обобщенного программирования, основанную на явных шаблонных параметрах, часто называют параметрическим полиморфизмом (parametric polymorphism). В противоположность ей полиморфизм, возникающий благодаря иерархии классов и виртуальным функциям, называют специальным полиморфизмом (ad hoc polymorphism), а соответствующий стиль — ориентированным программированием (см. разделы 14.3-14.4). Причина, по которой оба стиля программирования называют полиморфизмом (polymorphism), заключается в том, что каждый из них дает программисту возможность создавать много версий одного и того же понятия с помощью единого интерфейса. Полиморфизм по-гречески означает “много форм”. Таким образом, вы можете манипулировать разными типами с помощью общего интерфейса. В примерах, посвященных классу

Shape
, рассмотренных в главах 16–19, мы буквально работали с разными формами (классами
Text
,
Circle
и
Polygon
) с помощью интерфейса, определенного классом
Shape
. Используя класс
vector
, мы фактически работаем со многими векторами (например,
vector
,
vector
и
vector
) с помощью интерфейса, определенного шаблонным классом
vector
.

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


v.push_back(x); // записать x в вектор v

s.draw(); // нарисовать фигуру s


Для вызова

v.push_back(x)
компилятор определит тип элементов в объекте
v
и применит соответствующую функцию
push_back()
, а для вызова
s.draw()
он неявно вызовет некую функцию
draw()
(с помощью таблицы виртуальных функций, связанной с объектом
s
; см. раздел 14.3.1). Это дает объектно-ориентированному программированию свободу, которой лишено обобщенное программирование, но в то же время это делает обычное обобщенное программирование более систематическим, понятным и эффективным (благодаря прилагательным “специальный” и “параметрический”).

 Подведем итоги.

Обобщенное программирование поддерживается шаблонами, основываясь на решениях, принятых на этапе компиляции

Объектно-ориентированное программирование поддерживается иерархиями классов и виртуальными функциями, основываясь на решениях, принятых на этапе выполнения программы.


Сочетание этих стилей программирования вполне возможно и полезно. Рассмотрим пример.


void draw_all(vector& v)

{

  for (int i=0; idraw();

}


Здесь мы вызываем виртуальную функцию (

draw()
) из базового класса (
Shape
) с помощью другой виртуальной функции — это определенно объектно-ориентированное программирование. Однако указатели
Shape*
хранятся в объекте класса
vector
, который является параметризованным типом, значит, мы одновременно применяем (простое) обобщенное программирование.

 Но довольно философии. Для чего же на самом деле используются шаблоны?

Для получения непревзойденно гибких и высокопроизводительных программ.

• Используйте шаблоны, когда производительность программы играет важную роль (например, при интенсивных вычислениях в реальном времени; подробнее об этом речь пойдет в главах 24 и 25).

• Используйте шаблоны, когда гибкость сочетания информации, поступающей от разных типов, играет важную роль (например, при работе со стандартной библиотекой языка C++; эта тема будет обсуждаться в главах 20 и 21).


 Шаблоны имеют много полезных свойств, таких как высокая гибкость и почти оптимальная производительность, но, к сожалению, они не идеальны. Как всегда, преимуществам сопутствуют недостатки. Основным недостатком шаблонов является то, что гибкость и высокая производительность достигаются за счет плохого разделения между “внутренностью” шаблона (его определением) и его интерфейсом (объявлением). Это проявляется в плохой диагностике ошибок, особенно плохими являются сообщения об ошибках. Иногда эти сообщения об ошибках в процессе компиляции выдаются намного позже, чем следовало бы.

При компиляции программы, использующей шаблоны, компилятор “заглядывает” внутрь шаблонов и его шаблонных аргументов. Он делает это для того, чтобы извлечь информацию, необходимую для генерирования оптимального кода. Для того чтобы эта информация стала доступной, современные компиляторы требуют, чтобы шаблон был полностью определен везде, где он используется. Это относится и к его функциям-членам и ко всем шаблонным функциям, вызываемым из них. В результате авторы шаблонов стараются разместить определения шаблонов в заголовочных файлах. На самом деле стандарт этого не требует, но пока не будут разработаны более эффективные реализации языка, мы рекомендуем вам поступать со своими шаблонами именно так: размещайте в заголовочном файле определения всех шаблонов, используемых в нескольких единицах трансляции.

 Мы рекомендуем вам начинать с очень простых шаблонов и постепенно набираться опыта. Один из полезных приемов проектирования мы уже продемонстрировали на примере класса

vector
: сначала разработайте и протестируйте класс, используя конкретные типы. Если программа работает, замените конкретные типы шаблонными параметрами. Для обеспечения общности, типовой безопасности и высокой производительности программ используйте библиотеки шаблонов, например стандартную библиотеку языка C++. Главы 20-21 посвящены контейнерам и алгоритмам из стандартной библиотеки. В них приведено много примеров использования шаблонов.

19.3.3. Контейнеры и наследование

Это одна из разновидностей сочетания объектно-ориентированного и обобщенного программирования, которое люди постоянно, но безуспешно пытаются применять: использование контейнера объектов производного класса в качестве контейнера объектов базового класса. Рассмотрим пример.


vector vs;

vector vc;

vs = vc;    // ошибка: требуется класс vector

void f(vector&);

f(vc);      // ошибка: требуется класс vector


 Но почему? “В конце концов, — говорите вы, — я могу конвертировать класс

Circle
в класс
Shape
!” Нет, не можете. Вы можете преобразовать указатель
Circle*
в
Shape*
и ссылку
Circle&
в
Shape&
, но мы сознательно запретили присваивать объекты класса
Shape
, поэтому вы не имеете права спрашивать, что произойдет, если вы поместите объект класса Circle с определенным радиусом в переменную типа
Shape
, которая не имеет радиуса (см. раздел 14.2.4). Если бы это произошло, — т.е. если бы мы разрешили такое присваивание, — то возникло бы так называемое “усечение” (“slicing”), похожее на усечение целых чисел (см. раздел 3.9.2).

Итак, попытаемся снова использовать указатели.


vector vps;

vector vpc;

vps = vpc;  // ошибка: требуется класс vector

void f(vector&);

f(vpc);     // ошибка: требуется класс vector


И вновь система типов сопротивляется. Почему? Рассмотрим, что может делать функция

f()
.


void f(vector& v)

{

  v.push_back(new Rectangle(Point(0,0),Point(100,100)));

}


 Очевидно, что мы можем записать указатель

Rectangle*
в объект класса
vector
. Однако, если бы этот объект класса
vector
в каком-то месте программы рассматривался как объект класса
vector
, то мог бы возникнуть неприятный сюрприз. В частности, если бы компилятор пропустил пример, приведенный выше, то что указатель
Rectangle*
делал в векторе
vpc
? Наследование — мощный и тонкий механизм, а шаблоны не расширяют его возможности неявно. Существуют способы использования шаблонов для выражения наследования, но эта тема выходит за рамки рассмотрения этой книги. Просто запомните, что выражение “
D
— это
B
” не означает: “
C
— это
C
” для произвольного шаблонного класса
C
. Мы должны ценить это обстоятельство как защиту против непреднамеренного нарушения типов. (Обратитесь также к разделу 25.4.4.) 

19.3.4. Целые типы как шаблонные параметры

 Очевидно, что параметризация классов с помощью типов является полезной. А что можно сказать о параметризации классов с помощью, например, целых чисел или строк? По существу, любой вид аргументов может оказаться полезным, но мы будем рассматривать только типы и целочисленные параметры. Другие виды параметров реже оказываются полезными, и поддержка языком С++ других видов параметров носит более сложный характер и требует обширных и глубоких знаний.

Рассмотрим пример наиболее распространенного использования целочисленного значения в качестве шаблонного аргумента: контейнер, количество элементов которого известно уже на этапе компиляции.


template struct array {

  T elem[N]; // хранит элементы в массиве -

  // члене класса, использует конструкторы по умолчанию,

  // деструктор и присваивание


  T& operator[] (int n); // доступ: возвращает ссылку

  const T& operator[] (int n) const;


  T* data() { return elem; } // преобразование в тип T*

  const T* data() const { return elem; }


  int size() const { return N; }

}


Мы можем использовать класс

array
(см. также раздел 20.7) примерно так:


array gb; // 256 целых чисел

array ad = { 0.0, 1.1, 2.2, 3.3, 4.4, 5.5 }; // инициализатор!

const int max = 1024;

void some_fct(int n)

{

  array loc;

  array oops;         // ошибка: значение n компилятору

                              // неизвестно

  // ...

  array loc2 = loc; // создаем резервную копию

  // ...

  loc = loc2;                 // восстанавливаем

  // ...

}


Ясно, что класс array очень простой — более простой и менее мощный, чем класс

vector
, — так почему иногда следует использовать его, а не класс
vector
? Один из ответов: “эффективность”. Размер объекта класса array известен на этапе компиляции, поэтому компилятор может выделить статическую память (для глобальных объектов, таких как
gb
) или память в стеке (для локальных объектов, таких как
loc
), а не свободную память. Проверяя выход за пределы диапазона, мы сравниваем константы (например, размер N). Для большинства программ это повышение эффективности незначительно, но если мы создаем важный компонент системы, например драйвер сети, то даже небольшая разница оказывается существенной. Что еще более важно, некоторые программы просто не могут использовать свободную память. Такие программы обычно работают во встроенных системах и/или в программах, для которых основным критерием является безопасность (подробно об этом речь пойдет в главе 25). В таких программах массив
array
имеет много преимуществ над классом vector без нарушения основного ограничения (запрета на использование свободной памяти).

Поставим противоположный вопрос: “Почему бы просто не использовать класс

vector
?”, а не “Почему бы просто не использовать встроенные массивы?” Как было показано в разделе 18.5, массивы могут порождать ошибки: они не знают своего размера, они конвертируют указатели при малейшей возможности и неправильно копируются; в классе
array
, как и в классе
vector
, таких проблем нет. Рассмотрим пример.


double* p = ad;        // ошибка: нет неявного преобразования

                       // в указатель

double* q = ad.data(); // OK: явное преобразование

template void printout(const C& c) // шаблонная функция

{

  for (int i = 0; i


Эту функцию

printout()
можно вызвать как в классе
array
, так и в классе
vector
.


printout(ad); // вызов из класса array

vector vi;

// ...

printout(vi); // вызов из класса vector


Это простой пример обобщенного программирования, демонстрирующий доступ к данным. Он работает благодаря тому, что как для класса

array
, так и для класса
vector
используется один и тот же интерфейс (функции
size()
и операция индексирования). Более подробно этот стиль будет рассмотрен в главах 20 и 21. 

19.3.5. Вывод шаблонных аргументов

 Создавая объект конкретного класса на основе шаблонного класса, мы указываем шаблонные аргументы. Рассмотрим пример.


array buf; // для массива buf параметр T — char, а N == 1024

array b2;  // для массива b2 параметр T — double, а N == 10


 Для шаблонной функции компилятор обычно выводит шаблонные аргументы из аргументов функций. Рассмотрим пример.


template void fill(array& b, const T& val)

{

  for (int i = 0; i

}


void f()

{

  fill(buf, 'x'); // для функции fill() параметр T — char,

                  // а N == 1024,

                  // потому что аргументом является объект buf

  fill(b2,0.0);   // для функции fill() параметр T — double,

                  // а N == 10,

                  // потому что аргументом является объект b2

}


С формальной точки зрения вызов

fill(buf,'x')
является сокращенной формой записи
fill(buf,'x')
, а
fill(b2,0)
— сокращение вызова
fill(b2,0)
, но, к счастью, мы не всегда обязаны быть такими конкретными. Компилятор сам извлекает эту информацию за нас. 

19.3.6. Обобщение класса vector

Когда мы создавали обобщенный класс

vector
на основе класса “
vector
элементов типа
double
” и вывели шаблон “
vector
элементов типа
T
”, мы не проверяли определения функций
push_back()
,
resize()
и
reserve()
. Теперь мы обязаны это сделать, поскольку в разделах 19.2.2 и 19.2.3 эти функции были определены на основе предположений, которые были справедливы для типа
double
, но не выполняются для всех типов, которые мы хотели бы использовать как тип элементов вектора.

• Как запрограммировать класс

vector
, если тип
X
не имеет значения по умолчанию?

• Как гарантировать, что элементы вектора будут уничтожены в конце работы с ним? 


 Должны ли мы вообще решать эти проблемы? Мы могли бы заявить: “Не создавайте векторы для типов, не имеющих значений по умолчанию” или “Не используйте векторы для типов, деструкторы которых могут вызвать проблемы”. Для конструкции, предназначенной для общего использования, такие ограничения довольно обременительны и создают впечатление, что разработчик не понял задачи или не думал о пользователях. Довольно часто такие подозрения оказываются правильными, но разработчики стандартной библиотеки к этой категории не относятся. Для того чтобы повторить стандартный класс vector, мы должны устранить две указанные выше проблемы.

Мы можем работать с типами, не имеющими значений по умолчанию, предоставив пользователю возможность задавать это значение самостоятельно.


template void vector::resize(int newsize, T def = T());


Иначе говоря, используйте в качестве значения по молчанию объект, созданный конструктором

T()
, если пользователь не указал иначе. Рассмотрим пример.


vector v1;

v1.resize(100);      // добавляем 100 копий объекта double(), т.е. 0.0

v1.resize(200, 0.0); // добавляем 200 копий числа 0.0 — упоминание

                     // излишне

v1.resize(300, 1.0); // добавляем 300 копий числа 1.0

struct No_default {

  No_default(int);   // единственный конструктор класса No_default

  // ...

};


vector v2(10);     // ошибка: попытка создать 10

                               // No_default()

vector v3;

v3.resize(100, No_default(2)); // добавляем 100 копий объектов

                               // No_default(2)

v3.resize(200);                // ошибка: попытка создать 200

                               // No_default()


Проблему, связанную с деструктором, устранить труднее. По существу, мы оказались в действительно трудной ситуации: в структуре данных часть данных проинициализирована, а часть — нет. До сих пор мы старались избегать неинициализированных данных и ошибок, которые ими порождаются. Теперь, как разработчики класса

vector
, мы столкнулись с проблемой, которой раньше, как пользователи класса
vector
, не имели.

Во-первых, мы должны найти способ для получения неинициализированной памяти и манипулирования ею. К счастью, стандартная библиотека содержит класс

allocator
, распределяющий неинициализированную память. Слегка упрощенный вариант приведен ниже.


template class allocator {

public:

  // ...

  T* allocate(int n);       // выделяет память для n объектов типа T

  void deallocate(T* p, int n); // освобождает память, занятую n

                            // объектами типа T, начиная с адреса p


  void construct(T* p, const T& v); // создает объект типа T

                                    // со значением v по адресу p

  void destroy(T* p);               // уничтожает объект T по адресу p

};


Если вам нужна полная информация по этому вопросу, обратитесь к книге The C++ Programming Language или к стандарту языка С++ (см. описание заголовка ), а также к разделу B.1.1. Тем не менее в нашей программе демонстрируются четыре фундаментальных операции, позволяющих выполнять следующие действия:

• Выделение памяти, достаточной для хранения объекта типа

T
без инициализации.

• Создание объекта типа

T
в неинициализированной памяти.

• Уничтожение объекта типа

T
и возвращение памяти в неинициализированное состояние.

• Освобождение неинициализированной памяти, достаточной для хранения объекта типа

T
без инициализации.


Не удивительно, что класс

allocator
— то, что нужно для реализации функции
vector::reserve()
. Начнем с того, что включим в класс
vector
параметр класса
allocator
.


template> class vector {

  A alloc;  // используем объект класса allocator для работы

            // с памятью, выделяемой для элементов

  // ...

};


Кроме распределителя памяти, используемого вместо оператора

new
, остальная часть описания класса
vector
не отличается от прежнего. Как пользователи класса
vector
, мы можем игнорировать распределители памяти, пока сами не захотим, чтобы класс
vector
управлял памятью, выделенной для его элементов, нестандартным образом. Как разработчики класса
vector
и как студенты, пытающиеся понять фундаментальные проблемы и освоить основные технологии программирования, мы должны понимать, как вектор работает с неинициализированной памятью, и предоставить пользователям правильно сконструированные объекты. Единственный код, который следует изменить, — это функции-члены класса
vector
, непосредственно работающие с памятью, например функция
vector::reserve()
.


template

void vector::reserve(int newalloc)

{

  if (newalloc<=space) return;     // размер не уменьшается

  T* p = alloc.allocate(newalloc); // выделяем новую память

  for (int i=0; i

                                   // копируем

  for (int i=0; i

  alloc.deallocate(elem,space);    // освобождаем старую память

  elem = p;

  space = newalloc;

}


Мы перемещаем элемент в новый участок памяти, создавая копию в неинициализированной памяти, а затем уничтожая оригинал. Здесь нельзя использовать присваивание, потому что для таких типов, как

string
, присваивание подразумевает, что целевая область памяти уже проинициализирована.

Имея функции

reserve()
,
vector::push_back()
, можно без труда написать следующий код.


template

void vector::push_back(const T& val)

{

  if (space==0) reserve(8);        // начинаем с памяти для 8 элементов

  else if (sz==space) reserve(2*space); // выделяем больше памяти

  alloc.construct(&elem[sz],val);  // добавляем в конец

                                   // значение val

  ++sz;                            // увеличиваем размер

}


Аналогично можно написать функцию

vector::resize()
.


template

void vector::resize(int newsize, T val = T())

{

  reserve(newsize);

  for (int i=sz; i

  // создаем

  for (int i = newsize; i

  // уничтожаем

  sz = newsize;

}

Обратите внимание на то, что, поскольку некоторые типы не имеют конструкторов по умолчанию, мы снова предоставили возможность задавать начальное значение для новых элементов.

Другое новшество — деструктор избыточных элементов при уменьшении вектора. Представьте себе деструктор, превращающий объект определенного типа в простой набор ячеек памяти.

 “Непринужденное обращение с распределителями памяти” — это довольно сложное и хитроумное искусство. Не старайтесь злоупотреблять им, пока не почувствуете, что стали экспертом.

19.4. Проверка диапазона и исключения

 Мы проанализировали текущее состояние нашего класса

vector
и обнаружили (с ужасом?), что в нем не предусмотрена проверка выхода за пределы допустимого диапазона. Реализация оператора
operator[]
не вызывает затруднений.


template T& vector::operator[](int n)

{

  return elem[n];

}


Рассмотрим следующий пример:


vector v(100);

v[–200] = v[200]; // Ой!

int i;

cin>>i;

v[i] = 999;  // повреждение произвольной ячейки памяти


Этот код компилируется и выполняется, обращаясь к памяти, не принадлежащей нашему объекту класса

vector
. Это может создать большие неприятности! В реальной программе такой код неприемлем. Попробуем улучшить наш класс
vector
, чтобы решить эту проблему. Простейший способ — добавить в класс операцию проверки доступа с именем
at()
.


struct out_of_range { /* ... */ }; // класс, сообщающий об ошибках,

// связанных с выходом за пределы допустимого диапазона

template> class vector {

  // ...

  T& at(int n);                     // доступ с проверкой

  const T& at(int n) const;         // доступ с проверкой

  T& operator[](int n);             // доступ без проверки

  const T& operator[](int n) const; // доступ без проверки

  // ...

};


template T& vector::at(int n)

{

  if (n<0 || sz<=n) throw out_of_range();

  return elem[n];

}


template T& vector::operator[](int n)

// как прежде

{

  return elem[n];

}


Итак, мы можем написать следующую функцию:


void print_some(vector& v)

{

  int i = –1;

  cin >> i;

  while(i!= –1) try {

    cout << "v[" << i << "]==" << v.at(i) << "\n";

  }

  catch(out_of_range) {

  cout << "Неправильный индекс: " << i << "\n";

  }

}


Здесь мы используем функцию

at()
, чтобы обеспечить доступ к элементам с проверкой выхода за пределы допустимого диапазона, и генерируем исключение
out_of_range
, если обнаруживаем недопустимое обращение к элементу вектора.

Основная идея заключается в использовании операции индексирования

[]
, если нам известно, что индекс правильный, и функции
at()
, если возможен выход за пределы допустимого диапазона. 

19.4.1. Примечание: вопросы проектирования

 Итак, все хорошо, но почему бы нам не включить проверку выхода за пределы допустимого диапазона в функцию

operator[]()
? Тем не менее, как показано выше, стандартный класс
vector
содержит отдельную функцию
at()
с проверкой доступа и функцию
operator[]()
без проверки. Попробуем обосновать это решение. Оно основывается на четырех аргументах.

1. Совместимость. Люди использовали индексирование без проверки выхода за пределы допустимого диапазона задолго до того, как в языке C++ появились исключения.

2. Эффективность. Можно создать оператор с проверкой выхода за пределы допустимого диапазона на основе оптимально эффективного оператора индексирования без такой проверки, но невозможно создать оператор индексирования без проверки выхода за пределы допустимого диапазона, обладающий оптимальным быстродействием, на основе оператора доступа, выполняющего такую проверку.

3. Ограничения. В некоторых средах исключения не допускаются.

4. Необязательная проверка. На самом деле стандарт не утверждает, что вы не можете проверить диапазон в классе

vector
, поэтому, если хотите выполнить проверку, можете ее реализовать. 

19.4.1.1. Совместимость

Люди очень не любят переделывать старый код. Например, если вы написали миллионы строк кода, то было бы очень дорого переделывать его полностью, чтобы корректно использовать исключения. Мы могли бы сказать, что после такой переделки код станет лучше, но не станем этого делать, поскольку не одобряем излишние затраты времени и денег. Более того, люди, занимающиеся сопровождением существующего кода, обычно утверждают, что в принципе код без проверки небезопасен, но их конкретная программа была протестирована и используется уже многие годы, так что в ней уже выявлены все ошибки. К этим аргументам можно относиться скептически, но в каждом конкретном случае следует принимать взвешенное решение. Естественно, нет никаких программ, которые использовали стандартный класс

vector
до того, как он появился в языке C++, но существуют миллионы строк кода, в которых используются очень похожие классы, но без исключений. Большинство этих программ впоследствии было переделано с учетом стандарта. 

19.4.1.2. Эффективность

Да, проверка выхода за пределы диапазона в экстремальных случаях, таких как буферы сетевых интерфейсов и матрицы в высокопроизводительных научных вычислениях, может оказаться слишком сложной. Однако стоимость проверки выхода за пределы допустимого диапазона редко учитывается при обычных вычислениях, которые выполняются в большинстве случаев. Таким образом, мы рекомендуем при малейшей возможности использовать проверку выхода за пределы допустимого диапазона в классе

vector

19.4.1.3. Ограничения

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

19.4.1.4. Необязательная проверка

Стандарт ISO C++ утверждает, что выход за пределы допустимого диапазона вектора не имеет гарантированной семантики, поэтому его следует избегать. В соответствии со стандартом при попытке выхода за пределы допустимого диапазона следует генерировать исключение. Следовательно, если вы хотите, чтобы класс

vector
генерировал исключения и не создавал проблем, связанных с первыми тремя аргументами, в конкретном приложении следует использовать класс
vector
с проверкой выхода за пределы допустимого диапазона. Именно этого принципа мы придерживаемся в нашей книге.

 Короче говоря, реальная программа может оказаться сложнее, чем хотелось бы, но всегда есть возможность скопировать готовые решения. 

19.4.2. Признание: макрос

Как и наш класс vector, большинство реализаций стандартного класса

vector
не гарантирует проверку выхода за пределы допустимого диапазона с помощью оператора индексирования (
[]
), а вместо этого содержит функцию
at()
, выполняющую такую проверку. В каком же месте нашей программы возникают исключения
std::out_of_range
? По существу, мы выбрали вариант 4 из раздела 19.4.1: реализация класса
vector
не обязана проверять выход за пределы допустимого диапазона с помощью оператора
[]
, но ей не запрещено делать это иным способом, и мы решили воспользоваться этой возможностью. Однако в нашей отладочной версии под названием
Vector
, разрабатывая код, мы реализовали проверку в операторе
[]
. Это позволяет сократить время отладки за счет небольшой потери производительности программы.


struct Range_error:out_of_range { // подробное сообщение

// о выходе за пределы допустимого диапазона

  int index;

  Range_error(int i):out_of_range("Range error"), index(i)

  { }

};


template struct Vector:public std::vector {

  typedef typename std::vector::size_type size_type;


  Vector() { }

  explicit Vector(size_type n):std::vector(n) {}

  Vector(size_type n, const T& v):std::vector(n,v) {}


  T& operator[](size_type int i)  // rather than return at(i);

  {

    if (i<0||this–>size()<=i) throw Range_error(i);

    return std::vector::operator[](i);

  }


  const T& operator[](size_type int i) const

  {

    if (i<0||this–>size()<=i) throw Range_error(i);

    return std::vector::operator[](i);

  }

};


Мы используем класс

Range_error
, чтобы облегчить отладку операции индексирования. Оператор
typedef
вводит удобный синоним, который подробно описан в разделе 20.5.

Класс

Vector
очень простой, возможно, слишком простой, но он полезен для отладки нетривиальных программ. В качестве альтернативы нам пришлось бы использовать реализацию стандартного класса
vector
, предусматривающую систематическую проверку, — возможно, именно это нам и следовало сделать; у нас нет информации, насколько строгой является проверка, предусмотренная вашим компилятором и библиотекой (поскольку это выходит за рамки стандарта).

 В заголовке

std_lib_facilities.h
мы используем ужасный трюк (макроподстановку), указывая, что слово vector означает
Vector
.


// отвратительный макрос, чтобы получить вектор

// с проверкой выхода за пределы допустимого диапазона

#define vector Vector


Это значит, что там, где вы написали слово

vector
, компилятор увидит слово
Vector
. Этот трюк ужасен тем, что вы видите не тот код, который видит компилятор. В реальных программах макросы являются источником довольно большого количества запутанных ошибок (разделы 27.8 и A.17).

Мы сделали то же самое, чтобы реализовать проверку выхода за пределы допустимого диапазона для класса

string
.

К сожалению, не существует стандартного, переносимого и ясного способа реализовать проверку выхода за пределы допустимого диапазона с помощью операции

[]
в классе
vector []
. Однако эту проверку в классах
vector
и
string
можно реализовать намного точнее и полнее. Хотя обычно это связано с заменой реализации стандартной библиотеки, уточнением опций инсталляции или с вмешательством в код стандартной библиотеки. Ни одна из этих возможностей неприемлема для новичков, приступающих к программированию, поэтому мы использовали класс
string
из главы 2. 

19.5. Ресурсы и исключения

Таким образом, объект класса

vector
может генерировать исключения, и мы рекомендуем, чтобы, если функция не может выполнить требуемое действие, она генерировала исключение и передавала сообщение в вызывающий модуль (см. главу 5). Теперь настало время подумать, как написать код, обрабатывающий исключения, сгенерированные операторами класса
vector
и другими функциями. Наивный ответ — “для перехвата исключения используйте блок
try
, пишите сообщение об ошибке, а затем прекращайте выполнение программы” — слишком прост для большинства нетривиальных систем.

 Один из фундаментальных принципов программирования заключается в том, что, если мы запрашиваем ресурс, то должны — явно или неявно — вернуть его системе. Перечислим ресурсы системы.

• Память (memory).

• Блокировки (locks).

• Дескрипторы файлов (file handles).

• Дескрипторы потоков (thread handles).

• Сокеты (sockets).

• Окна (windows).


 По существу, ресурс — это нечто, что можно получить и необходимо вернуть (освободить) самостоятельно или по требованию менеджера ресурса. Простейшим примером ресурса является свободная память, которую мы занимаем, используя оператор

new
, и возвращаем с помощью оператора
delete
. Рассмотрим пример.


void suspicious(int s, int x)

{

  int* p = new int[s]; // занимаем память

  // ...

  delete[] p;          // освобождаем память

}


Как мы видели в разделе 17.4.6, следует помнить о необходимости освободить память, что не всегда просто выполнить. Исключения еще больше усугубляют ситуацию, и в результате из-за невежества или небрежности может возникнуть утечка ресурсов. В качестве примера рассмотрим функцию

suspicious()
, которая использует оператор new явным образом и присваивает результирующий указатель на локальную переменную, создавая очень опасную ситуацию.

19.5.1. Потенциальные проблемы управления ресурсами

 Рассмотрим одну из опасностей, таящуюся в следующем, казалось бы, безвредном присваивании указателей:


int* p = new int[s]; // занимаем память


Она заключается в трудности проверки того, что данному оператору new соответствует оператор

delete
. В функции
suspicious()
есть инструкция
delete[] p
, которая могла бы освободить память, но представим себе несколько причин, по которым это может и не произойти. Какие инструкции можно было бы вставить в часть, отмеченную многоточием,
...
, чтобы вызвать утечку памяти? Примеры, которые мы подобрали для иллюстрации возникающих проблем, должны натолкнуть вас на размышления и вызвать подозрения относительно такого кода. Кроме того, благодаря этим примерам вы оцените простоту и мощь альтернативного решения.

Возможно, указатель

p
больше не ссылается на объект, который мы хотим уничтожить с помощью оператора
delete
.


void suspicious(int s, int x)

{

  int* p = new int[s]; // занимаем память

  // ...

  if (x) p = q;        // устанавливаем указатель p на другой объект

  // ...

  delete[] p;          // освобождаем память

}


Мы включили в программу инструкцию

if (x)
, чтобы гарантировать, что вы не будете знать заранее, изменилось ли значение указателя
p
или нет. Возможно, программа никогда не выполнит оператор
delete
.


void suspicious(int s, int x)

{

  int* p = new int[s]; // занимаем память

  // ...

  if (x) return;

  // ...

  delete[] p; // освобождаем память

}


Возможно, программа никогда не выполнит оператор

delete
, потому что сгенерирует исключение.


void suspicious(int s, int x)

{

  int* p = new int[s]; // занимаем память

  vector v;

  // ...

  if (x) p[x] = v.at(x);

  // ...

  delete[] p;          // освобождаем память

}


 Последняя возможность беспокоит нас больше всего. Когда люди впервые сталкиваются с такой проблемой, они считают, что она связана с исключениями, а не с управлением ресурсами. Не понимая истинных причин проблемы, они пытаются перехватывать исключения.


void suspicious(int s, int x) // плохой код

{

  int* p = new int[s]; // занимаем память

  vector v;

  // ...

  try {

    if (x) p[x] = v.at(x);

    // ...

  } catch (...) {      // перехватываем все исключения

  delete[] p;          // освобождаем память

  throw;               // генерируем исключение повторно

  }

  // ...

  delete[] p;          // освобождаем память

}


Этот код решает проблему за счет дополнительных инструкций и дублирования кода, освобождающего ресурсы (в данном случае инструкции

delete[] p;
). Иначе говоря, это некрасивое решение; что еще хуже — его сложно обобщить. Представим, что мы задействовали несколько ресурсов.


void suspicious(vector& v, int s)

{

  int* p = new int[s];

  vectorv1;

  // ...

  int* q = new int[s];

  vector v2;

  // ...

  delete[] p;

  delete[] q;

}


Обратите внимание на то, что, если оператор

new
не сможет выделить свободную память, он сгенерирует стандартное исключение
bad_alloc
. Прием
try ... catc
h в этом примере также успешно работает, но нам потребуется несколько блоков
try
, и код станет повторяющимся и ужасным. Мы не любим повторяющиеся и запутанные программы, потому что повторяющийся код сложно сопровождать, а запутанный код не только сложно сопровождать, но и вообще трудно понять. 


ПОПРОБУЙТЕ

Добавьте блоки

try
в последний пример и убедитесь, что все ресурсы будут правильно освобождаться при любых исключениях.

19.5.2. Получение ресурсов — это инициализация

 К счастью, нам не обязательно копировать инструкции

try...catch
, чтобы предотвратить утечку ресурсов. Рассмотрим следующий пример:


void f(vector& v, int s)

{

  vector p(s);

  vector q(s);

  // ...

}


 Это уже лучше. Что еще более важно, это очевидно лучше. Ресурс (в данном случае свободная память) занимается конструктором и освобождается соответствующим деструктором. Теперь мы действительно решили нашу конкретную задачу, связанную с исключениями. Это решение носит универсальный характер; его можно применить ко всем видам ресурсов: конструктор получает ресурсы для объекта, который ими управляет, а соответствующий деструктор их возвращает. Такой подход лучше всего зарекомендовал себя при работе с блокировками баз данных (database locks), сокетами (sockets) и буферами ввода-вывода (I/O buffers) (эту работу делают объекты класса

iostream
). Соответствующий принцип обычно формулируется довольно неуклюже: “Получение ресурса есть инициализация” (“Resource Acquisition Is Initialization” — RAII).

Рассмотрим предыдущий пример. Как только мы выйдем из функции

f()
, будут вызваны деструкторы векторов
p
и
q
: поскольку переменные
p
и
q
не являются указателями, мы не можем присвоить им новые значения, инструкция
return
не может предотвратить вызов деструкторов и никакие исключения не генерируются.

Это универсальное правило: когда поток управления покидает область видимости, вызываются деструкторы для каждого полностью созданного объекта и активизированного подобъекта. Объект считается полностью созданным, если его конструктор закончил свою работу. Исследование всех следствий, вытекающих из этих двух утверждений, может вызвать головную боль. Будем считать просто, что конструкторы и деструкторы вызываются, когда надо и где надо.

 В частности, если хотите выделить в области видимости свободную память переменного размера, мы рекомендуем использовать класс

vector
, а не “голые” операторы
new
и
delete

19.5.3. Гарантии

Что делать, если вектор невозможно ограничить только одной областью (или подобластью) видимости? Рассмотрим пример.


vector* make_vec() // создает заполненный вектор

{

  vector* p = new vector; // выделяем свободную память

  // ...заполняем вектор данными;

  // возможна генерация исключения...

  return p;

}


Это довольно распространенный пример: мы вызываем функцию, чтобы создать сложную структуру данных, и возвращаем эту структуру как результат. Однако, если при заполнении вектора возникнет исключение, функция

make_vec()
потеряет этот объект класса
vector
. Кроме того, если функция успешно завершит работу, то кто-то будет должен удалить объект, возвращенный функцией
make_vec()
(см. раздел 17.4.6).

Для того чтобы сгенерировать исключение, мы можем добавить блок

try
.


vector* make_vec() // создает заполненный вектор

{

  vector* p = new vector; // выделяет свободную память

  try {

    // ...заполняем вектор данными;

    // возможна генерация исключения...

    return p;

  }

  catch (...) {

    delete p; // локальная очистка

    throw;    // повторно генерируем исключение,

              // чтобы вызывающая

              // функция отреагировала на то, что функция

              // make_vec() не сделала то, что требовалось

  }

}


 Функция

make_vec()
иллюстрирует очень распространенный стиль обработки ошибок: программа пытается выполнить свое задание, а если не может, то освобождает все локальные ресурсы (в данном случае свободную память, занятую объектом класса
vector
) и сообщает об этом, генерируя исключение. В данном случае исключение генерируется другой функцией (
(vector::at()
); функция
make_vec()
просто повторяет генерирование с помощью оператора
throw
;.

Это простой и эффективный способ обработки ошибок, который можно применять систематически.

Базовая гарантия. Цель кода

try ... catch
состоит в том, чтобы гарантировать, что функция
make_vec()
либо завершит работу успешно, либо сгенерирует исключение без утечки ресурсов. Это часто называют базовой гарантией (basic guarantee). Весь код, являющийся частью программы, которая восстанавливает свою работу после генерирования исключения, должна поддерживать базовую гарантию.

Жесткая гарантия. Если кроме базовой гарантии, функция также гарантирует, что все наблюдаемые значения (т.е. все значения, не являющиеся локальными по отношению к этой функции) после отказа восстанавливают свои предыдущие значения, то говорят, что такая функция дает жесткую гарантию (strong guarantee). Жесткая гарантия — это идеал для функции: либо функция будет выполнена так, как ожидалось, либо ничего не произойдет, кроме генерирования исключения, означающего отказ.

Гарантия отсутствия исключений (no-throw guarantee). Если бы мы не могли выполнять простые операции без какого бы то ни было риска сбоя и без генерирования исключений, то не могли бы написать код, соответствующий условиям базовой и жесткой гарантии. К счастью, практически все встроенные средства языка С++ поддерживают гарантию отсутствия исключений: они просто не могут их генерировать. Для того чтобы избежать генерирования исключений, просто не выполняйте оператор

throw
,
new
и не применяйте оператор dynamic_cast к ссылочным типам (раздел A.5.7).


Для анализа правильности программы наиболее полезными являются базовая и жесткая гарантии. Принцип RAII играет существенную роль для реализации простого и эффективного кода, написанного в соответствии с этими идеями. Более подробную информацию можно найти в приложении Д книги Язык программирования С++.

 Естественно, всегда следует избегать неопределенных (и обычно опасных) операций, таких как разыменования нулевого указателя, деление на нуль и выход за пределы допустимого диапазона. Перехват исключений не отменяет фундаментальные правила языка.

19.5.4. Класс auto_ptr

Итак, функции, такие как

make_vec()
, подчиняются основным правилам корректного управления ресурсами с использованием исключений. Это обеспечивает выполнение базовой гарантии, которую должны давать все правильные функции при восстановлении работы программы после генерирования исключений. Если не произойдет чего-либо катастрофического с нелокальными данными в той части программы, которая ответственна за заполнение вектора данными, то можно даже утверждать, что такие функции дают жесткую гарантию. Однако этот блок
try ... catch
по-прежнему выглядит ужасно. Решение очевидно: нужно как-то применить принцип RAII; иначе говоря, необходимо предусмотреть объект, который будет владеть объектом класса
vector
и сможет его удалить, если возникнет исключение. В заголовке
стандартной библиотеки содержится класс
auto_ptr
, предназначенный именно для этого.


vector* make_vec() // создает заполненный вектор

{

  auto_ptr< vector> p(new vector);  // выделяет свободную

                                               // память

  // ...заполняем вектор данными;

  // возможна генерация исключения...

  return p.release();  // возвращаем указатель,

                       // которым владеет объект p

}


Объект класса

auto_ptr
просто владеет указателем в функции. Он немедленно инициализируется указателем, созданным с помощью оператора
new
. Теперь мы можем применять к объектам класса
auto_ptr
операторы
–>
и
*
как к обычному указателю (например,
p–> at(2)
или
(*p).at(2)
), так что объект класса
auto_ptr
можно считать разновидностью указателя. Однако не спешите копировать класс
auto_ptr
, не прочитав соответствующей документации; семантика этого класса отличается от семантики любого типа, который мы до сих пор встречали. Функция
release()
вынуждает объект класса
auto_ptr
вернуть обычный указатель обратно, так что мы можем вернуть этот указатель, а объект класса
auto_ptr
не сможет уничтожить объект, на который установлен возвращаемый указатель. Если вам не терпится использовать класс
auto_ptr
в более интересных ситуациях (например, скопировать его объект), постарайтесь преодолеть соблазн. Класс
auto_ptr
предназначен для того, чтобы владеть указателем и гарантировать уничтожение объекта при выходе из области видимости. Иное использование этого класса требует незаурядного мастерства. Класс
auto_ptr
представляет собой очень специализированное средство, обеспечивающее простую и эффективную реализацию таких функций, как
make_vec()
. В частности, класс
auto_ptr
позволяет нам повторить наш совет: с подозрением относитесь к явному использованию блоков
try
; большинство из них вполне можно заменить, используя одно из применений принципа RAII. 

19.5.5. Принцип RAII для класса vector

Даже использование интеллектуальных указателей, таких как

auto_ptr
, может показаться недостаточно безопасным. Как убедиться, что мы выявили все указатели, требующие защиты? Как убедиться, что мы освободили все указатели, которые не должны были уничтожаться в конце области видимости? Рассмотрим функцию
reserve()
из раздела 19.3.5.


template

void vector::reserve(int newalloc)

{

  if (newalloc<=space) return;     // размер никогда не уменьшается

  T* p = alloc.allocate(newalloc); // выделяем новую память


  for (int i=0; i

                                   // копируем


  for (int i=0; i

  alloc.deallocate(elem,space);    // освобождаем старую память

  elem = p;

  space = newalloc;

}


 Обратите внимание на то, что операция копирования старого элемента

alloc.construct(&p[i],elem[i])
может генерировать исключение. Следовательно, указатель
p
— это пример проблемы, о которой мы предупреждали в разделе 19.5.1. Ой! Можно было бы применить класс
auto_ptr
. А еще лучше — вернуться назад и понять, что память для вектора — это ресурс; иначе говоря, мы можем определить класс
vector_base
для выражения фундаментальной концепции, которую используем все время. Эта концепция изображена на следующем рисунке, содержащем три элемента, определяющих использование памяти, предназначенной для вектора:



Добавив для полноты картины распределитель памяти, получим следующий код:


template

struct vector_base {

  A alloc;    // распределитель памяти

  T* elem;    // начало распределения

  int sz;     // количество элементов

  int space;  // размер выделенной памяти


  vector_base(const A& a, int n)

  :alloc(a), elem(a.allocate(n)), sz(n), space(n) { }

  ~vector_base() { alloc.deallocate(elem,space); }

};


Обратите внимание на то, что класс

vector_base
работает с памятью, а не с типизированными объектами. Нашу реализацию класса
vector
можно использовать для владения объектом, имеющим желаемый тип элемента. По существу, класс
vector
— это просто удобный интерфейс для класса
vector_base
.


template>

class vector:private vector_base {

public:

  // ...

};


Теперь можно переписать функцию

reserve()
, сделав ее более простой и правильной.


template

void vector::reserve(int newalloc)

{

  if (newalloc<=space) return;  // размер никогда не уменьшается

  vector_base b(alloc,newalloc);   // выделяем новую память

  for (int i=0; i

  alloc.construct(&b.elem[i], elem[i]); // копируем

  for (int i=0; i

    alloc.destroy(&elem[i]);            // освобождаем память

  swap< vector_base>(*this,b);    // меняем представления

                                        // местами

}


При выходе из функции

reserve()
старая память автоматически освобождается деструктором класса
vector_base
, даже если выход был вызван операцией копирования, сгенерировавшей исключение. Функция
swap()
является стандартным библиотечным алгоритмом (из заголовка
), меняющим два объекта местами. Мы использовали алгоритм
swap>(*this,b)
, а не более простую функцию
swap(*this,b)
, поскольку объекты
*this
и
b
имеют разные типы (
vector
и
vector_base
соответственно), поэтому должны явно указать, какую специализацию алгоритма
swap
следует выполнить. 


ПОПРОБУЙТЕ

Модифицируйте функцию

reserve
, чтобы она использовала класс
auto_ptr
. Помните о необходимости освободить память перед возвратом из функции. Сравните это решение с классом
vector_base
. Выясните, какое из них лучше и какое легче реализовать.


Задание

1. Определите класс

template struct S { T val; };
.

2. Добавьте конструктор, чтобы можно было инициализировать его типом

T
.

3. Определите переменные типов

S
,
S
,
S
,
S
и
S>
; инициализируйте их значениями по своему выбору.

4. Прочитайте эти значения и выведите их на экран.

5. Добавьте шаблонную функцию

get()
, возвращающую ссылку на значение
val
.

6. Разместите функцию

get()
за пределами класса.

7. Разместите значение

val
в закрытом разделе.

8. Выполните п. 4, используя функцию

get()
.

9. Добавьте шаблонную функцию

set()
, чтобы можно было изменить значение val.

10. Замените функции

get()
и
set()
оператором
operator[] ()
.

11. Напишите константную и неконстантную версии оператора

operator[] ()
.

12. Определите функцию

template read_val(T& v)
, выполняющую ввод данных из потока
cin
в переменную
v
.

13. Используйте функцию

read_val()
, чтобы считать данные в каждую из переменных, перечисленных в п. 3, за исключением переменной
S>
.

14. Бонус: определите класс

template istream& operator<<(istream&, vector&)
так, чтобы функция
read_val()
также обрабатывала переменную
S>
. Не забудьте выполнить тестирование после каждого этапа.


Контрольные вопросы

1. Зачем нужно изменять размер вектора?

2. Зачем нужны разные векторы с разными типами элементов?

3. Почему мы раз и навсегда не резервируем большой объем памяти для векторов?

4. Сколько зарезервированной памяти мы выделяем для нового вектора?

5. Зачем копировать элементы вектора в новую память?

6. Какие операции класса

vector
могут изменять размер вектора после его создания?

7. Чему равен объект класса

vector
после копирования?

8. Какие две операции определяют копию вектора?

9. Какой смысл имеет копирование объектов класса по умолчанию?

10. Что такое шаблон?

11. Назовите два самых полезных вида шаблонных аргументов?

12. Что такое обобщенное программирование?

13. Чем обобщенное программирование отличается от объектно-ориентированного программирования?

14. Чем класс

array
отличается от класса
vector
?

15. Чем класс

array
отличается от массива встроенного типа?

 16. Чем функция

resize()
отличается от функции
reserve()
?

17. Что такое ресурс? Дайте определение и приведите примеры.

18. Что такое утечка ресурсов?

19. Что такое принцип RAII? Какие проблемы он решает?

20. Для чего предназначен класс

auto_ptr
?


Термины


Упражнения

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

1. Напишите шаблонную функцию, складывающую векторы элементов любых типов, допускающих сложение.

2. Напишите шаблонную функцию, получающую в качестве аргументов объекты типов

vector vt
и
vector vu
и возвращающую сумму всех выражений
vt[i]*vu[i]
.

3. Напишите шаблонный класс

Pair
, содержащий пары значений любого типа. Используйте его для реализации простой таблицы символов, такой как в калькуляторе (см. раздел 7.8).

4. Превратите класс

Link
из раздела 17.9.3 в шаблонный. Затем выполните заново упр. 13 из главы 17 на основе класса
Link
.

5. Определите класс

Int
, содержащий единственный член типа
int
. Определите конструкторы, оператор присваивания и операторы
+
,
,
*
и
/
. Протестируйте этот класс и при необходимости уточните его структуру (например, определите операторы
<<
и
>>
для обычного ввода-вывода).

6. Повторите предыдущее упражнение с классом

Number
, где
T
— любой числовой тип. Попытайте добавить в класс
Number
оператор
%
и посмотрите, что получится, когда вы попробуете применить оператор
%
к типам
Number
и
Number
.

7. Примените решение упр. 2 к нескольким объектам типа

Number
.

8. Реализуйте распределитель памяти (см. раздел 19.3.6), используя функции

malloc()
и
free()
(раздел Б.10.4). Создайте класс
vector
так, как описано в конце раздела 19.4, для работы с несколькими тестовыми примерами.

9. Повторите реализацию функции

vector::operator=()
(см. раздел 19.2.5), используя класс
allocator
(см. раздел 19.3.6) для управления памятью.

10. Реализуйте простой класс

auto_ptr
, содержащий только конструктор, деструктор, операторы
–>
и
*
, а также функцию
release()
. В частности, не пытайтесь реализовать присваивание или копирующий конструктор.

11. Разработайте и реализуйте класс

counted_ptr
, владеющий указателем на объект типа
T
, и указатель, подсчитывающий количество ссылок (переменная типа
int
), общий для всех указателей, с подсчетом ссылок на один и тот же объект типа
T
. Счетчик ссылок должен содержать количество указателей, ссылающихся на данный объект типа
T
. Конструктор класса
counted_ptr
должен размещать в свободной памяти объект типа
T
и счетчик ссылок. Присвойте объекту класса
counted_ptr
начальное значение типа
T
. После уничтожения последнего объекта класса
counted_ptr
для класса
T
его деструктор должен удалить объект класса
T
. Предусмотрите в классе
counted_ptr
операции, позволяющие использовать его как указатель. Это пример так называемого “интеллектуального указателя”, который используется для того, чтобы гарантировать, что объект не будет уничтожен, пока последний пользователь не прекратит на него ссылаться. Напишите набор тестов для класса
counted_ptr
, используя его объекты в качестве аргументов при вызове функций, в качестве элементов контейнера и т.д.

12. Определите класс

File_handle
, конструктор которого получает аргумент типа
string
(имя файла) и открывает файл, а деструктор закрывает файл.

13. Напишите класс

Tracer
, в котором конструктор вводит, а деструктор выводит строки. Аргументами конструктора должны быть строки. Используйте этот пример для демонстрации того, как работают объекты, соответствующие принципу RAII (например, поэкспериментируйте с объектами класса
Tracer
, играющими роль локальных объектов, объектов-членов класса, глобальных объектов, объектов, размещенных с помощью оператора
new
, и т.д.). Затем добавьте копирующий конструктор и копирующее присваивание, чтобы можно было увидеть поведение объектов класса
Tracer
в процессе копирования.

14. Разработайте графический пользовательский интерфейс и средства вывода для игры “Охота на Вампуса” (см. главу 18). Предусмотрите ввод данных из окна редактирования и выведите на экран карту части пещеры, известной игроку.

15. Модифицируйте программу из предыдущего упражнения, чтобы дать пользователю возможность помечать комнаты, основываясь на знаниях и догадках, таких как “могут быть летучие мыши” и “бездонная пропасть”.

16. Иногда желательно, чтобы пустой вектор был как можно более маленьким. Например, можно интенсивно использовать класс

vector>>
, в котором большинство векторов пусто. Определите вектор так, чтобы выполнялось условие
sizeof(vector)==sizeof(int*)
, т.е. чтобы класс вектора состоял только из указателя на массив элементов, количества элементов и указателя space.


Послесловие

Шаблоны и исключения представляют собой весьма мощные языковые конструкции. Они поддерживают весьма гибкие технологии программирования — в основном благодаря разделению ответственности, т.е. возможности решать по одной проблеме в каждый отдельный момент времени. Например, используя шаблоны, мы можем определить контейнер, такой как vector, отделив его от определения типа элементов. Аналогично можно написать код, идентифицирующий ошибки и выдающий сообщения о них, отдельно от кода, предназначенного для их обработки. Третья основная тема, связанная с изменением размера вектора, относительно проста: функции

push_back()
,
resize()
и
reserve()
позволяют отделить определение вектора от спецификации его размера.

Глава 20