Часть III Данные и алгоритмы

Глава 17 Векторы и свободная память

“Используйте vector по умолчанию”.

Алекс Степанов (Alex Stepanov)


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

STL: vector
.

17.1. Введение

Наиболее полезным контейнером, описанным в стандартной библиотеке языка С++, является класс

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

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

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

В этой и следующих двух главах мы покажем, как построить класс

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

Разобравшись в вопросах проектирования, реализации и использования класса

vector
, мы сможем понять устройство других стандартных контейнеров, таких как
map
, и испытать элементные и эффективные методы их использования, обеспечиваемые стандартной библиотекой языка C++ (подробнее об этом речь пойдет в главах 20 и 21). Эти методы, называемые алгоритмами, позволяют решать типичные задачи программирования обработки данных. Вместо самостоятельной разработки кустарных инструментов мы можем облегчить написание и тестирование программ с помощью библиотеки языка C++. Мы уже видели и использовали один из наиболее полезных алгоритмов из стандартной библиотеки —
sort()
.

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

Глава 17. Как работать с разными объемами памяти? В частности, как создать разные векторы с разным количеством элементов и как отдельный вектор может иметь разное количество элементов в разные моменты времени? Это приведет нас к проверке объема свободной памяти (объема кучи), указателям, приведению типов (операторам явного приведения типов) и ссылкам.

Глава 18. Как скопировать вектор? Как реализовать оператор доступа к элементам по индексу? Кроме того, мы введем в рассмотрение массивы и исследуем их связь с указателями.

Глава 19. Как создать векторы с разными типами хранящихся в них элементов? Как обрабатывать ошибку выхода за пределы допустимого диапазона? Для ответа на этот вопрос мы изучим шаблоны языка С++ и исключения.


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

Итак, все упирается в прямой доступ к памяти. Зачем нам это нужно? Наши классы

vector
и
string
чрезвычайно полезны и удобны; их можно просто использовать. В конце концов, контейнеры, такие как
vector
и
string
, разработаны именно для того, чтобы освободить нас от неприятных аспектов работы с реальной памятью. Однако, если мы не верим в волшебство, то должны освоить самый низкий уровень управления памятью. А почему бы нам не поверить в волшебство, т.е. почему бы не поверить, что разработчики класса vector знали, что делают? В конце концов, мы же не разбираем физические устройства, обеспечивающие работу памяти компьютера.

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

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

17.2. Основы

Начнем нашу поступательную разработку класса

vector
с очень простого примера.


vector age(4); // вектор с четырьмя элементами типа double

age[0]=0.33;

age[1]=22.0;

age[2]=27.2;

age[3]=54.2;


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

vector
с четырьмя элементами типа
double
и присваивает им значения
0.33
,
22.0
,
27.2
и
54.2
. Эти четыре элемента имеют номера 0, 1, 2 и 3. Нумерация элементов в стандартных контейнерах языка С++ всегда начинается с нуля. Нумерация с нуля используется часто и является универсальным соглашением, которого придерживаются все программисты, пишущие программы на языке С++. Количество элементов в объекте класса
vector
называется его размером. Итак, размер вектора
age
равен четырем. Элементы вектора нумеруются (индексируются) от
0
до
size-1
. Например, элементы вектора
age
нумеруются от
0
до
age.size()–1
. Вектор age можно изобразить следующим образом:



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

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

Итак, как представить стрелку, изображенную на рисунке? Представим себе, что ее нет. Мы можем определить структуру данных фиксированного размера.


class vector {

 int size,age0,age1,age2,age3;

 // ...

};


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



Это просто и красиво, но как только мы попробуем добавить элемент с помощью функции

push_back()
, окажемся в затруднительном положении: мы не можем добавить элемент, так как количество элементов зафиксировано и равно четырем. Нам нужно нечто большее, чем структура данных, хранящая фиксированное количество элементов. Операции, изменяющие количество элементов в объекте класса
vector
, такие как
push_back()
, невозможно реализовать, если в классе
vector
количество элементов фиксировано. По существу, нам нужен член класса, ссылающийся на множество элементов так, чтобы при расширении памяти он мог ссылаться на другое множество элементов. Нам нужен адрес первого элемента. В языке C++ тип данных, способный хранить адрес, называют указателем (pointer). Синтаксически он выделяется суффиксом
*
, так что
double*
означает указатель на объект типа
double
. Теперь можем определить первый вариант класса
vector
.


// очень упрощенный вектор элементов типа double (вроде vector)

class vector {

 int sz;     // размер

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

public:

 vector(int s); // конструктор: размещает в памяти s чисел

 // типа double,

 // устанавливает на них указатель elem,

 // хранит число s в члене sz

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

};


Прежде чем продолжить проектирование класса vector, изучим понятие “указатель” более подробно. Понятие “указатель” — вместе с тесно связанным с ним понятием “массив” — это ключ к понятию “память” в языке C++.

17.3. Память, адреса и указатели

Память компьютера — это последовательность байтов. Эти байты нумеруются от нуля до последнего. Адресом (address) называют число, идентифицирующее ячейку в памяти. Адрес можно считать разновидностью целых чисел. Первый байт памяти имеет адрес 0, второй — 1 и т.д. Мегабайты памяти можно визуализировать следующим образом:



Все, что расположено в памяти, имеет адрес. Рассмотрим пример.


int var = 17;


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

int
, для хранения переменной
var
и записывает туда число
17
. Кроме того, можно хранить адреса и применять к ним операции. Объект, хранящий адрес, называют указателем. Например, тип, необходимый для хранения объекта типа
int
, называется указателем на
int
и обозначается как
int*
.


int* ptr = &var; // указатель ptr хранит адрес переменной var


Для определения адреса объекта используется оператор взятия адреса, унарный

&
. Итак, если переменная var хранится в участке памяти, первая ячейка которого имеет адрес 4096 (или 212), то указатель
ptr
будет хранить число 4096.



По существу, память компьютера можно рассматривать как последовательность байтов, пронумерованную от

0
до
size-1
. Для некоторых машин такое утверждение носит слишком упрощенный характер, но для нашей модели этого пока достаточно.

Каждый тип имеет соответствующий тип указателя. Рассмотрим пример.


char ch = 'c';

char* pc = &ch; // указатель на char

int ii = 17;

int* pi = ⅈ // указатель на int


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

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


cout << "pc==" << pc << "; содержимое pc==" << *pc << "\n";

cout << "pi==" << pi << "; содержимое pi==" << *pi << "\n";


Значением

*pc
является символ
c
, а значением
*pi
— целое число
17
. Значения переменных
pc
и
pi
зависят от того, как компилятор размещает переменные
ch
и
ii
в памяти. Обозначение, используемое для значения указателя (адрес), также может изменяться в зависимости от того, какие соглашения приняты в системе; для обозначения значений указателей часто используются шестнадцатеричные числа (раздел A.2.1.1).

Оператор разыменования также может стоять в левой части оператора присваивания.


*pc = 'x'; // OK: переменной char, на которую ссылается

      // указатель pc,

      // можно присвоить символ 'x'

*pi = 27;  // OK: указатель int* ссылается на int, поэтому *pi —

      // это int

*pi = *pc; // OK: символ (*pc) можно присвоить переменной

      // типа int (*pi)


Обратите внимание: несмотря на то, что значение указателя является целым числом, сам указатель целым числом не является. “На что ссылается

int
?” — некорректный вопрос. Ссылаются не целые числа, а указатели. Тип указателя позволяет выполнять операции над адресами, в то время как тип
int
позволяет выполнять (арифметические и логические) операции над целыми числами. Итак, указатели и целые числа нельзя смешивать.


int i = pi; // ошибка: нельзя присвоить объект типа int*

       // объекту типа int

pi = 7;   // ошибка: нельзя присвоить объект типа int объекту

       // типа int*


Аналогично, указатель на

char
(т.е.
char*
) — это не указатель на
int
(т.е.
int*
). Рассмотрим пример.


pc = pi; // ошибка: нельзя присвоить объект типа int*

     // объекту типа char*

pi = pc; // ошибка: нельзя присвоить объект типа char*

     // объекту типа int*


Почему нельзя присвоить переменную

pc
переменной
pi
? Один из ответов — символ
char
намного меньше типа
int
.


char ch1 = 'a';

char ch2 = 'b';

char ch3 = 'c';

char ch4 = 'd';

int* pi = &ch3; // ссылается на переменную,

         // имеющую размер типа char

         // ошибка: нельзя присвоить объект char* объекту

        // типа int*

         // однако представим себе, что это можно сделать

*pi = 12345;   // попытка записи в участок памяти, имеющий размер

         // типа char

*pi = 67890;


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



Если бы компилятор пропустил такой код, то мы могли бы записать число

12345
в ячейку памяти, начинающуюся с адреса
&ch3
. Это изменило бы содержание окрестной памяти, т.е. значения переменных
ch2
и
ch4
. В худшем (и самом реальном) случае мы бы перезаписали часть самой переменной
pi
! В этом случае следующее присваивание
*pi=67890
привело бы к размещению числа
67890
в совершенно другой области памяти. Очень хорошо, что такое присваивание запрещено, но таких механизмов защиты на низком уровне программирования очень мало.

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

int
в указатель или конвертировать один тип показателя в другой, можно использовать оператор
reinterpret_cast
(подробнее об этом — в разделе 17.8).

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

vector
. Мы должны знать, как написать код на низком уровне, поскольку не всякий код может быть высокоуровневым (см. главу 25). Кроме того, для того чтобы оценить удобство и относительную надежность высокоуровневого программирования, необходимо почувствовать сложность низкоуровневого программирования. Наша цель — всегда работать на самом высоком уровне абстракции, который допускает поставленная задача и сформулированные ограничения. В этой главе, а также в главах 18–19 мы покажем, как вернуться на более комфортабельный уровень абстракции, реализовав класс
vector
.

17.3.1. Оператор sizeof

Итак, сколько памяти требуется для хранения типа

int
? А указателя? Ответы на эти вопросы дает оператор
sizeof
.


cout << "размер типа char" << sizeof(char) << ' '

   << sizeof ('a') << '\n';

cout << "размер типа int" << sizeof(int) << ' '

    << sizeof (2+2) << '\n';

int* p = 0;

cout << "размер типа int*" << sizeof(int*) << ' '

   << sizeof (p) << '\n';


Как видим, можно применить оператор

sizeof
как к имени типа, так и к выражению; для типа оператор
sizeof
возвращает размер объекта данного типа, а для выражения — размер типа его результата. Результатом оператора
sizeof
является положительное целое число, а единицей измерения объема памяти является значение
sizeof(char)
, которое по определению равно
1
. Как правило, тип
char
занимает один байт, поэтому оператор
sizeof
возвращает количество байтов.


ПОПРОБУЙТЕ

Выполните код, приведенный выше, и посмотрите на результаты. Затем расширьте этот пример для определения размера типов

bool
,
double
и некоторых других.


Размер одного и того же типа в разных реализациях языка С++ не обязательно совпадает. В настоящее время выражение

sizeof(int)
в настольных компьютерах и ноутбуках обычно равно четырем. Поскольку в байте содержится 8 бит, это значит, что тип
int
занимает 32 бита. Однако в процессорах встроенных систем тип
int
занимает 16 бит, а в высокопроизводительных архитектурах размер типа
int
обычно равен 64 битам.

Сколько памяти занимает объект класса vector? Попробуем выяснить.


vector v(1000);

cout << "Размер объекта типа vector(1000) = "

   << sizeof (v) << '\n';


Результат может выглядеть так:


Размер объекта типа vector(1000) = 20


Причины этого факта станут очевидными по мере чтения этой и следующей глав (а также раздела 19.2.1), но уже сейчас ясно, что оператор

sizeof
не просто пересчитывает элементы.

17.4. Свободная память и указатели

Рассмотрим реализацию класса

vector
, приведенную в конце раздела 17.2. Где класс
vector
находит место для хранения своих элементов? Как установить указатель
elem
так, чтобы он ссылался на них? Когда начинается выполнение программы, написанной на языке С++, компилятор резервирует память для ее кода (иногда эту память называют кодовой (code storage), или текстовой (text storage)) и глобальных переменных (эту память называют статической (static storage)). Кроме того, он выделяет память, которая будет использоваться при вызове функций для хранения их аргументов и локальных переменных (эта память называется стековой (stack storage), или автоматической (automatic storage)). Остальная память компьютера может использоваться для других целей; она называется свободной (free). Это распределение памяти можно проиллюстрировать следующим образом.



Язык С++ делает эту свободную память (которую также называют кучей (heap)) доступной с помощью оператора

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


double* p = new double[4]; // размещаем 4 числа double в свободной

               // памяти


Указанная выше инструкция просит систему выполнения программы разместить четыре числа типа

double
в свободной памяти и вернуть указатель на первое из них. Этот указатель используется для инициализации переменной
p
. Схематически это выглядит следующим образом.



Оператор

new
возвращает указатель на объект, который он создал. Если оператор
new
создал несколько объектов (массив), то он возвращает указатель на первый из этих массивов. Если этот объект имеет тип
X
, то указатель, возвращаемый оператором
new
, имеет тип
X*
. Рассмотрим пример.


char* q = new double[4]; // ошибка: указатель double*

              // присваивается char*


Данный оператор new возвращает указатель на переменную типа

double
, но тип
double
отличается от типа
char
, поэтому мы не должны (и не можем) присвоить указатель на переменную типа
double
указателю на переменную типа
char
.

17.4.1. Размещение в свободной памяти

Оператор

new
выполняет выделение (allocation) свободной памяти (free store).

• Оператор

new
возвращает указатель на выделенную память.

• Значением указателя является адрес на первый байт выделенной памяти.

• Указатель ссылается на объект указанного типа.

• Указатель не знает, на какое количество элементов он ссылается.


Оператор

new
может выделять память как для отдельных элементов, так и для последовательности элементов. Рассмотрим пример.


int* pi = new int;      // выделяем память для одной переменной int

int* qi = new int[4];     // выделяем память для четырех переменных int

                // (массив)

double* pd = new double;   // выделяем память для одной переменной

               // double

double* qd = new double[n]; // выделяем память для n переменных

              // double


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

n
равно
2
, то произойдет следующее.



Указатели на объекты разных типов имеют разные типы. Рассмотрим пример.


pi = pd; // ошибка: нельзя присвоить указатель double* указателю int*

pd = pi; // ошибка: нельзя присвоить указатель int* указателю double*


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

int
переменной типа
double
, и наоборот. Причина заключается в операторе
[]
. Для того чтобы найти элемент, он использует информацию о размере его типа. Например, элемент
qi[2]
находится на расстоянии, равном двум размерам типа
int
от элемента
qi[0]
, а элемент
qd[2]
находится на расстоянии, равном двум размерам типа
double
от элемента
qd[0]
. Если размер типа
int
отличается от размера типа
double
, как во многих компьютерах, то, разрешив указателю
qi
ссылаться на память, выделенную для адресации указателем
qd
, можем получить довольно странные результаты.

Это объяснение с практической точки зрения. С теоретической точки зрения ответ таков: присваивание друг другу указателей на разные типы сделало бы возможными ошибки типа (type errors).

17.4.2. Доступ с помощью указателей

Кроме оператора разыменования

*
, к указателю можно применять оператор индексирования
[]
. Рассмотрим пример.


double* p = new double[4]; // выделяем память для четырех переменных

              // типа double в свободной памяти

double x = *p;       // читаем (первый) объект, на который

               // ссылается p

double y = p[2];      // читаем третий объект, на который

               // ссылается p


Так же как и в классе

vector
, оператор индексирования начинает отсчет от нуля. Это значит, что выражение
p[2]
ссылается на третий элемент;
p[0]
— это первый элемент, поэтому
p[0]
означает то же самое, что и
*p
. Операторы
[]
и
*
можно также использовать для записи.


*p = 7.7;  // записываем число в (первый) объект, на который

      // ссылается p

p[2] = 9.9; // записываем число в третий объект, на который

       // ссылается p


Указатель ссылается на объект, расположенный в памяти. Оператор разыменования (“contents of” operator, or dereference operator) позволяет читать и записывать объект, на который ссылается указатель

p
.


double x = *p; // читаем объект, на который ссылается указатель p

*p = 8.9;    // записываем объект, на который ссылается указатель p


Когда оператор

[]
применяется к указателю
p
, он интерпретирует память как последовательность объектов (имеющих тип, указанный в объявлении указателя), на первый из который ссылается указатель
p
.


double x = p[3]; // читаем четвертый объект, на который ссылается p

p[3] = 4.4;    // записываем четвертый объект, на который

         // ссылается p

double y = p[0]; // p[0] - то же самое, что и *p


Вот и все. Здесь нет никаких проверок, никакой тонкой реализации — простой доступ к памяти.



Именно такой простой и оптимально эффективный механизм доступа к памяти нам нужен для реализации класса

vector
.

17.4.3. Диапазоны

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


double* pd = new double[3];

pd[2] = 2.2;

pd[4] = 4.4;

pd[– 3] = – 3.3;


Может ли указатель

pd
ссылаться на третий элемент
pd[2]
? Может ли он ссылаться на пятый элемент
pd[4]
? Если мы посмотрим на определение указателя
pd
, то ответим “да” и “нет” соответственно. Однако компилятор об этом не знает; он не отслеживает значения указателя. Наш код просто обращается к памяти так, будто она распределена правильно. Компилятор даже не возразит против выражения
pd[–3]
, как будто можно разместить три числа типа
double
перед элементом, на который ссылается указатель
pd
.



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

pd[–3]
и
pd[4]
. Однако мы знаем, что они не могут использоваться как часть нашего массива, в котором хранятся три числа типа
double
, на которые ссылается указатель
pd
. Вероятнее всего, они являются частью других объектов, и мы просто заблудились. Это плохо. Это катастрофически плохо. Здесь слово “катастрофически” означает либо “моя программа почему-то завершилась аварийно”, либо “моя программа выдает неправильные ответы”. Попытайтесь произнести это вслух; звучит ужасно. Нужно очень многое сделать, чтобы избежать подобных фраз. Выход за пределы допустимого диапазона представляет собой особенно ужасную ошибку, поскольку очевидно, что при этом опасности подвергаются данные, не имеющие отношения к нашей программе. Считывая содержимое ячейки памяти, находящегося за пределами допустимого диапазона, получаем случайное число, которое может быть результатом совершенно других вычислений. Записывая в ячейку памяти, находящуюся за пределами допустимого диапазона, можем перевести какой-то объект в “невозможное” состояние или просто получить совершенно неожиданное и неправильное значение. Такие действия, как правило, остаются незамеченными достаточно долго, поэтому их особенно трудно выявить. Что еще хуже: дважды выполняя программу, в которой происходит выход за пределы допустимого диапазона, с немного разными входными данными, мы можем прийти к совершенно разным результатам. Ошибки такого рода (неустойчивые ошибки) выявить труднее всего.

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

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

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

double*
другому указателю
double*
независимо от количества элементов, на которые они ссылаются. Указатель действительно не знает, на сколько элементов он ссылается. Рассмотрим пример.


double* p = new double;    // разместить переменную типа double

double* q = new double[1000]; // разместить тысячи переменных double


q[700] = 7.7;   // отлично

q = p;       // пусть указатель q ссылается на то же, что и p

double d = q[700]; // выход за пределы допустимого диапазона!


Здесь всего три строки кода, в которых выражение

q[700]
ссылается на две разные ячейки памяти, причем во втором случае происходит опасный выход за пределы допустимого диапазона.



Теперь мы надеемся, что вы спросите: “А почему указатель не может помнить размер памяти?” Очевидно, что можно было бы разработать указатель, который помнил бы, на какое количество элементов он ссылается, — в классе

vector
это сделано почти так. А если вы прочитаете книги, посвященные языку С++, и просмотрите его библиотеки, то обнаружите множество “интеллектуальных указателей”, компенсирующих этот недостаток встроенных низкоуровневых указателей. Однако в некоторых ситуациях нам нужен низкоуровневый доступ и понимание механизма адресации объектов, а машина не знает, что она адресует. Кроме того, знание механизма работы указателей важно для понимания огромного количества уже написанных программ.

17.4.4. Инициализация

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


double* p0;           // объявление без инициализации:

                // возможны проблемы

double* p1 = new double;    // выделение памяти для переменной

                // типа double

                // без инициализации

double* p2 = new double(5.5); // инициализируем переменную типа 
double

                // числом 5.5

double* p3 = new double[5];  // выделение памяти для массива

                // из пяти чисел

                // типа double без инициализации


Очевидно, что объявление указателя

p0
без инициализации может вызвать проблемы. Рассмотрим пример.


*p0 = 7.0;


Эта инструкция записывает число

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

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

Память, выделенная оператором new встроенных типов, не инициализируется. Если хотите инициализировать указатель, задайте конкретное значение, как это было сделано при объявлении указателя

p2: *p2
равно
5.5
. Обратите внимание на круглые скобки,
()
, используемые при инициализации. Не перепутайте их с квадратными скобками,
[]
, которые используются для индикации массивов.

В языке С++ нет средства для инициализации массивов объектов встроенных типов, память для которых выделена оператором

new
. Для массивов работу придется проделать самостоятельно. Рассмотрим пример.


double* p4 = new double[5];

for (int i = 0; i<5; ++i) p4[i] = i;


Теперь указатель

p4
ссылается на объекты типа
double
, содержащие числа
0.0
,
1.0
,
2.0
,
3.0
и
4.0
.

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

X
имеет конструктор по умолчанию, то получим следующее:


X* px1 = new X;   // один объект класса Х, инициализированный

           // по умолчанию

X* px2 = new X[17]; // 17 объектов класса Х, инициализированных

           // по умолчанию


Если класс

Y
имеет конструктор, но не конструктор по умолчанию, мы должны выполнить явную инициализацию


Y* py1 = new Y;   // ошибка: нет конструктора по умолчанию

Y* py2 = new Y[17]; // ошибка: нет конструктора по умолчанию

Y* py3 = new Y(13); // OK: инициализирован адресом объекта Y(13)

17.4.5. Нулевой указатель

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

0
(нуль).


double* p0 = 0; // нулевой указатель


Указатель, которому присвоен нуль, называют нулевым (null pointer). Корректность указателя (т.е. ссылается ли он на что-либо) часто проверяется с помощью сравнения его с нулем. Рассмотрим пример.


if (p0 != 0) // проверка корректности указателя p0


Этот тест неидеален, поскольку указатель

p0
может содержать случайное ненулевое значение или адрес объекта, который был удален с помощью оператора
delete
(подробнее об этом — в разделе 17.4.6). Однако такая проверка часто оказывается лучшим, что можно сделать. Мы не обязаны явно указывать нуль, поскольку инструкция
if
по умолчанию проверяет, является ли условие ненулевым.


if (p0) // проверка корректности указателя p0; эквивалентно p0!=0


Мы предпочитаем более короткую форму проверки, полагая, что она точнее отражает смысл выражения “

p0
корректен”, но это дело вкуса.

Нулевой указатель следует использовать тогда, когда некий указатель то ссылается на какой-нибудь объект, то нет. Эта ситуация встречается реже, чем можно себе представить; подумайте: если у вас нет объекта, на который можно установить указатель, то зачем вам определять сам указатель? Почему бы не подождать, пока не будет создан объект?

17.4.6. Освобождение свободной памяти

Оператор

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


double* calc(int res_size, int max) // утечка памяти

{

 double* p = new double[max];

 double* res = new double[res_size];

 // используем указатель p для вычисления результатов

 // и записи их в массив res

 return res;

}


double* r = calc(100,1000);


В соответствии с этой программой каждый вызов функции

calc()
будет забирать из свободной памяти участок, размер которого равен размеру типа
double
, и присваивать его адрес указателю
p
. Например, вызов
calc(100,1000)
сделает недоступным для остальной части программы участок памяти, на котором могут разместиться тысяча переменных типа
double
.

Оператор, возвращающий освобождающую память, называется

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


double* calc(int res_size, int max)

 // за использование памяти, выделенной для массива res,

 // несет ответственность вызывающий модуль

{

 double* p = new double[max];

 double* res = new double[res_size];

 // используем указатель p для вычисления результатов и их

 // записи в res

 delete[ ] p; // эта память больше не нужна: освобождаем ее

 return res;

}


 double* r = calc(100,1000);

 // используем указатель r

 delete[ ] r; // эта память больше не нужна: освобождаем ее


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

Оператор

delete
имеет две формы:

delete p
освобождает память, выделенную с помощью оператора
new
для отдельного объекта;

delete[] p
освобождает память, выделенную с помощью оператора
new
для массива объектов.


Выбор правильного варианта должен сделать программист.

Двойное удаление объекта — очень грубая ошибка. Рассмотрим пример.


int* p = new int(5);

delete p; // отлично: p ссылается на объект, созданный оператором new

      // ...указатель здесь больше не используется...

delete p; // ошибка: p ссылается на память, принадлежащую диспетчеру

      // свободной памяти


Вторая инструкция

delete p
порождает две проблемы.

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

delete p
правильно во второй раз было невозможно.

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

p
, так что теперь указатель
p
ссылается на другой объект; удаление этого объекта (принадлежащего другой части программы) может вызвать ошибку.


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

Удаление нулевого указателя не приводит ни к каким последствиям (так как нулевой указатель не ссылается ни на один объект), поэтому эта операция безвредна. Рассмотрим пример.


int* p = 0;

delete p; // отлично: никаких действий не нужно

delete p; // тоже хорошо (по-прежнему ничего делать не нужно)


Зачем возиться с освобождением памяти? Разве компилятор сам не может понять, когда память нам больше не нужна, и освободить ее без вмешательства человека? Может. Такой механизм называется автоматической сборкой мусора (automatic garbage collection) или просто сборкой мусора (garbage collection). К сожалению, автоматическая сборка мусора недешевое удовольствие и не идеально подходит ко всем приложениям. Если вам действительно нужна автоматическая сборка мусора, можете встроить этот механизм в свою программу. Хорошие сборщики мусора доступны по адресу www.research.att.com/~bs/C++.html. Однако в этой книге мы предполагаем, что читатели сами разберутся со своим “мусором”, а мы покажем, как это сделать удобно и эффективно.

Почему следует избегать утечки памяти? Программа, которая должна работать бесконечно, не должна допускать никаких утечек памяти. Примером таких программ является операционная система, а также большинство встроенных систем (о них речь пойдет в главе 25). Библиотеки также не должны допускать утечек памяти, поскольку кто-нибудь может использовать эти библиотеки как часть системы, работающей бесконечно. В общем, утечек памяти следует избегать, и все тут. Многие программисты рассматривают утечки памяти как проявление неряшливости. Однако эта точка зрения кажется нам слишком категоричной. Если программа выполняется под управлением операционной системы (Unix, Windows или какой-нибудь еще), то после завершения работы программы вся память будет автоматически возвращена системе. Отсюда следует, что если вам известно, что ваша программа не будет использовать больше памяти, чем ей доступно, то вполне можно допустить утечку, пока операционная система сама не восстановит порядок. Тем не менее, если вы решитесь на это, то надо быть уверенным в том, что ваша оценка объема используемой памяти является правильной, иначе вас сочтут неряхой.

17.5. Деструкторы

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


// очень упрощенный вектор, содержащий числа типа double

class vector {

 int sz;    // размер

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

public:

 vector(int s)     // конструктор

 :sz(s),        // инициализация члена sz

 elem(new double[s]) // инициализация члена elem

 {

   for (int i=0; i

                    // элементов

  }

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

  // ...

};


Итак, член

sz
хранит количество элементов. Мы инициализируем его в конструкторе, а пользователь класса
vector
может выяснить количество элементов, вызвав функцию
size()
. Память для элементов выделяется в конструкторе с помощью оператора
new
, а указатель, возвращенный оператором
new
, хранится в члене
elem
.

Обратите внимание на то, что мы инициализируем элементы их значением по умолчанию (

0.0
). Класс
vector
из стандартной библиотеки делает именно так, поэтому мы решили сделать так же с самого начала.

К сожалению, наш примитивный класс

vector
допускает утечку памяти. В конструкторе он выделяет память для элементов с помощью оператора
new
. Следуя правилу, сформулированному в разделе 17.4, мы должны освободить эту память с помощью оператора
delete
. Рассмотрим пример.


void f(int n)

{

 vector v(n); // выделяем память для n чисел типа double

 // ...

}


После выхода из функции

f()
элементы вектора
v
, созданные в свободной памяти, не удаляются. Мы могли бы определить функцию
clean_up()
— член класса
vector
и вызвать ее следующим образом:


void f2(int n)

{

 vector v(n);  // определяем вектор,

         // выделяющий память для других n переменных

         // типа int

         // ...используем вектор v...

 v.clean_up(); // функция clean_up() удаляет член elem

}


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

clean_up()
; люди просто забудут ее вызвать. Мы можем предложить более удачное решение. Основная идея состоит в том, чтобы компилятор знал не только о конструкторе, но и о функции, играющей роль, противоположную конструктору. Такую функцию логично назвать деструктором (destructor). Точно так же как конструктор неявно вызывается при создании объекта класса, деструктор неявно вызывается, когда объект выходит за пределы области видимости. Конструктор гарантирует, что объект будет правильно создан и проинициализирован. Деструктор, наоборот, гарантирует, что объект будет правильно очищен перед тем, как будет уничтожен. Рассмотрим пример.


// очень упрощенный вектор, содержащий числа типа double

class vector {

 int sz; // размер

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

public:

 vector(int s) // конструктор

 :sz(s), elem(new double[s]) // выделяет память

  {

   for (int i=0; i

  // элементы

  }

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

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

  // ...

};


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


void f3(int n)

{

 double* p = new double[n]; // выделяем память для n

               // чисел типа double

 vector v(n);        // определяем вектор (выделяем память

                // для других n чисел типа double)

               // ...используем p и v...

 delete[ ] p;        // освобождаем память, занятую массивом

                // чисел типа double

}              // класс vector автоматически освободит

               // память, занятую объектом v


Оказывается, оператор

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

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

iostream
? Они очищали буферы, закрывали файлы, освобождали память и т.д. Все это делали их деструкторы. Каждый класс, “владеющий” какими-то ресурсами, должен иметь деструктор.

17.5.1. Обобщенные указатели

Если член класса имеет деструктор, то этот деструктор будет вызываться при уничтожении объекта, содержащего этот член. Рассмотрим пример.


struct Customer {

 string name;

 vector addresses;

 // ...

};


void some_fct()

{

 Customer fred;

 // инициализация объекта fred

 // использование объекта fred

}


Когда мы выйдем из функции

some_fct()
и объект
fred
покинет свою область видимости, он будет уничтожен; иначе говоря, будут вызваны деструкторы для строки name и вектора
addresses
. Это совершенно необходимо, поскольку иначе могут возникнуть проблемы. Иногда это выражают таким образом: компилятор сгенерировал деструктор для класса
Customer
, который вызывает деструкторы членов. Такая генерация выполняется компилятором часто и позволяет гарантированно вызывать деструкторы членов класса.

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

delete
и т.д.).

17.5.2. Деструкторы и свободная память

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

• Если функции в качестве ресурса требуется какой-то объект, она обращается к конструктору.

• На протяжении своего срока существования объект может освобождать ресурсы и запрашивать новые.

• В конце существования объекта деструктор освобождает все ресурсы, которыми владел объект.


Типичным примером является пара “конструктор–деструктор” в классе

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


Shape* fct()

{

 Text tt(Point(200,200),"Annemarie");

 // ...

 Shape* p = new Text(Point(100,100),"Nicholas");

 return p;

}


void f()

{

 Shape* q = fct();

 // ...

 delete q;

}


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

fct()
объект
tt
класса
Text
(см. раздел 3.11), существующий в ней, уничтожается вполне корректно. Класс
Text
имеет член типа
string
, у которого обязательно нужно вызвать деструктор, — класс
string
занимает и освобождает память примерно так же, как и класс
vector
. Для объекта
tt
это просто; компилятор вызывает сгенерированный деструктор класса
Text
, как описано в разделе 17.5.1. А что можно сказать об объекте класса
Text
возвращаемом функцией
fct()
? Вызывающая функция
f()
понятия не имеет о том, что указатель
q
ссылается на объект класса
Text
; ей известно лишь, что он ссылается на объект класса
Shape
. Как же инструкция
delete q
сможет вызвать деструктор класса
Text
?

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

Shape
имеет деструктор. Фактически в классе
Shape
есть виртуальный деструктор. В этом все дело. Когда мы выполняем инструкцию
delete q
, оператор
delete
анализирует тип указателя
q
, чтобы увидеть, нужно ли вызывать деструктор, и при необходимости он его вызывает. Итак, инструкция
delete q
вызывает деструктор
~Shape()
класса
Shape
. Однако деструктор
~Shape()
является виртуальным, поэтому с помощью механизма вызова виртуальной функции (см. раздел 17.3.1) он вызывает деструктор класса, производного от класса
Shape
, в данном случае деструктор
~Text()
. Если бы деструктор
Shape::~Shape()
не был виртуальным, то деструктор
Text::~Text()
не был бы вызван и член класса
Text
, имеющий тип
string
, не был бы правильно уничтожен.

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

1. Если класс имеет виртуальную функцию, то, скорее всего, он будет использован в качестве базового.

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

new
.

3. Если объект производного класса размещается в памяти с помощью оператора

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


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

delete
. Они никогда не вызываются непосредственно. Это позволяет избежать довольно трудоемкой работы.


ПОПРОБУЙТЕ

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

17.6. Доступ к элементам

Для того чтобы нам было удобно работать с классом

vector
, нужно читать и записывать элементы. Для начала рассмотрим простые функции-члены
get()
и
set()
.


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

class vector {

 int sz;    // размер

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

public:

 vector(int s):

 sz(s), elem(new double[s]) { /* */} // конструктор

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

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

                    // размер


 double get(int n) const { return elem[n]; } // доступ: чтение

 void set(int n, double v) { elem[n]=v; }   // доступ: запись

};


Функции

get()
и
set()
обеспечивают доступ к элементам, применяя оператор
[]
к указателю
elem
.

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

double
, и использовать его.


vector v(5);

for (int i=0; i

 v.set(i,1.1*i);

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

}


Результаты выглядят так:


v[0]==0

v[1]==1.1

v[2]==2.2

v[3]==3.3

v[4]==4.4


Данный вариант класса

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

17.7. Указатели на объекты класса

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

vector
точно так же, как и указатели на переменные типа
char
.


vector* f(int s)

{

 vector* p = new vector(s); // размещаем вектор в свободной

               // памяти заполняем *p

 return p;

}


void ff()

{

 vector* q = f(4);

          // используем *q

 delete q;     // удаляем вектор из свободной памяти

}


Обратите внимание на то, что, когда мы удаляем объект класса

vector
с помощью оператора
delete
, вызывается его деструктор. Рассмотрим пример.


vector* p = new vector(s); // размещаем вектор в свободной памяти

delete p;          // удаляем вектор из свободной памяти


При создании объекта класса

vector
в свободной памяти оператор new выполняет следующие действия:

• сначала выделяет память для объекта класса

vector
;

• затем вызывает конструктор класса

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


Удаляя объект класса

vector
, оператор
delete
выполняет следующие действия:

• сначала вызывает деструктор класса

vector
; этот деструктор, в свою очередь, вызывает деструктор элементов (если они есть), а затем освобождает память, занимаемую элементами вектора;

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

vector
.


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

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


vector< vector >* p = new vector > (10);

delete p;


Здесь инструкция

delete p
вызывает деструктор класса
vector>
, а он, в свою очередь, вызывает деструктор элементов класса
vector
, и весь вектор аккуратно освобождается, ни один объект не остается не уничтоженным, и утечка памяти не возникает.

Поскольку оператор

delete
вызывает деструкторы (для типов, в которых они предусмотрены, например, таких, как
vector
), часто говорят, что он уничтожает (destroy) объекты, а не удаляет из памяти (deallocate).

Как всегда, следует помнить, что “голый” оператор new за пределами конструктора таит в себе опасность забыть об операторе

delete
. Если у вас нет хорошей стратегии удаления объектов (хотя она действительно проста, см., например, класс
Vector_ref
из разделов 13.10 и Д.4), попробуйте включить операторы
new
в конструкторы, а операторы
delete
— в деструкторы.

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

.
(точка), примененного к имени объекта.


vector v(4);

int x = v.size();

double d = v.get(3);


Аналогично все классы поддерживают работу оператора

–>
(стрелка) для доступа к своим членам с помощью указателя на объект.


vector* p = new vector(4);

int x = p–>size();

double d = p–>get(3);


Как и операторы

.
(точка), оператор
–>
(стрелка) можно использовать для доступа к данным-членам и функциям-членам. Поскольку встроенные типы, такие как
int
и
double
, не имеют членов, то оператор
–>
к ним не применяется. Операторы “точка” и “стрелка” часто называют операторами доступа к членам класса (member access operators).

17.8. Путаница с типами: void* и операторы приведения типов

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

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

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

В таких случаях нам нужны две вещи.

• Тип указателя, ссылающегося на память без учета информации о том, какие объекты в нем размещены.

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


Тип

void*
означает “указатель на ячейку памяти, тип которой компилятору неизвестен”. Он используется тогда, когда необходимо передать адрес из одной части программы в другую, причем каждая из них ничего не знает о типе объекта, с которым работает другая часть. Примерами являются адреса, служащие аргументами функций обратного вызова (см. раздел 16.3.1), а также распределители памяти самого нижнего уровня (такие как реализация оператора
new
).

Объектов типа

void
не существует, но, как мы видели, ключевое слово
void
означает “функция ничего не возвращает”.


void v;  // ошибка: объектов типа void не существует

void f(); // функция f() ничего не возвращает;

      // это не значит, что функция f() возвращает объект

      // типа void


Указателю типа

void*
можно присвоить указатель на любой объект. Рассмотрим пример.


void* pv1 = new int;    // OK: int* превращается в void*

void* pv2 = new double[10]; // OK: double* превращается в void*


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

void*
, мы должны сообщить ему об этом.


void f(void* pv)

{

 void* pv2 = pv;  // правильно (тип void* для этого

          // и предназначен)

 double* pd = pv; // ошибка: невозможно привести тип void*

          // к double*

 *pv = 7;   // ошибка: невозможно разыменовать void*

       // (тип объекта, на который ссылается указатель,

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

 pv[2] = 9;            // ошибка: void* нельзя индексировать

 int* pi = static_cast(pv); // OK: явное приведение

 // ...

}


Оператор

static_cast
позволяет явно преобразовать указатели типов в родственный тип, например
void*
в
double*
(раздел A.5.7). Имя
static_cast
— это сознательно выбранное отвратительное имя для отвратительного (и опасного) оператора, который следует использовать только в случае крайней необходимости. Его редко можно встретить в программах (если он вообще где-то используется). Операции, такие как
static_cast
, называют явным преобразованием типа (explicit type conversion), или просто приведением (cast), потому что в языке C++ предусмотрены два оператора приведения типов, которые потенциально еще хуже оператора
static_cast
.

• Оператор

reinterpret_cast
может преобразовать тип в совершенно другой, никак не связанный с ним тип, например
int
в
double*
.

• Оператор

const_cast
позволяет отбросить квалификатор
const
.


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


Register* in = reinterpret_cast(0xff);

void f(const Buffer* p)

{

 Buffer* b = const_cast(p);

 // ...

}


Первый пример — классическая ситуация, в которой необходимо применить оператор

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



Во втором примере оператор

const_cast
аннулирует квалификатор
const
в объявлении
const Buffer*
указателя
p
. Разумеется, мы понимали, что делали.

По крайней мере, оператор

static_cast
не позволяет преобразовать указатели в целые числа или аннулировать квалификатор
const
, поэтому при необходимости привести один тип к другому следует предпочесть оператор
static_cast
. Если вы пришли к выводу, что вам необходимо приведение типов, подумайте еще раз: нельзя ли написать программу иначе, без приведения типов? Можно ли переписать программу так, чтобы приведение типов стало ненужным? Если вам не приходится использовать код, написанный другими людьми, или управлять аппаратным обеспечением, то, безусловно, можно и нужно обойтись без оператора
static_cast
. В противном случае могут возникнуть трудноуловимые и опасные ошибки. Если вы используете оператор
reinterpret_cast
, то не следует ожидать, что ваша программа будет без проблем работать на другом компьютере.

17.9. Указатели и ссылки

Ссылку (reference) можно интерпретировать как автоматически разыменовываемый постоянный указатель или альтернативное имя объекта. Указатели и ссылки отличаются следующими особенностями.

• Присвоение чего-либо указателю изменяет значение указателя, а не объекта, на который он установлен.

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

new
или
&
.

• Для доступа к объекту, на который установлен указатель, используются операторы

*
и
[]
.

• Присвоение ссылке нового значения изменяет значение объекта, на который она ссылается, а не саму ссылку.

• После инициализации ссылку невозможно установить на другой объект.

• Присвоение ссылок основано на глубоком копировании (новое значение присваивается объекту, на который указывает ссылка); присвоение указателей не использует глубокое копирование (новое значение присваивается указателю, а не объекту).

• Нулевые указатели представляют опасность.


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


int x = 10;

int* p = &x;  // для получения указателя нужен оператор &

*p = 7;     // для присвоения значения переменной x

        // через указатель p используется *

int x2 = *p;  // считываем переменную x с помощью указателя p

int* p2 = &x2; // получаем указатель на другую переменную

        // типа int

p2 = p;     // указатели p2 и p ссылаются на переменную x

p = &x2;    // указатель p ссылается на другой объект


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


int y = 10;

int& r = y;  // символ & означает тип, а не инициализатор

r = 7;     // присвоение значения переменной y

        // с помощью ссылки r (оператор * не нужен)

int y2 = r;  // считываем переменную y с помощью ссылки r

       // (оператор * не нужен)

int& r2 = y2; // ссылка на другую переменную типа int

r2 = r;    // значение переменной y присваивается

        // переменной y2

r = &y2;    // ошибка: нельзя изменить значение ссылки

        // (нельзя присвоить переменную int* ссылке int&)


Обратите внимание на последний пример; это значит не только то, что эта конструкция неработоспособна, — после инициализации невозможно связать ссылку с другим объектом. Если вам нужно указать на другой объект, используйте указатель. Использование указателей описано в разделе 17.9.3.

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

17.9.1. Указатели и ссылки как параметры функций

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


int incr_v(int x) { return x+1; } // вычисляет и возвращает новое

                  // значение

void incr_p(int* p) { ++*p; }   // передает указатель

                  // (разыменовывает его

                  // и увеличивает значение

                 // на единицу)

void incr_r(int& r) { ++r; }    // передает ссылку


Какой выбор вы сделаете? Скорее всего, выберете возвращение значения (которое наиболее уязвимо к ошибкам).


int x = 2;

x = incr_v(x); // копируем x в incr_v(); затем копируем результат

         // и присваиваем его вновь


Этот стиль предпочтительнее для небольших объектов, таких как переменные типа

int
. Однако передача значений туда и обратно не всегда реальна. Например, можно написать функцию, модифицирующую огромную структуру данных, такую как вектор, содержащий 10 тыс. переменных типа
int
; мы не можем копировать эти 40 тыс. байтов (как минимум, вдвое) с достаточной эффективностью.

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

Использование передачи аргумента с помощью ссылок предостерегает программиста о том, что значение может измениться. Рассмотрим пример.


int x = 7;

incr_p(&x); // здесь необходим оператор &

incr_r(x);


Необходимость использования оператора

&
в вызове функции
incr_p(&x)
обусловлена тем, что пользователь должен знать о том, что переменная
x
может измениться. В противоположность этому вызов функции
incr_r(x)
“выглядит невинно”. Это свидетельствует о небольшом преимуществе передачи указателя.

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


incr_p(0); // крах: функция incr_p() пытается разыменовать нуль

int* p = 0;

incr_p(p); // крах: функция incr_p() пытается разыменовать нуль


Совершенно очевидно, что это ужасно. Человек, написавший функцию,

incr_p()
, может предусмотреть защиту.


void incr_p(int* p)

{

 if (p==0) error("Функции incr_p() передан нулевой указатель");

 ++*p;   // разыменовываем указатель и увеличиваем на единицу

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

}


Теперь функция

incr_p()
выглядит проще и приятнее, чем раньше. В главе 5 было показано, как устранить проблему, связанную с некорректными аргументами. В противоположность этому пользователи, применяющие ссылки (например, в функции
incr_r()
), должны предполагать, что ссылка связана с объектом. Если “передача пустоты” (когда объект на самом деле не передается) с точки зрения семантики функции вполне допустима, аргумент следует передавать с помощью указателя. Примечание: это не относится к операции инкрементации — поскольку при условии
p==0
в этом случае следует генерировать исключение.

Итак, правильный ответ формулируется так: выбор зависит от природы функции.

• Для маленьких объектов предпочтительнее передача по значению.

• Для функций, допускающих в качестве своего аргумента “нулевой объект” (представленный значением

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

• В противном случае в качестве параметра следует использовать ссылку.


См. также раздел 8.5.6.

17.9.2. Указатели, ссылки и наследование

В разделе 14.3 мы видели, как можно использовать производный класс, такой как

Circle
, вместо объекта его открытого базового класса
Shape
. Эту идею можно выразить в терминах указателей или ссылок: указатель
Circle*
можно неявно преобразовать в указатель
Shape
, поскольку класс
Shape
является открытым базовым классом по отношению к классу
Circle
. Рассмотрим пример.


void rotate(Shape* s, int n); // поворачиваем фигуру *s на угол n

Shape* p = new Circle(Point(100,100),40);

Circle c(Point(200,200),50);

rotate(&c,45);


Это можно сделать и с помощью ссылок.


void rotate(Shape& s, int n); // поворачиваем фигуру *s на угол n

Shape& r = c;

rotate(c,75);


Этот факт является чрезвычайно важным для большинства объектно-ориентированных технологий программирования (см. разделы 14.3, 14.4).

17.9.3. Пример: списки

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



Такой список называют двусвязным (doubly-linked list), поскольку в нем существуют предшествующий и последующий узлы. Список, в котором существуют только последующие узлы, называют односвязным (singly-linked list). Мы используем двусвязные узлы, когда хотим облегчить удаление элемента. Узлы списка определяются следующим образом:


struct Link {

 string value;

 Link* prev;

 Link* succ;

 Link(const string& v,Link* p = 0,Link* s = 0)

    :value(v),prev(p),succ(s) { }

};


Иначе говоря, имея объект типа

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


Link* norse_gods = new Link("Thor",0,0);

norse_gods = new Link("Odin",0,norse_gods);

norse_gods–>succ–>prev = norse_gods;

norse_gods = new Link("Freia",0,norse_gods);

norse_gods–>succ–>prev = norse_gods;


Мы создали этот список с помощью структуры

Link
: во главе списка находится
Тор
, за ним следует Один, являющийся предшественником Тора, а завершает список Фрея — предшественница Одина. Следуя за указателями. можете убедиться, что мы правы и каждый указатель
succ
и
prev
ссылается на правильного бога. Однако этот код мало понятен, так как мы не определили явно и не присвоили имя операции вставки.


Link* insert(Link* p, Link* n) // вставка n перед p ( фрагмент )

{

 n–>succ = p;    // p следует после n

 p–>prev–>succ = n; // n следует после предшественника p

 n–>prev = p–>prev; // предшественник p становится

           // предшественником n

 p–>prev = n;    // n становится предшественником p

  return n;

}


Этот фрагмент программы работает, если указатель

p
действительно ссылается на объект типа
Link
и этот объект действительно имеет предшественника. Убедитесь, что это именно так. Размышляя об указателях и связанных структурах, таких как список, состоящий из объектов типа
Link
, мы практически всегда рисуем на бумаге диаграммы, состоящие из прямоугольников и стрелок, чтобы проверить программу на небольших примерах. Пожалуйста, не пренебрегайте этим эффективным средством.

Приведенная версия функции

insert()
неполна, поскольку в ней не предусмотрен случай, когда указатели
n
,
p
или
p–>prev
равны
0
. Добавив соответствующую проверку, мы получим немного более сложный, но зато правильный вариант функции
insert
.


Link* insert(Link* p, Link* n) // вставляет n перед p; возвращает n

{

 if (n==0) return p;

 if (p==0) return n;

 n–>succ = p;     // p следует после n

 if (p–>prev) p–>prev–>succ = n;

 n–>prev = p–>prev;  // предшественник p становится

            // предшественником n

 p–>prev = n;     // n становится предшественником p

 return n;

}


В этом случае мы можем написать такой код:


Link* norse_gods = new Link("Thor");

norse_gods = insert(norse_gods,new Link("Odin"));

norse_gods = insert(norse_gods,new Link("Freia"));


Теперь все возможные неприятности, связанные с указателями

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

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

17.9.4. Операции над списками

Стандартная библиотека содержит класс

list
, который будет описан в разделе 20.4. В нем реализованы все необходимые операции, но в данном разделе мы самостоятельно разработаем список, основанный на классе
Link
, чтобы узнать, что скрывается “под оболочкой” стандартного списка, и продемонстрировать еще несколько примеров использования указателей.

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

• Конструктор.

insert
: вставка перед элементом.

add
: вставка после элемента.

erase
: удаление элемента.

find
: поиск узла с заданным значением.

advance
: переход к n-му последующему узлу.


Эти операции можно написать следующим образом:


Link* add(Link* p,Link* n) // вставляет n после p; возвращает n

{

 // напоминает insert (см. упр. 11)

}


Link* erase(Link* p) // удаляет узел *p из списка; возвращает

            // следующий за p

{

 if (p==0) return 0;

  if (p–>succ) p–>succ–>prev = p–>prev;

 if (p–>prev) p–>prev–>succ = p–>succ;

  return p–>succ;

}


Link* find(Link* p,const string& s) // находит s в списке;

                   // если не находит, возвращает 0

{

 while(p) {

   if (p–>value == s) return p;

  p = p–>succ;

  }

  return 0;

}


Link* advance(Link* p,int n) // удаляет n позиций из списка

 // если не находит, возвращает 0

 // при положительном n переносит указатель на n узлов вперед,

 // при отрицательном — на n узлов назад

{

 if (p==0) return 0;

 if (0

   while (n––) {

    if (p–>succ == 0) return 0;

    p = p–>succ;

   }

  }

 else if (n<0) {

   while (n++) {

    if (p–>prev == 0) return 0;

    p = p–>prev;

   }

  }

  return p;

}


Обратите внимание на использование постфиксной инкрементации

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

17.9.5. Использование списков

В качестве небольшого примера создадим два списка


Link* norse_gods = new Link("Thor");

norse_gods = insert(norse_gods,new Link("Odin"));

norse_gods = insert(norse_gods,new Link("Zeus"));

norse_gods = insert(norse_gods,new Link("Freia"));


Link* greek_gods = new Link("Hera");

greek_gods = insert(greek_gods,new Link("Athena"));

greek_gods = insert(greek_gods,new Link("Mars"));

greek_gods = insert(greek_gods,new Link("Poseidon"));


К сожалению, мы наделали много ошибок: Зевс — греческий бог, а не норвежский, греческий бог войны — Арес, а не Марс (Марс — это его римское имя). Эти ошибки можно исправить следующим образом:


Link* p = find(greek_gods, "Mars");

if (p) p–>value = "Ares";


Обратите внимание на то, что мы проверяем, возвращает ли функция

find()
значение
0
. Мы, конечно, уверены, что этого не может быть (в конце концов, мы только что вставили имя Марса в список
greek_gods
), но в реальности что-то могло произойти не так, как ожидалось.

Аналогично можем перенести Зевса в правильный список греческих богов.


Link* p = find(norse_gods,"Zeus");

if (p) {

 erase(p);

 insert(greek_gods,p);

}


Вы заметили ошибку? Она довольно тонкая (конечно, если вы не работаете со списками непосредственно). Что, если на опустошенный с помощью функции

erase()
узел ссылался один из узлов списка
norse_gods
? Разумеется, на самом деле этого не было, но в жизни бывает всякое, и хорошая программа должна это учитывать.


Link* p = find(norse_gods, "Zeus");

if (p) {

  if (p==norse_gods) norse_gods = p–>succ;

 erase(p);

  greek_gods = insert(greek_gods,p);

}


Заодно мы исправили и вторую ошибку: вставляя Зевса перед первым греческим богом, мы должны установить на него указатель списка. Указатели — чрезвычайно полезный и гибкий, но очень тонкий инструмент. В заключение распечатаем наш список.


void print_all(Link* p)

{

 cout << "{ ";

 while (p) {

   cout << p–>value;

   if (p=p–>succ) cout << ", ";

 }

  cout << " }";

}


print_all(norse_gods);

cout<<"\n";

print_all(greek_gods);

cout<<"\n";


Результат должен быть следующим:


{ Freia, Odin, Thor }

{ Zeus, Poseidon, Ares, Athena, Hera }

17.10. Указатель this

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

Link*
для доступа к данным, хранящимся в этом объекте. Такие функции обычно являются членами класса. Можно ли упростить класс
Link
(или использование списка), предусмотрев соответствующие члены класса?

Может быть, сделать указатели закрытыми, чтобы только функции-члены класса могли обращаться к ним? Попробуем.


class Link {

public:

 string value;

 Link(const string& v,Link* p = 0,Link* s = 0)

 :value(v), prev(p),succ(s) { }


 Link* insert(Link* n);  // вставляет n перед данным объектом

 Link* add(Link* n);   // вставляет n после данного объекта

 Link* erase();      // удаляет данный объект из списка

 Link* find(const string& s); // находит s в списке

 Link* advance(int n) const;  // удаляет n позиций

                // из списка

 Link* next() const { return succ; }

 Link* previous() const { return prev; }

private:

 Link* prev;

 Link* succ;

};


Этот фрагмент выглядит многообещающе. Мы определили операции, не изменяющие состояние объекта класса

Link
, с помощью константных функций-членов. Мы добавили (не модифицирующие) функции
next()
и
previous()
, чтобы пользователи могли перемещаться по списку, — поскольку непосредственный доступ к указателям
succ
и
prev
теперь запрещен. Мы оставили значение узла в открытом разделе класса, потому что (пока) у нас не было причины его скрывать; ведь это просто данные.

Попробуем теперь реализовать функцию

Link::insert()
, скопировав и модифицировав предыдущий вариант.


Link* Link::insert(Link* n) // вставляет n перед p; возвращает n

{

 Link* p = this;   // указатель на данный объект

 if (n==0) return p; // ничего не вставляем

 if (p==0) return n; // ничего не вставляем

 n–>succ = p;     // p следует за n

 if (p–>prev) p–>prev–>succ = n;

 n–>prev = p–>prev;  // предшественник p становится

            // предшественником n

 p–>prev = n;     // n становится предшественником p

 return n;

}


Как получить указатель на объект, для которого была вызвана функция

Link::insert()
? Без помощи языка это сделать невозможно. Однако в каждой функции-члене существует идентификатор
this
, являющийся указателем на объект, для которого она вызывается. A в качестве альтернативы мы могли бы просто писать
this
вместо
p
.


Link* Link::insert(Link* n) // вставляет n перед p; возвращает n

{

 if (n==0) return this;

 if (this==0) return n;

 n–>succ = this;    // этот объект следует за n

 if (this–>prev) this–>prev–>succ = n;

 n–>prev = this–>prev; // предшественник этого объекта

             // становится

             // предшественником объекта n

  this–>prev = n;     // n становится предшественником этого

             // объекта

  return n;

}


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

this
обеспечивает доступ к члену класса, поэтому код можно сократить.


Link* Link::insert(Link* n) // вставляет n перед p; возвращает n

{

 if (n==0) return this;

 if (this==0) return n;

 n–>succ = this;  // этот объект следует за n

 if (prev) prev–>succ = n;

 n–>prev = prev;  // предшественник этого объекта

           // становится

           // предшественником объекта n

 prev = n;     // n становится предшественником этого

           // объекта

  return n;

}


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

this
. Единственная ситуация, в которой его необходимо упомянуть явно, возникает, когда нужно сослаться на весь объект.

Обратите внимание на то, что указатель

this
имеет специфический смысл: он ссылается на объект, для которого вызывается функция-член. Он не указывает на какой-то из ранее использованных объектов. Компилятор гарантирует, что мы не сможем изменить значение указателя
this
в функции-члене. Рассмотрим пример.


struct S {

 // ...


 void mutate(S* p)

 {

   this = p; // ошибка: указатель this не допускает изменений

  // ...

  }

};

17.10.1. Еще раз об использовании списков

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


Link* norse_gods = new Link("Thor");

norse_gods = norse_gods–>insert(new Link("Odin"));

norse_gods = norse_gods–>insert(new Link("Zeus"));

norse_gods = norse_gods–>insert(new Link("Freia"));


Link* greek_gods = new Link("Hera");

greek_gods = greek_gods–>insert(new Link("Athena"));

greek_gods = greek_gods–>insert(new Link("Mars"));

greek_gods = greek_gods–>insert(new Link("Poseidon"));


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


Link* p = greek_gods–>find("Mars");

if (p) p–>value = "Ares";


Перенесем Зевса в список греческих богов.


Link* p2 = norse_gods–>find("Zeus");

if (p2) {

  if (p2==norse_gods) norse_gods = p2–>next();

  p2–>erase();

  greek_gods = greek_gods–>insert(p2);

}


И наконец, выведем список на печать.


void print_all(Link* p)

{

 cout << "{ ";

 while (p) {

   cout << p–>value;

   if (p=p–>next()) cout << ", ";

 }

 cout << " }";

}


print_all(norse_gods);

cout<<"\n";

print_all(greek_gods);

cout<<"\n";


В итоге получим следующий результат:


{ Freia, Odin, Thor }

{ Zeus, Poseidon, Ares, Athena, Hera }


Какая из этих версий лучше: та, в которой функция

insert()
и другие являются функциями-членами, или та, в которой они не принадлежат классу? В данном случае это не имеет значения, но вспомните, что было написано в разделе 9.7.5.

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

List
, но структура класса, продемонстрированная выше, является общепринятой. Стандартный класс
list
рассматривается в разделе 20.4.


Задание

Это задание состоит из двух частей. Первые упражнения должны дать вам представление о динамических массивах и их отличии от класса

vector
.

1. Разместите в свободной памяти массив, состоящий из десяти чисел типа

int
, используя оператор
new
.

2. Выведите в поток

cout
значения десяти чисел типа
int
.

3. Освободите память, занятую массивом (используя оператор

delete[]
).

4. Напишите функцию

print_array10(ostream& os, int* a)
, выводящую в поток
os
значения из массива
a
(содержащего десять элементов).

5. Разместите в свободной памяти массив, состоящий из десяти чисел типа

int
; инициализируйте его значениями 100, 101, 102 и т.д.; выведите эти значения на печать.

6. Разместите в свободной памяти массив, состоящий из одиннадцати чисел типа

int
; инициализируйте его значениями 100, 101, 102 и т.д.; выведите эти значения на печать.

7. Напишите функцию

print_array(ostream& os, int* a, int n)
, выводящую в поток
os
значения массива
a
(содержащего n элементов).

8. Разместите в свободной памяти массив, состоящий из двадцати чисел типа

int
; инициализируйте его значениями 100, 101, 102 и т.д.; выведите эти значения на печать.

9. Вы не забыли удалить массивы? (Если забыли, сделайте это сейчас.)

10. Выполните задания 5, 6 и 8, используя класс

vector
, а не массив, и функцию
print_vector()
вместо функции
print_array()
.


Вторая часть задания посвящена указателям и их связи с массивами. Используйте функцию

print_array()
из последнего задания.

1. Разместите в свободной памяти переменную типа

int
, инициализируйте ее число 7 и присвойте ее адрес указателю
p1
.

2. Выведите на печать значения указателя

p1
и переменной типа
int
, на которую он ссылается.

3. Разместите в свободной памяти массив, состоящий из семи чисел типа

int
; инициализируйте его числами 1, 2, 4, 8 и т.д.; присвойте адрес массива указателю
p2
.

4. Выведите на печать значение указателя

p2
и массив, на который он ссылается.

5. Объявите указатель типа

int*
с именем
p3
и инициализируйте его значением указателя
p2
.

6. Присвойте указатель

p1
указателю
p2
.

7. Присвойте указатель

p3
указателю
p2
.

8. Выведите на печать значения указателей

p1
и
p2
, а также то, на что они ссылаются.

9. Освободите всю память, которую использовали.

10. Разместите в свободной памяти массив, состоящий из десяти чисел типа

int
; инициализируйте их числами 1, 2, 4, 8 и т.д.; присвойте его адрес указателю
p1
.

11. Разместите в свободной памяти массив, состоящий из десяти чисел типа

int
, присвойте его адрес указателю
p2
.

12. Скопируйте значения из массива, на который ссылается указатель

p1
, в массив, на который ссылается указатель
p2
.

13. Повторите задания 10–12, используя класс

vector
, а не массив.


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

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

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

3. Что такое свободная память? Как еще ее называют? Какие операторы работают со свободной памятью?

4. Что такое оператор разыменования и зачем он нужен?

5. Что такое адрес? Как язык С++ манипулирует с адресами?

6. Какую информацию об объекте несет указатель, который на него ссылается? Какую полезную информацию он теряет?

7. На что может ссылаться указатель?

8. Что такое утечка памяти?

9. Что такое ресурс?

10. Как инициализировать указатель?

11. Что такое нулевой указатель? Зачем он нужен?

12. Когда нужен указатель (а не ссылка или именованный объект)?

13. Что такое деструктор? Когда он нужен?

14. Зачем нужен виртуальный деструктор?

15. Как вызываются деструкторы членов класса?

16. Что такое приведение типов? Когда оно необходимо?

17. Как получить доступ к члену класса с помощью указателя?

18. Что такое двусвязный список?

19. Что собой представляет переменная

this
и когда она нужна?


Термины


Упражнения

1. Какой формат вывода значений указателя в вашей реализации языка? Подсказка: не читайте документацию.

2. Сколько байтов занимают типы

int
,
double
и
bool
? Ответьте на вопрос, не используя оператор
sizeof
.

3. Напишите функцию

void to_lower(char* s)
, заменяющую все прописные символы в строке
s
в стиле языка С на их строчные эквиваленты. Например, строка “
Hello, World!
” примет вид “
hello, world!
”. Не используйте стандартные библиотечные функции. Строка в стиле языка С представляет собой массив символов, который завершается нулем, поэтому если вы обнаружите символ
0
, то это значит, что вы находитесь в конце массива.

4. Напишите функцию

char* strdup(const char*)
, копирующую строку в стиле языка C в свободную память одновременно с ее выделением. Не используйте стандартные библиотечные функции.

5. Напишите функцию

char* findx(const char* s,const char* x)
, находящую первое вхождение строки
x
в стиле языка C в строку
s
.

6. В этой главе ничего не говорилось о том, что произойдет, если, используя оператор

new
, вы выйдете за пределы памяти. Это называется исчерпанием памяти (memory exhaustion). Выясните, что случится. У вас есть две альтернативы: обратиться к документации или написать программу с бесконечным циклом, в котором постоянно происходит выделение памяти и никогда не выполняется ее освобождение. Попробуйте оба варианта. Сколько памяти вы можете использовать, пока она не исчерпается?

7. Напишите программу, считывающую символы из потока

cin
в массив, расположенный в свободной памяти. Читайте отдельные символы, пока не будет введен знак восклицания (
!
). Не используйте класс
std::string
. Не беспокойтесь об исчерпании памяти.

8. Выполните упр. 7 еще раз, но теперь считывайте символы в строку

std::string
, а не в свободную память (класс
string
знает, как использовать свободную память). 9. Как увеличивается стек: вверх (в сторону старших адресов) или вниз (в сторону младших адресов)? В каком направлении возрастает занятая память изначально (т.е. пока вы не выполнили оператор
delete
)? Напишите программу, позволяющую выяснить это.

10. Посмотрите на решение упр. 7. Может ли ввод вызвать переполнение массива; иначе говоря, можете ли вы ввести больше символов, чем выделено памяти (это серьезная ошибка)? Что произойдет, если вы введете больше символов, чем выделено памяти?

11. Завершите программу, создающую список богов, из раздела 17.10.1 и выполните ее.

12. Зачем нужны две версии функции

find()
?

13. Модифицируйте класс

Link
из раздела 17.10.1, чтобы он хранил значение типа
struct God
. Класс
God
должен иметь члены типа
string
: имя, мифология, транспортное средство и оружие. Например,
God("Зевс", "Греция", "", "молния") and God("Один", "Норвегия", "Восьминогий летающий конь по имени Слейпнер", "")
. Напишите программу
print_all()
, выводящую имена богов и их атрибуты построчно. Добавьте функцию-член
add_ordered()
, размещающую новый элемент с помощью оператора
new
в правильной лексикографической позиции. Используя объекты класса
Link
со значениями типа
God
, составьте список богов из трех мифологий; затем переместите элементы (богов) из этого списка в три лексикографически упорядоченных списка — по одному на каждую мифологию.

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

prev
из класса
Link
? Какие причины могли бы нас заставить это сделать? В каких ситуациях разумно использовать односвязные списки? Переделайте этот пример с помощью односвязного списка.


Послесловие

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

vector
? Один из ответов состоит в том, что кто-то же написал класс
vector
и аналогичные абстракции, поэтому нам важно знать, как это можно сделать. Существуют языки программирования, не содержащие указателей и не имеющие проблем, связанных с низкоуровневым программированием. По существу, программисты, работающие на таких языках, перепоручают решение задач, связанных с непосредственным доступом к аппаратному обеспечению, программистам, работающим на языке C++ (или на других языках, допускающих низкоуровневое программирование). Однако нам кажется, что главная причина заключается в том, что невозможно понять компьютер и программирование, не зная, как программа взаимодействует с физическими устройствами. Люди, ничего не знающие об указателях, адресах памяти и так далее, часто имеют неверные представления о возможностях языка программирования, на которых они работают; такие заблуждения приводят к созданию программ, которые “почему-то не работают”.

Глава 18 Векторы и массивы

“Покупатель, будь бдителен!”

Полезный совет


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

18.1. Введение

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

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

• Объект в памяти имеет фиксированный размер.

• Объект в памяти занимает конкретное место.

• Компьютер предоставляет только самые необходимые операции над объектами (например, копирование слова, сложение двух слов и т.д.).


По существу, эти ограничения относятся к встроенным типам и операциям языка С++ (и унаследованы от языка С; см. раздел 22.2.5 и главу 27). В главе 17 мы уже ознакомились с типом

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

В этой главе мы сосредоточим свое внимание на копировании. Это важное, но скорее техническое понятие. Что мы имеем в виду, копируя нетривиальный объект? До какой степени копии являются независимыми после выполнения операции копирования? Какие виды копирования существуют? Как их указать? Как они связаны с другими фундаментальными операциями, например с инициализацией и очисткой?

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

vector
и
string
, изучим массивы и указатели, их взаимосвязь и способы применения, а также ловушки, связанные с их использованием. Это важная информация для любого программиста, вынужденного работать с низкоуровневыми кодами, написанными на языке C++ или C.

Отметим, что детали класса

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

18.2. Копирование

Рассмотрим класс

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


class vector {

 int sz; // размер

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

public:

 vector(int s) // конструктор

 :sz(s), elem(new double[s]) { /* */ } // выделяет

                     // память

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

 { delete[ ] elem; } // освобождает

 // память

 // ...

};


Попробуем скопировать один из таких векторов.


void f(int n)

{

 vector v(3);   // определяем вектор из трех элементов

 v.set(2,2.2);  // устанавливаем v[2] равным 2.2

 vector v2 = v; // что здесь происходит?

 // ...

}


Теоретически объект

v2
должен стать копией объекта
v
(т.е. оператор = создает копии); иначе говоря, для всех
i
в диапазоне
[0:v.size()]
должны выполняться условия
v2.size()==v.size()
и
v2[i]==v[i]
. Более того, при выходе из функции
f()
вся память возвращается в свободный пул. Именно это (разумеется) делает класс
vector
из стандартной библиотеки, но не наш слишком простой класс
vector
. Наша цель — улучшить наш класс
vector
, чтобы правильно решать такие задачи, но сначала попытаемся понять, как на самом деле работает наша текущая версия. Что именно она делает неправильно, как и почему? Поняв это, мы сможем устранить проблему. Еще более важно то, что мы можем распознать аналогичные проблемы, которые могут возникнуть в других ситуациях.

По умолчанию копирование относительно класса означает “скопировать все данные-члены”. Это часто имеет смысл. Например, мы копируем объект класса

Point
, копируя его координаты. Однако при копировании членов класса, являющихся указателями, возникают проблемы. В частности, для векторов в нашем примере выполняются условия
v.sz==v2.sz
и
v.elem==v2.elem
, так что наши векторы выглядят следующим образом:



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

v2
не содержит копии элементов объекта
v
; он ими владеет совместно с объектом
v
. Мы могли бы написать следующий код:


v.set(1,99);  // устанавливаем v[1] равным 99

v2.set(0,88); // устанавливаем v2[0] равным 88

cout << v.get(0) << ' ' << v2.get(1);


В результате мы получили бы вектор

88
99
. Это не то, к чему мы стремились. Если бы не существовало скрытой связи между объектами
v
и
v2
, то результат был бы равен
0
0
, поскольку мы не записывали никаких значений в ячейку
v[0]
или
v2[1]
. Вы могли бы возразить, что такое поведение является интересным, аккуратным или иногда полезным, но мы не этого ждали, и это не то, что реализовано в стандартном классе
vector
. Кроме того, когда мы вернем результат из функции
f()
, произойдет явная катастрофа. При этом неявно будут вызваны деструкторы объектов
v
и
v2
; деструктор объекта
v
освободит использованную память с помощью инструкции


delete[] elem;


И то же самое сделает деструктор объекта

v2
. Поскольку в обоих объектах,
v
и
v2
, указатель
elem
ссылается на одну ту же ячейку памяти, эта память будет освобождена дважды, что может привести к катастрофическим результатам (см. раздел 17.4.6).

18.2.1. Конструкторы копирования

Итак, что делать? Это очевидно: необходимо предусмотреть операцию копирования, которая копировала бы элементы и вызывалась при инициализации одного вектора другим. Следовательно, нам нужен конструктор, создающий копии. Такой конструктор, очевидно, называется копирующим (copy constructor). В качестве аргумента он принимает ссылку на объект, который подлежит копированию. Значит, класс

vector
должен выглядеть следующим образом:


vector(const vector&);


Этот конструктор будет вызываться, когда мы попытаемся инициализировать один объект класса

vector
другим. Мы передаем объект по ссылке, поскольку не хотим (очевидно) копировать аргумент конструктора, который определяет суть копирования. Мы передаем эту ссылку со спецификатором
const
, потому что не хотим модифицировать аргумент (см. раздел 8.5.6). Уточним определение класса
vector
.


class vector {

 int sz;

 double* elem;

 void copy(const vector& arg); // копирует элементы copy

                 // из arg в *elem

public:

 vector(const vector&);     // конструктор копирования

 // ...

};


Функция-член

copy()
просто копирует элементы из вектора, являющегося аргументом.


void vector::copy(const vector& arg)

 // копирует элементы [0:arg.sz–1]

{

 for (int i = 0; i

}


Подразумевается, что функции-члену

copy()
доступны
sz
элементов как в аргументе
arg
, так и в векторе, в который он копируется. Для того чтобы обеспечить это, мы сделали функцию-член
copy()
закрытой. Ее могут вызывать только функции, являющиеся частью реализации класса vector. Эти функции должны обеспечить совпадение размеров векторов.

Конструктор копирования устанавливает количество элементов (

sz
) и выделяет память для элементов (инициализируя указатель
elem
) перед копированием значений элементов из аргумента
vector
.


vector::vector(const vector& arg)

// размещает элементы, а затем инициализирует их путем копирования

    :sz(arg.sz), elem(new double[arg.sz])

{

 copy(arg);

}


Имея конструктор копирования, мы можем вернуться к рассмотренному выше примеру.


vector v2 = v;


Это определение инициализирует объект

v2
, вызывая конструктор копирования класса
vector
с аргументом
v
. Если бы объект класса
vector
содержал три элемента, то возникла бы следующая ситуация:



Теперь деструктор может работать правильно. Каждый набор элементов будет корректно удален. Очевидно, что два объекта класса

vector
теперь не зависят друг от друга, и мы можем изменять значения элементов в объекте
v
, не влияя на содержание объекта
v2
, и наоборот. Рассмотрим пример.


v.set(1,99);  // устанавливаем v[1] равным 99

v2.set(0,88); // устанавливаем v2[0] равным 88

cout << v.get(0) << ' ' << v2.get(1);


Результат равен

0
0
.

Вместо инструкции


vector v2 = v;


мы могли бы написать инструкцию


vector v2(v);


Если объекты

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

18.2.2. Копирующее присваивание

Копирование векторов может возникать не только при их инициализации, но и при присваивании. Как и при инициализации, по умолчанию копирование производится поэлементно, так что вновь может возникнуть двойное удаление (см. раздел 18.2.1) и утечка памяти. Рассмотрим пример.


void f2(int n)

{

 vector v(3); // определяем вектор

 v.set(2,2.2);

 vector v2(4);

 v2 = v;    // присваивание: что здесь происходит?

 // ...

}


Мы хотели бы, чтобы вектор

v2
был копией вектора
v
(именно так функционирует стандартный класс
vector
), но поскольку в нашем классе
vector
смысл копирования не определен, используется присваивание по умолчанию; иначе говоря, присваивание выполняется почленно, и члены
sz
и
elem
объекта
v2
становятся идентичными элементам
sz
и
elem
объекта
v
соответственно.

Эту ситуацию можно проиллюстрировать следующим образом:



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

f2()
возникнет такая же катастрофа, как и при выходе из функции
f()
в разделе 18.2, до того, как мы определили копирующий конструктор: элементы, на которые ссылаются оба вектора,
v
и
v2
, будут удалены дважды (с помощью оператора
delete[]
). Кроме того, возникнет утечка памяти, первоначально выделенной для вектора
v2
, состоящего из четырех элементов. Мы “забыли” их удалить. Решение этой проблемы в принципе не отличается от решения задачи копирующей инициализации (см. раздел 18.2.1). Определим копирующий оператор присваивания.


class vector {

 int sz;

 double* elem;

 void copy(const vector& arg); // копирует элементы из arg

                // в *elem

public:

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

  // ...

};


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

 // делает этот вектор копией вектора a

{

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

 for (int=0; i

   p[i]=a.elem[i];       // копируем элементы

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

 elem = p;           // теперь можно обновить elem

  sz = a.sz;

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

           // на текущий объект
 (см. раздел 17.10)

}


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

vector
.


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

for(int=0; i


Теперь освобождаем старые элементы из целевого объекта класса

vector
.


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


В заключение установим указатель

elem
на новые элементы.


elem = p; // теперь можем изменить указатель elem

sz = a.sz;



Теперь в классе

vector
утечка памяти устранена, а память освобождается только один раз (
delete[]
).

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


vector v(10);

v=v; // самоприсваивание


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

18.2.3. Терминология, связанная с копированием

Копирование встречается в большинстве программ и языков программирования. Основная проблема при этом заключается в том, что именно копируется: указатель (или ссылка) или информация, на которую он ссылается.

Поверхностное копирование (shallow copy) предусматривает копирование только указателя, поэтому в результате на один и тот же объект могут ссылаться два указателя. Именно этот механизм копирования лежит в основе работы указателей и ссылок.

Глубокое копирование (deep copy) предусматривает копирование информации, на которую ссылается указатель, так что в результате два указателя ссылаются на разные объекты. На основе этого механизма копирования реализованы классы

vector
,
string
и т.д. Если мы хотим реализовать глубокое копирование, то должны реализовать в наших классах конструктор копирования и копирующее присваивание.


Рассмотрим пример поверхностного копирования.


int* p = new int(77);

int* q = p; // копируем указатель p

*p = 88;   // изменяем значение переменной int, на которую

       // ссылаются указатели p и q 


Эту ситуацию можно проиллюстрировать следующим образом.



В противоположность этому мы можем осуществить глубокое копирование.


int* p = new int(77);

int* q = new int(*p); // размещаем новую переменную int,

            // затем копируем значение, на которое

            // ссылается p

*p = 88;        // изменяем значение, на которое ссылается p


Эту ситуацию можно проиллюстрировать так.



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

vector
заключалась в том, что мы выполняли поверхностное копирование и не копировали элементы, на которые ссылался указатель
elem
. Наш усовершенствованный класс
vector
, как и стандартный класс
vector
, выполняет глубокое копирование, выделяя новую память для элементов и копируя их значения. О типах, предусматривающих поверхностное копирование (таких как указатели и ссылки), говорят, что они имеют семантику указателей (pointer semantics) или ссылок (reference semantics), т.е. копируют адреса. О типах, осуществляющих глубокое копирование (таких как
string
и
vector
), говорят, что они имеют семантику значений (value semantics), т.е. копируют значения, на которые ссылаются. С точки зрения пользователя типы с семантикой значений функционируют так, будто никакие указатели не используются, а существуют только значения, которые копируются. С точки зрения копирования типы, обладающие семантикой значений, мало отличаются от типа
int
.

18.3. Основные операции

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

• Конструкторы с одним или несколькими аргументами.

• Конструктор по умолчанию.

• Копирующий конструктор (копирование объектов одинаковых типов).

• Копирующее присваивание (копирование объектов одинаковых типов).

• Деструктор.


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


string s(" Триумф "); // инициализируем объект s строкой "Триумф"

vector v(10); // создаем вектор v, состоящий из 10 чисел

            // double


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

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

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

Как понять, что в классе необходим конструктор по умолчанию? Он требуется тогда, когда мы хотим создавать объекты класса без указания инициализатора. Наиболее распространенный пример такой ситуации возникает, когда мы хотим поместить объекты класса в стандартный контейнер, имеющий тип

vector
. Приведенные ниже инструкции работают только потому, что для типов
int
,
string
и
vector
существуют значения, предусмотренные по умолчанию.


vector vi(10); // вектор из 10 элементов типа double,

             // каждый из них инициализирован 0.0

vector vs(10); // вектор из 10 элементов типа string,

             // каждый из них инициализирован ""

vector > vvi(10); // вектор из 10 векторов,

                // каждый из них

                 // инициализирован конструктором
 vector()


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

int
и
double
, очевидным значением является
0
(для типа
double
оно принимает вид
0.0
). Для типа
string
очевидным выбором является
""
. Для класса
vector
можно использовать пустой вектор. Если тип
T
имеет значение по умолчанию, то оно задается конструктором
T()
. Например,
double()
равно
0.0
,
string()
равно
""
, а
vector()
— это пустой
vector
, предназначенный для хранения переменных типа
int
.

Если класс обладает ресурсами, то он должен иметь деструктор. Ресурс — это то, что вы “где-то взяли” и должны вернуть, когда закончите его использовать. Очевидным примером является память, выделенная с помощью оператора new, которую вы должны освободить, используя оператор

delete
или
delete[]
. Для хранения своих элементов наш класс vector требует память, поэтому он должен ее вернуть; следовательно, он должен иметь деструктор. Другие ресурсы, которые используются в более сложных программах, — это файлы (если вы открыли файл, то должны его закрыть), блокировки (locks), дескрипторы потоков (thread handles) и двунаправленные каналы (sockets), используемые для обеспечения взаимосвязи между процессами и удаленными компьютерами.

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

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

vector
.

Если производный класс должен иметь деструктор, то базовый класс должен иметь виртуальный деструктор (см. раздел 17.5.2).

18.3.1. Явные конструкторы

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


class complex {

public:

 complex(double); // определяет преобразование double в complex

 complex(double,double);

 // ...

};


complex z1 = 3.18; // OK: преобразует 3.18 в (3.18,0)

complex z2 = complex(1.2, 3.4);


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

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


class vector {

 // ...

vector(int);

 // ...

};


vector v = 10;  // создаем вектор из 10 элементов типа double

v = 20;     // присваиваем вектору v новый вектор

         // из 20 элементов типа double to v

void f(const vector&);

f(10);      // Вызываем функцию f с новым вектором,

         // состоящим из 10 элементов типа double


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

explicit
допускает только обычную семантику конструирования и не допускает неявные преобразования. Рассмотрим пример.


class vector {

 // ...

 explicit vector(int);

 // ...

};


vector v = 10;  // ошибка: преобразования int в vector нет

v = 20;     // ошибка: преобразования int в vector нет

vector v0(10);  // OK


void f(const vector&);

f(10);      // ошибка: преобразования int в vector нет

f(vector(10)); // OK


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

vector
с одним аргументом имел спецификатор
explicit
. Очень жаль, что все конструкторы не имеют спецификатора
explicit
по умолчанию; если сомневаетесь, объявляйте конструктор, который может быть вызван с одним аргументом, используя ключевое слово
explicit
.

118.3.2. Отладка конструкторов и деструкторов

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

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

• Когда создается объект класса

X
, вызывается один из его конструкторов.

• Когда уничтожается объект типа

X
, вызывается его деструктор.


Деструктор вызывается всегда, когда уничтожается объект класса; это происходит, когда объект выходит из области видимости, программа прекращает работу или к указателю на объект применяется оператор

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

Что же при этом происходит? Для того чтобы понять это, добавим в конструкторы, операторы копирующего присваивания и деструкторы операторы вывода. Рассмотрим пример.


struct X { // простой тестовый класс

 int val;

 void out(const string& s)

   { cerr << this << "–>" << s << ": " << val << "\n"; }

 X(){ out("X()"); val=0; }    // конструктор по умолчанию

 X(int v) { out( "X(int)"); val=v; }

 X(const X& x){ out("X(X&) "); val=x.val; } // копирующий

                       // конструктор

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

   { out("X::operator=()"); val=a.val; return *this; }

  ~X() { out("~X()"); }      // деструктор

};


Проследим, что происходит при выполнении операций над объектом класса

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


X glob(2);  // глобальная переменная

X copy(X a) { return a; }

X copy2(X a) { X aa = a; return aa; }

X& ref_to(X& a) { return a; }

X* make(int i) { X a(i); return new X(a); }

struct XX { X a; X b; };


int main()

{

 X loc(4);     // локальная переменная

 X loc2 = loc;

 loc = X(5);

 loc2 = copy(loc);

 loc2 = copy2(loc);

 X loc3(6);

 X& r = ref_to(loc);

 delete make(7);

  delete make(8);

 vector v(4);

 XX loc4;

 X* p = new X(9);  // объект класса Х в свободной памяти

 delete p;

 X* pp = new X[5]; // массив объектов класса X

           // в свободной памяти

 delete[]pp;

}


Попробуйте выполнить эту программу.


ПОПРОБУЙТЕ

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


В зависимости от качества вашего компилятора вы можете заметить пропущенные копии, связанные с вызовами функций

copy()
и
copy2()
. Мы (люди) видим, что эти функции ничего не делают; они просто копируют значение из потока ввода в поток вывода без каких-либо изменений. Если компилятор настолько хорош, что заметит это, то сможет удалить эти вызовы конструктора копирования. Иначе говоря, компилятор может предполагать, что конструктор копирования только копирует и ничего больше не делает. Некоторые компиляторы настолько “умны”, что могут исключить фиктивные копии.

Так зачем же возиться с этим “глупым классом

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

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

18.4. Доступ к элементам вектора

До сих пор (см. раздел 17.6) для доступа к элементам вектора мы использовали функции-члены

set()
и
get()
. Но этот способ слишком громоздок и некрасив. Мы хотим использовать обычную индексацию:
v[i]
. Для этого следует определить функцию-член с именем
operator[]
. Вот ее первая (наивная) версия.


class vector {

 int sz;     // размер

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

public:

 // ...

  double operator[](int n) { return elem[n]; } // возвращаем

                        // элемент

};


Все выглядит хорошо и просто, но, к сожалению, слишком просто. Разрешив оператору индексирования (

operator[]()
) возвращать значение, мы разрешили чтение, но не запись элементов.


vector v(10);

int x = v[2]; // хорошо

v[3] = x;   // ошибка: v[3] не может стоять в левой

        // части оператора =


Здесь выражение

v[i]
интерпретируется как вызов оператора
v.operator[](i)
, который возвращает значение элемента вектора
v
с номером
i
. Для такого слишком наивного варианта класса
vector
значение
v[3]
является числом с плавающей точкой, а не переменной, содержащей число с плавающей точкой.


ПОПРОБУЙТЕ

Создайте вариант класса

vector
, скомпилируйте его и посмотрите на сообщение об ошибке, которое ваш компилятор выдаст для инструкции
v[3]=x;
.


В следующей версии мы разрешим оператору

operator[]
возвращать указатель на соответствующий элемент:


class vector {

 int sz;     // размер

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

public:

 // ...

 double* operator[](int n) { return &elem[n]; } // возвращаем

                         // указатель

};


При таком определении мы можем записывать элементы.


vector v(10);

for (int i=0; i

                 // некрасиво

 *v[i] = i;

cout << *v[i];

}


Здесь выражение

v[i]
интерпретируется как вызов оператора
v.operator[](i)
и возвращает указатель на элемент вектора
v
с номером
i
. Проблема в том, что теперь мы должны написать оператор
*
, чтобы разыменовать указатель, ссылающийся на этот элемент. Это так же некрасиво, как и функции
set()
и
get()
. Проблему можно устранить, если вернуть из оператора индексирования ссылку.


class vector {

 // ...

 double& operator[ ](int n) { return elem[n]; } // возвращаем

                         // ссылку

};


Теперь можем написать следующий вариант.


vector v(10);

for (int i=0; i

 v[i] = i;    // v[i] возвращает ссылку на элемент с номером i

 cout << v[i];

}


Мы обеспечили традиционные обозначения: выражение

v[i]
интерпретируется как вызов оператора
v.operator[](i)
и возвращает ссылку на элемент вектора
v
с номером
i
.

18.4.1. Перегрузка ключевого слова const

Функция

operator[]()
, определенная выше, имеет один недостаток: ее нельзя вызвать для константного вектора. Рассмотрим пример.


void f(const vector& cv)

{

 double d = cv[1]; // неожиданная ошибка

 cv[1] = 2.0;    // ожидаемая ошибка

}


Причина заключается в том, что наша функция

vector::operator[]()
потенциально может изменять объект класса
vector
. На самом деле она этого не делает, но компилятор об этом не знает, потому что мы забыли сообщить ему об этом. Для того чтобы решить эту проблему, необходимо предусмотреть функцию-член со спецификатором
const
(см раздел 9.7.4). Это легко сделать.


class vector {

 // ...

 double& operator[](int n);    // для неконстантных векторов

 double operator[](int n) const; // для константных векторов

};


Очевидно, что мы не могли бы вернуть ссылку типа

double&
из версии со спецификатором
const
, поэтому возвращаем значение типа
double
. С таким же успехом мы могли бы вернуть ссылку типа
const double &
, но, поскольку объект типа
double
невелик, не имеет смысла возвращать ссылку (см. раздел 8.5.6), и мы решили вернуть значение. Теперь можно написать следующий код:


void ff(const vector& cv, vector& v)

{

 double d = cv[1]; // отлично (использует константный вариант [ ])

 cv[1] = 2.0;    // ошибка (использует константный вариант [ ])

 double d = v[1];  // отлично (использует неконстантный вариант [ ])

 v[1] = 2.0;    // отлично (использует неконстантный вариант [ ])

}


Поскольку объекты класса

vector
часто передаются по константной ссылке, эта версия оператора
operator[]()
с ключевым словом
const
является существенным дополнением.

18.5. Массивы

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

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

• Как локальные переменные (однако массивы накладывают на них серьезные ограничения).

• Как аргументы функции (но массив не знает своего размера).

• Как член класса (хотя массивы, являющиеся членами класса, трудно инициализировать).


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

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

Итак, что такое массив? Как его определить и как использовать? Массив — это однородная последовательность объектов, расположенных в смежных ячейках памяти; иначе говоря, все элементы массива имеют один и тот же тип, и между ними нет пробелов. Элементы массива нумеруются, начиная с нуля в возрастающем порядке. В объявлении массив выделяется квадратными скобками.


const int max = 100;

int gai[max];   // глобальный массив (из 100 чисел типа int);

         // "живет всегда"


void f(int n)

{

 char lac[20]; // локальный массив; "живет" до конца области

         // видимости

 int lai[60];

 double lad[n]; // ошибка: размер массива не является константой

 // ...

}


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

vector
с массивами элементов.

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

[ ]
и
*
). Рассмотрим пример.


void f2()

{

  char lac[20];  // локальный массив; "живет" до конца области

          // видимости

 lac[7] = 'a';

 *lac = 'b';   // эквивалент инструкции lac[0]='b'

 lac[–2] = 'b';  // ??

 lac[200] = 'c'; // ??

}


Эта функция компилируется, но, как мы знаем, не все скомпилированные функции работают правильно. Использование оператора

[ ]
очевидно, но проверка выхода за пределы допустимого диапазона отсутствует, поэтому функция
f2()
компилируется, а результат записи
lac[–2]
и
lac[200]
приводит к катастрофе (как всегда, при выходе за пределы допустимого диапазона). Не делайте этого. Массивы не проверяют выход за пределы допустимого диапазона. И снова здесь нам приходится непосредственно работать с физической памятью, так как на системную поддержку рассчитывать не приходится.

А не мог ли компилятор как-то увидеть, что массив

lac
содержит только двадцать элементов, так что выражение
lac[200]
— это ошибка? В принципе мог бы, но, как нам известно, в настоящее время не существует ни одного такого компилятора. Дело в том, что отследить границы массива на этапе компиляции невозможно в принципе, а перехват простейших ошибок (таких как приведены выше) не решает всех проблем.

18.5.1. Указатели на элементы массива

Указатель может ссылаться на элемент массива. Рассмотрим пример.


double ad[10];

double* p = &ad[5]; // ссылается на элемент ad[5]


Указатель

p
ссылается на переменную типа
double
, известную как
ad[5]
.



Этот указатель можно индексировать и разыменовывать.


*p =7;

p[2] = 6;

p[–3] = 9;


Теперь ситуация выглядит следующим образом.



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

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


p += 2; // переносим указатель p на два элемента вправо


Итак, приходим к следующей ситуации.



Аналогично,


p –= 5; // переносим указатель p на пять элементов вправо


В итоге получим следующее.



Использование операций

+
,
,
+=
и
–=
для переноса указателей называется арифметикой указателей (pointer arithmetic). Очевидно, поступая так, мы должны проявлять большую осторожность, чтобы не выйти за пределы массива.


p += 1000;   // абсурд: p ссылается на массив, содержащий

        // только 10 чисел

double d = *p; // незаконно: возможно неправильное значение

         // (совершенно непредсказуемое)

*p = 12.34;   // незаконно: можно задеть неизвестные данные


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

Наиболее распространенным использованием арифметик указателей является инкрементация указателя (с помощью оператора

++
) для ссылки на следующий элемент и декрементация указателя (с помощью оператора
––
) для ссылки на предыдущий элемент. Например, мы могли вы вывести элементы массива ad следующим образом:


for (double* p = &ad[0]; p<&ad[10]; ++p) cout << *p << '\n';


И в обратном порядке:


for (double* p = &ad[9]; p>=&ad[0]; ––p) cout << *p << '\n';


Это использование арифметики указателей не слишком широко распространено. Однако, по нашему мнению, последний (“обратный”) пример небезопасен. Почему

&ad[9]
, а не
&ad[10]
? Почему
>=
, а не
>
? Эти примеры были бы одинаково хороши (и одинаково эффективны), если бы мы использовали индексацию. Кроме того, они были бы совершенно эквивалентны в классе
vector
, в котором проверка выхода за пределы допустимого диапазона осуществляется проще.

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

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


double* p1 = &ad[0];

double* p2 = p1+7;

double* p3 = &p1[7];

if (p2 != p3) cout << "impossible!\n";


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

18.5.2. Указатели и массивы

Имя массива относится ко всем элементам массива. Рассмотрим пример.


char ch[100];


Размер массива

ch
, т.е.
sizeof(ch)
, равен 100. Однако имя массива без видимых причин превращается в указатель.


char* p = ch;


Здесь указатель

p
инициализируется адресом
&ch[0]
, а размер
sizeof(p)
равен 4 (а не 100). Это свойство может быть полезным. Например, рассмотрим функцию
strlen()
, подсчитывающую количество символов в массиве символов, завершающимся нулем.


int strlen(const char* p) // аналогична стандартной

              // функции strlen()

{

 int count = 0;

 while (*p) { ++count; ++p; }

 return count;

}


Теперь можем вызвать ее как с аргументом

strlen(ch)
, так и с аргументом
strlen(&ch[0]
). Возможно, вы заметили, что такое обозначение дает очень небольшое преимущество, и мы с вами согласны. Одна из причин, по которым имена массивов могут превращаться в указатели, состоит в желании избежать передачи большого объема данных по значению. Рассмотрим пример.


int strlen(const char a[]) // аналогична стандартной

               // функции strlen()

{

 int count = 0;

 while (a[count]) { ++count; }

 return count;

}


char lots [100000];


void f()

{

 int nchar = strlen(lots);

 // ... 


Наивно (но частично обоснованно) мы могли бы ожидать, что при выполнении этого вызова будут скопированы 100 тыс. символов, заданных как аргумент функции

strlen()
, но этого не происходит. Вместо этого объявление аргумента
char p[]
рассматривается как эквивалент объявления
char* p
, а вызов
strlen(lots)
— как эквивалент вызова
strlen(&lots[0])
. Это предотвращает затратное копирование, но должно вас удивить. Почему вы должны удивиться? Да потому, что в любой другой ситуации при передаче объекта, если вы не потребуете явно, чтобы он передавался по ссылке (см. разделы 8.5.3–8.5.6), этот объект будет скопирован.

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


char ac[10];

ac = new char [20];   // ошибка: имени массива ничего присвоить нельзя

&ac[0] = new char [20]; // ошибка: значению указателя ничего

             // присвоить нельзя


И на десерт — проблема, которую компилятор может перехватить!

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


int x[100];

int y[100];

// ...

x = y;      // ошибка

int z[100] = y; // ошибка


Это логично, но неудобно. Если необходимо скопировать массив, вы должны написать более сложный код. Рассмотрим пример.


for (int i=0; i<100; ++i) x[i]=y[i]; // копируем 100 чисел типа int

memcpy(x,y,100*sizeof(int)); // копируем 100*sizeof(int) байт

copy(y,y+100, x); // копируем 100 чисел типа int


Поскольку в языке C нет векторов, в нем интенсивно используются массивы. Вследствие этого в огромном количестве программ, написанных на языке C++, используются массивы (подробнее об этом — в разделе 27.1.2). В частности, строки в стиле C (массивы символов, завершаемые нулем; эта тема рассматривается в разделе 27.5) распространены очень широко.

Если хотите копировать, то используйте класс, аналогичный классу

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


vector x(100);

vector y(100);

// ...

x = y;   // копируем 100 чисел типа int

18.5.3. Инициализация массива

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


char ac[] = "Beorn"; // массив из шести символов


Подсчитайте эти символы. Их пять, но

ac
становится массивом из шести символов, потому что компилятор добавляет завершающий нуль в конце строкового литерала.



Строка, завершающаяся нулем, является обычным явлением в языке С и многих системах. Такие массивы символов, завершающиеся нулем, мы называем строками в стиле языка С (C-style string). Все строковые литералы являются строками в стиле языка C. Рассмотрим пример.


char* pc = "Howdy"; // указатель pc ссылается на массив из шести

           // символов


Графически это можно изобразить следующим образом.



Переменная типа

char
, имеющая числовое значение
0
, — это не символ
'0'
, не буква и не цифра. Цель этого завершающего нуля — помочь функции найти конец строки. Помните: массив не знает своего размера. Полагаясь на использование завершающего нуля, мы можем написать следующий код:


int strlen(const char* p) // похоже на стандартную функцию strlen()

{

 int n = 0;

 while (p[n]) ++n;

 return n;

}


На самом деле мы не обязаны определять функцию

strlen()
, поскольку это уже стандартная библиотечная функция, определенная в заголовочном файле
(разделы 27.5 и Б.10.3). Обратите внимание на то, что функция
strlen()
подсчитывает символы, но игнорирует завершающий нуль; иначе говоря, для хранения
n
символов в строке в стиле языка С необходимо иметь память для хранения n+1 переменной типа
char
.

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


int ai[] = { 1, 2, 3, 4, 5, 6 };    // массив из шести чисел

                     // типа int

int ai2[100] = { 0,1,2,3,4,5,6,7,8,9 }; // остальные 90 элементов

                     // инициализируются нулем

double ad[100] = { };       // все элементы инициализируются нулем

char chars[] = { 'a', 'b', 'c' }; // нет завершающего нуля!


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

ai
равно шести (а не семи), а количество элементов в массиве
chars
равно трем (а не четырем), — правило “добавить нуль в конце” относится только к строковым литералам. Если размер массива не задан явно, то он определяется по списку инициализации. Это довольно полезное правило. Если количество элементов в списке инициализации окажется меньше, чем количество элементов массива (как в определениях массивов
ai2
и
ad
), остальные элементы инициализируются значениями, предусмотренными для данного типа элементов по умолчанию.

18.5.4. Проблемы с указателями

Как и массивами, указателями часто злоупотребляют. Люди часто сами создают себе проблемы, используя указатели и массивы. В частности, все серьезные проблемы, связанные с указателями, вызваны обращением к области памяти, которая не является объектом ожидаемого типа, причем многие из этих проблем, в свою очередь, вызваны выходом за пределы массива. Перечислим эти проблемы.

• Обращение по нулевому указателю.

• Обращение по неинициализированному указателю.

• Выход за пределы массива.

• Обращение к удаленному объекту.

• Обращение к объекту, вышедшему из области видимости.


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

Не обращайтесь к памяти с помощью нулевого указателя.


int* p = 0;

*p = 7;  // Ой!


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


int* p = fct_that_can_return_a_0();

if (p == 0) {

  // что-то делаем

}

else {

 // используем р

  *p = 7;

}


и


void fct_that_can_receive_a_0(int* p)

{

 if (p == 0) {

   // что-то делаем

  }

  else {

   // используем р

   *p = 7;

  }

}


Основными средствами, позволяющими избежать ошибок, связанных с нулевыми указателями, являются ссылки (см. раздел 17.9.1) и исключения (см. разделы 5.6 и 19.5).

Инициализируйте указатели.


int* p;

*p = 9; // Ой!


В частности, не забывайте инициализировать указатели, являющиеся членами класса.

Не обращайтесь к несуществующим элементам массива.


int a[10];

int* p = &a[10];

*p = 11;   // Ой!

a[10] = 12;  // Ой!


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

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

Не обращайтесь к памяти с помощью удаленного указателя.


int* p = new int(7);

// ...

delete p;

// ...

*p = 13;  // Ой!


Инструкция

delete p
или код, размещенный после нее, может неосторожно обратиться к значению
*p
или использовать его косвенно. Все эти ситуации совершенно недопустимы. Наиболее эффективной защитой против этого является запрет на использование “голых” операторов
new
, требующих выполнения “голых” операторов
delete
: выполняйте операторы
new
и
delete
в конструкторах и деструкторах или используйте контейнеры, такие как
Vector_ref
(раздел Д.4).

Не возвращайте указатель на локальную переменную.


int* f()

{

 int x = 7;

 // .. .

 return &x;

}


// ...

int* p = f();

// ...

*p = 15;  // Ой!


Возврат из функции

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

Рассмотрим эквивалентный пример.


vector& ff()

{

 vector x(7);

 // ...

 return x;

}  // здесь вектор х был уничтожен


// ...

vector& p = ff();

// ...

p[4] = 15;  // Ой!


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

new
и
delete
. Если же вы поступаете так, то просто быть осторожным в реальной жизни недостаточно. Полагайтесь на векторы, концепцию RAII (“Resource Acquisition Is Initialization” — “Получение ресурса — это инициализация”; см. раздел 19.5), а также на другие систематические подходы к управлению памятью и другими ресурсами.

18.6. Примеры: палиндром

Довольно технических примеров! Попробуем решить маленькую головоломку. Палиндром (palindrome) — это слово, которое одинаково читается как слева направо так и справа налево. Например, слова anna, petep и malayalam являются палиндромами, а слова ida и homesick — нет. Есть два основных способа определить, является ли слово палиндромом.

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

• Проверить, совпадает ли первая буква с последней, вторая — с предпоследней, и так далее до середины.


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

18.6.1. Палиндромы, созданные с помощью класса string

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

string
, в котором индексы сравниваемых букв задаются переменной типа
int
.


bool is_palindrome(const string& s)

{

 int first = 0;      // индекс первой буквы

 int last = s.length()–1; // индекс последней буквы

 while (first < last) {   // мы еще не достигли середины слова

   if (s[first]!=s[last]) return false;

   ++first;  // вперед

   ––last;  // назад

 }

 return true;

}


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

is_palindrome()
.


int main()

{

 string s;

 while (cin>>s) {

   cout << s << " is";

   if (!is_palindrome(s)) cout << " not";

   cout << " a palindrome\n";

 }

}


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

string
, заключается в том, что объекты класса
string
хорошо работают со словами. Они достаточно просто считывают слова, разделенные пробелами, и знают свой размер. Если бы мы хотели применить функцию
is_palindrome()
к строкам, содержащим пробелы, то просто считывали бы их с помощью функции
getline()
(см. раздел 11.5). Это можно было бы продемонстрировать на примере строк ah ha и as df fd sa.

18.6.2. Палиндромы, созданные с помощью массива

А если бы у нас не было класса

string
(или
vector
) и нам пришлось бы хранить символы в массиве? Посмотрим.


bool is_palindrome(const char s[], int n)

 // указатель s ссылается на первый символ массива из n символов

{

 int first = 0;     // индекс первой буквы

 int last = n–1;     // индекс последней буквы

 while (first < last) { // мы еще не достигли середины слова

 if (s[first]!=s[last]) return false;

 ++first;   // вперед

 ––last;   // назад

 }

 return true;

}


Для того чтобы выполнить функцию

is_palindrome()
, сначала необходимо записать символы в массив. Один из безопасных способов (без риска переполнения массива) выглядит так:


istream& read_word(istream& is, char* buffer, int max)

 // считывает не более max–1 символов в массив buffer

{

 is.width(max); // при выполнении следующего оператора >>

         // будет считано не более max–1 символов

 is >> buffer;  // читаем слово, разделенное пробелами,

         // добавляем нуль после последнего символа

 return is;

}


Правильная установка ширины потока

istream
предотвращает переполнение массива при выполнении следующего оператора
>>
. К сожалению, это также означает, что нам неизвестно, завершается ли чтение пробелом или буфер полон (поэтому нам придется продолжить чтение). Кроме того, кто помнит особенности поведения функции
width()
при вводе? Стандартные классы
string
и
vector
на самом деле лучше, чем буферный ввод, поскольку они могут регулировать размер буфера при вводе. Завершающий символ
0
необходим, так как большинство операций над массивами символов (строка в стиле языка C) предполагают, что массив завершается нулем. Используя функцию
read_word()
, можно написать следующий код:


int main()

{

 const int max = 128;

 char s[max];

 while (read_word(cin,s,max)) {

   cout << s << " is";

   if (!is_palindrome(s,strlen(s))) cout << " not";

   cout << " a palindrome\n";

 }

}


Вызов

strlen(s)
возвращает количество символов в массиве после выполнения вызова
read_word()
, а инструкция
cout<
выводит символы из массива, завершающегося нулем.

Решение задачи с помощью класса

string
намного аккуратнее, чем с помощью массивов. Это проявляется намного ярче, когда приходится работать с длинными строками (см. упр. 10).

18.6.3. Палиндромы, созданные с помощью указателей

Вместо использования индексов для идентификации символов можно было бы применить указатели.


bool is_palindrome(const char* first, const char* last)

 // указатель first ссылается на первую букву

 // указатель last ссылается на последнюю букву

{

 while (first < last) {  // мы еще не достигли середины

   if (*first!=*last) return false;

   ++first;  // вперед

   ––last;  // назад

 }

 return true;

}


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

Функция

is_palindrome()
вызывается следующим образом:


int main()

{

 const int max = 128;

 char s[max];

 while (read_word(cin,s,max)) {

   cout << s << " is";

   if (!is_palindrome(&s[0],&s[strlen(s)–1])) cout << " not";

   cout << " a palindrome\n";

 }

}


Просто забавы ради мы переписали функцию

is_palindrome()
следующим образом:


bool is_palindrome(const char* first, const char* last)

 // указатель first ссылается на первую букву

 // указатель last ссылается на последнюю букву

{

 if (first

   if (*first!=*last) return false;

   return is_palindrome(first+1,last-1);

 }

 return true;

}


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


Задание

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


  Задание с массивами

1. Определите глобальный массив

ga
типа
int
, состоящий из десяти целых чисел и инициализированный числами 1, 2, 4, 8, 16 и т.д.

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

f()
, принимающую в качестве аргументов массив типа
int
и переменную типа
int
, задающую количество элементов в массиве.

3. В функции

f()
выполните следующее.

3.1. Определите локальный массив

la
типа
int
, состоящий из десяти элементов.

3.2. Скопируйте значения из массива

ga
в массив
la
.

3.3. Выведите на печать элементы массива

la
.

3.4. Определите указатель

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

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

3.6. Выведите на печать элементы массива, расположенного в свободной памяти.

3.7. Удалите массив из свободной памяти.

4. В функции

main()
сделайте следующее.

4.1. Вызовите функцию

f()
с аргументом
ga
.

4.2. Определите массив

aa
, содержащий десять элементов, и инициализируйте его первыми десятью значениями факториала (т.е. 1, 2*1, 3*2*1, 4*3*2*1 и т.д.).

4.3. Вызовите функцию

f()
с аргументом
aa
.


  Задание со стандартным вектором

1. Определите глобальный вектор

vector gv
; инициализируйте его десятью целыми числами 1, 2, 4, 8, 16 и т.д.

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

f()
, принимающую аргумент типа
vector
.

3. В функции

f()
сделайте следующее.

3.1. Определите локальный вектор

vector lv
с тем же количеством элементов, что и вектор, являющийся аргументом функции.

3.2. Скопируйте значения из вектора

gv
в вектор
lv
.

3.3. Выведите на печать элементы вектора

lv
.

3.4. Определите локальный вектор

vector lv2
; инициализируйте его копией вектора, являющегося аргументом функции.

3.5. Выведите на печать элементы вектора

lv2
.

4. В функции

main()
сделайте следующее.

4.1. Вызовите функцию

f()
с аргументом
gv
.

4.2. Определите вектор

vector vv
и инициализируйте его первыми десятью значениями факториала (1, 2*1, 3*2*1, 4*3*2*1 и т.д.).

4.3. Вызовите функцию

f()
с аргументом
vv
.


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

1. Что означает выражение “Покупатель, будь бдителен!”?

2. Какое копирование объектов класса используется по умолчанию?

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

4. Что такое конструктор копирования?

5. Что такое копирующее присваивание?

6. В чем разница между копирующим присваиванием и копирующей инициализацией?

7. Что такое поверхностное копирование? Что такое глубокое копирование?

8. Как копия объекта класса vector сравнивается со своим прототипом?

9. Перечислите пять основных операций над классом.

10. Что собой представляет конструктор с ключевым словом

explicit
? Когда его следует предпочесть конструктору по умолчанию?

11. Какие операции могут применяться к объекту класса неявно?

12. Что такое массив?

13. Как скопировать массив?

14. Как инициализировать массив?

15. Когда передача указателя на аргумент предпочтительнее передачи его по ссылке и почему?

16. Что такое строка в стиле С, или С-строка?

17. Что такое палиндром?


Термины


Упражнения

1. Напишите функцию

char* strdup(const char*)
, копирующую строку в стиле языка C в свободную память, одновременно выделяя для нее место. Не используйте никаких стандартных функций. Не используйте индексирование, вместо него применяйте оператор разыменования
*
.

2. Напишите функцию

char* findx(const char* s, const char* x)
, находящую первое вхождение строки
x
в стиле языка С в строку
s
. Не используйте никаких стандартных функций. Не используйте индексирование, вместо него применяйте оператор разыменования *.

3. Напишите функцию

int strcmp(const char* s1, const char* s2)
, сравнивающую две строки в стиле языка С. Если строка
s1
меньше строки
s2
в лексикографическом смысле, функция должна возвращать отрицательное число, если строки совпадают — нуль, а если строка
s1
больше строки
s2
в лексикографическом стиле — положительное число. Не используйте никаких стандартных функций. Не используйте индексирование, вместо него применяйте оператор разыменования
*
.

4. Что случится, если передать функциям

strdup()
,
findx()
и
strcmp()
в качестве аргумента не строку в стиле С? Попробуйте! Сначала необходимо выяснить, как получить указатель
char*
, который не ссылается на массив символов, завершающийся нулем, а затем применить его (никогда не делайте этого в реальном — не экспериментальном — коде; это может вызвать катастрофу). Поэкспериментируйте с неправильными строками в стиле С, расположенными в свободной памяти или стеке. Если результаты покажутся разумными, отключите режим отладки. Переделайте и заново выполните все три функции так, чтобы они получали еще один аргумент — максимально допустимое количество символов в строке. Затем протестируйте функции с правильными и неправильными строками в стиле языка С.

5. Напишите функцию

string cat_dot(const string& s1, const string& s2)
, выполняющую конкатенацию двух строк с точкой между ними. Например,
cat_dot("Нильс", "Бор")
вернет строку
Нильс.Бор
.

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

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

7. Напишите варианты функции

cat_dot()
из предыдущих упражнений, получающие в качестве аргументов строки в стиле языка C и возвращающие строку в стиле языка С, размещенную в свободной памяти. Не используйте никаких стандартных функций или типов. Протестируйте эти функции на нескольких строках. Убедитесь, что вся память, занятая вами с помощью оператора new, освобождается с помощью оператора
delete
. Сравните усилия, затраченные вами на выполнение упр. 5 и 6.

8. Перепишите все функции, приведенные в разделе 18.6, используя для сравнения обратную копию строки; например, введите строку "

home
", сгенерируйте строку "
emoh
" и сравните эти две строки, чтобы убедиться, что слово home — не палиндром.

9. Проанализируйте схему распределения памяти, описанную в разделе 17.4. Напишите программу, сообщающую, в каком порядке выделяется статическая память, стек и свободная память. В каком направлении растет стек: в сторону старших или младших адресов? Допустим, массив расположен в свободной памяти. Какой элемент будет иметь больший адрес — с большим индексом или с меньшим?

10. Проанализируйте решение задачи о палиндроме из раздела 18.6.2 на основе массива 10. Исправьте его так, чтобы можно было работать с длинными строками: 1) выдавайте сообщение, если введенная строка оказалась слишком длинной; 2) разрешите произвольно длинные строки. Прокомментируйте сложность обеих версий.

11. Разберитесь, что собой представляет список с пропусками (skip list), и реализуйте эту разновидность списка. Это не простое упражнение.

12. Реализуйте версию игры “Охота на Вампуса” (или просто “Вамп”). Это простая компьютерная (не графическая) игра, изобретенная Грегори Йобом (Gregory Yob). Цель этой игры — найти довольно смышленого монстра, прячущегося в темном пещерном лабиринте. Ваша задача — убить вампуса с помощью лука и стрел. Кроме вампуса, пещера таит еще две опасности: бездонные ямы и гигантские летучие мыши. Если вы входите в комнату с бездонной ямой, то игра для вас закончена. Если вы входите в комнату с летучей мышью, то она вас хватает и перебрасывает в другую комнату. Если же вы входите в комнату с вампусом или он входит в комнату, где находитесь вы, он вас съедает. Входя в комнату, вы должны получить предупреждение о грозящей опасности.

  “Я чувствую запах вампуса” — значит, он в соседней комнате.

  “Я чувствую ветерок” — значит, в соседней комнате яма.

  “Я слышу летучую мышь” — значит, в соседней комнате живет летучая мышь.

Для вашего удобства комнаты пронумерованы. Каждая комната соединена туннелями с тремя другими. Когда вы входите в комнату, то получаете сообщение, например: “Вы в комнате номер 12; отсюда идут туннели в комнаты 1, 13 и 4; идти или стрелять?” Возможные ответы:

m13
(“Переход в комнату номер 13”) и
s13–4–3
(“Стрелять через комнаты с номерами 13, 4 и 3”). Стрела может пролететь через три комнаты. В начале игры у вас есть пять стрел. Загвоздка со стрельбой заключается в том, что вы можете разбудить вампуса и он войдет в комнату, соседнюю с той, где он спал, — она может оказаться вашей комнатой.

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

randint()
из библиотеки
std_lib_facilities.h)
, чтобы при разных запусках программы использовались разные пещеры и разное расположение летучих мышей и вампуса. Подсказка: используйте режим отладки для проверки состояния лабиринта.


Послесловие

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

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

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

Уинстон Черчилль (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 Контейнеры и итераторы

“Пишите программы, которые делают что-то одно

и делают это хорошо. Пишите программы,

чтобы работать вместе”.

Дуг Мак-Илрой (Doug McIlroy)


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

20.1. Хранение и обработка данных

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

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

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

vector
.

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


double* get_from_jack(int* count); // Джек записывает числа

 // типа double 
в массив и возвращает

 // количество
 элементов в массиве *count

vector* get_from_jill();  // Джилл заполняет вектор

void fct()

{

 int jack_count = 0;

 double* jack_data = get_from_jack(&jack_count);

 vector* jill_data = get_from_jill();

 // ...обрабатываем...

 delete[] jack_data;

 delete jill_data;

}


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

20.1.1. Работа с данными

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

Что мы хотим делать с этими данными? Упорядочить их? Найти наибольшее значение? Вычислить среднее? Найти все значения, большие 65? Сравнить данные Джилл с данными Джека? Определить количество элементов? Возможности бесконечны. Когда мы пишем реальную программу, то просто выполняем требуемые вычисления. В данном случае мы хотим выяснить, как обработать данные и выполнить вычисления с большим массивом чисел. Сначала сделаем нечто совсем простое: найдем наибольший элемент в каждом из наборов данных. Для этого комментарий ...обработка... следует заменить соответствующими инструкциями.


// ...

double h = –1;

double* jack_high;  // jack_high — указатель на наибольший элемент

double* jill_high;  // jill_high — указатель на наибольший элемент


for (int i=0; i

 if (h

   jack_high = &jack_data [i]; // сохраняем адрес наибольшего

                  // элемента


h = –1;

for (int i=0; i< jill_data –>size(); ++i)

 if (h<(*jill_data)[i])

   jill_high = &(*jill_data)[i]; // сохраняем адрес наибольшего

                  // элемента

cout << "Максимум Джилл: " << *jill_high

    << "; максимум Джека: " << *jack_high;

// ...


Обратите внимание на уродливую конструкцию, используемую для доступа к данным Джилл:

(*jill_data)[i]
. Функция
get_from_jill()
возвращает указатель на вектор, а именно
vector*
. Для того чтобы получить данные, мы сначала должны его разыменовать, получив доступ к вектору с помощью указателя
*jill_ data
, а затем применить к нему операцию индексирования. Однако выражение
*jill_data[i]
— не совсем то, что мы хотели; оно означает
*(jill_data[i])
, так как оператор
[]
имеет более высокий приоритет, чем
*
, поэтому нам необходимы скобки вокруг конструкции
*jill_data
, т.е. выражение
(*jill_data)[i]
.


ПОПРОБУЙТЕ

Как вы изменили бы интерфейс, чтобы избежать неуклюжих конструкций, если бы могли изменить код Джилл?

20.1.2. Обобщение кода

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

Разумеется, все, что мы сделаем с данными Джека, относится и к данным Джилл. Однако между их программами есть два досадных различия: переменные

jack_count
и
jill_data–>size()
, а также конструкции
jack_data[i]
и
(*jill_data)[i]
. Последнее различие можно устранить, введя ссылку.


vector& v = *jill_data;

for (int i=0; i

 if (h

 {

   jill_high = &v[i];

   h = v[i];

  }


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


double* high(double* first, double* last)

// возвращает указатель на наибольший элемент в диапазоне [first,last]

{

 double h = –1;

 double* high;

 for(double* p = first; p!=last; ++p)

   if (h<*p)

   {

    high = p;

    h = *p;

   }

 return high;

}


Теперь можно написать следующий код:


double* jack_high = high(jack_data,jack_data+jack_count);

vector& v = *jill_data;

double* jill_high = high(&v[0],&v[0]+v.size());


Он выглядит получше. Мы не ввели слишком много переменных и написали только один цикл (в функции

high()
). Если мы хотим найти наибольший элемент, то можем посмотреть на значения
*jack_high
и
*jill_high
. Рассмотрим пример.


cout << "Максимум Джилл: " << *jill_high

    << "; максимум Джека: " << *jack_high;


Обратите внимание на то, что функция

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


ПОПРОБУЙТЕ

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

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


Функция

high()
решает одну конкретную задачу, поэтому она ограничена следующими условиями.

• Она работает только с массивами. Мы считаем, что элементы объекта класса

vector
хранятся в массиве, но наряду с этим существует множество способов хранения данных, таких как списки и ассоциативные массивы (см. разделы 20.4 и 21.6.1).

• Ее можно применять только к объектам класса

vector
и массивам типа
double
, но не к векторам и массивам с другими типами элементов, например
vector
и
char[10]
.

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


Попробуем обеспечить более высокую общность вычислений над нашими наборами данных.

Обратите внимание на то, что, решив выразить алгоритм поиска наибольшего элемента в терминах указателей, мы “случайно” уже обобщили решение задачи: при желании мы можем найти наибольший элемент массива или вектора, но, помимо этого, можем найти максимальный элемент части массива или вектора. Рассмотрим пример.


// ...

vector& v = *jill_data;

double* middle = &v[0]+v.size()/2;

double* high1 = high(&v[0], middle);      // максимум первой

                        // половины

double* high2 = high(middle, &v[0]+v.size()); // максимум второй

                        // половины

// ...


Здесь указатель

high1
ссылается на максимальный элемент первой половины вектора, а указатель
high2
— на максимальный элемент второй половины. Графически это можно изобразить следующим образом:



В качестве аргументов функции

high()
мы использовали указатели. Этот механизм управления памятью относится к слишком низкому уровню и уязвим для ошибок. Мы подозреваем, что большинство программистов для поиска максимального элемента в векторе написали бы нечто вроде следующего:


double* find_highest(vector& v)

{

 double h = –1;

 double* high = 0;

 for (int i=0; i

   if (h

   {

    high = &v[i];

    h = v[i];

   }

 return high;

}


Однако это не обеспечивает достаточно гибкости, которую мы “случайно” уже придали функции

high()
, — мы не можем использовать функцию
find_highest()
для поиска наибольшего элемента в части вектора. На самом деле, “связавшись с указателями”, мы достигли практической выгоды, получив функцию, которая может работать как с векторами, так и с массивами. Помните: обобщение может привести к функциям, которые позволяют решать больше задач.

20.2. Принципы библиотеки STL

Стандартная библиотека языка С++, обеспечивающая основу для работы с данными, представленными в виде последовательности элементов, называется STL. Обычно эту аббревиатуру расшифровывают как “стандартная библиотека шаблонов” (“standard template library”). Библиотека STL является частью стандарта ISO C++. Она содержит контейнеры (такие как классы

vector
,
list
и
map
) и обобщенные алгоритмы (такие как
sort
,
find
и
accumulate
). Следовательно, мы имеем право говорить, что такие инструменты, как класс
vector
, являются как частью библиотеки STL, так и стандартной библиотеки. Другие средства стандартной библиотеки, такие как потоки
ostream
(см. главу 10) и функции для работы строками в стиле языка С (раздел B.10.3), не являются частью библиотеки STL. Чтобы лучше оценить и понять библиотеку STL, сначала рассмотрим проблемы, которые мы должны устранить, работая с данными, а также обсудить идеи их решения.

Существуют два основных вычислительных аспекта: вычисления и данные. Иногда мы сосредоточиваем внимание на вычислениях и говорим об инструкциях

if
, циклах, функциях, обработке ошибок и пр. В других случаях мы фокусируемся на данных и говорим о массивах, векторах, строках, файлах и пр. Однако, для того чтобы выполнить полезную работу, мы должны учитывать оба аспекта. Большой объем данных невозможно понять без анализа, визуализации и поиска “чего-нибудь интересного”. И наоборот, мы можем выполнять вычисления так, как хотим, но такой подход оказывается слишком скучным и “стерильным”, пока мы не получим некие данные, которые свяжут наши вычисления с реальностью. Более того, вычислительная часть программы должна элегантно взаимодействовать с “информационной частью.



Говоря так о данных, мы подразумеваем много разных данных: десятки фигур, сотни значений температуры, тысячи регистрационных записей, миллионы точек, миллиарды веб-страниц и т.д.; иначе говоря, мы говорим о работе с контейнерами данных потоками данных и т.д. В частности, мы не рассматриваем вопросы, как лучше выбрать набор данных, представляющих небольшой объект, такой как комплексное число, запись о температуре или окружность. Эти типы описаны в главах 9, 11 и 14.

Рассмотрим простые примеры, которые иллюстрируют наше понятие о крупном наборе данных.

• Сортировка слов в словаре.

• Поиск номера в телефонной книге по заданному имени.

• Поиск максимальной температуры.

• Поиск всех чисел, превышающих 8800.

• Поиск первого появления числа 17.

• Сортировка телеметрических записей по номерам устройств.

• Сортировка телеметрических записей по временным меткам.

• Поиск первого значения, большего, чем строка “Petersen”.

• Поиск наибольшего объема.

• Поиск первого несовпадения между двумя последовательностями.

• Вычисление попарного произведения элементов двух последовательностей.

• Поиск наибольшей месячной температуры.

• Поиск первых десяти лучших продавцов по записям о продажах.

• Подсчет количества появлений слова “Stroustrup” в сети веб.

• Вычисление суммы элементов.


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

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

• Существует бесконечное множество вариантов типов данных (виды данных).

• Существует огромное количество способов организации коллекций данных.

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


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

Для того чтобы понять, какая поддержка нам нужна, чтобы написать наш код, рассмотрим, что мы можем делать с данными, более абстрактно. Итак, можно сделать следующее.

• Собирать данные в контейнерах

 • например, собирать их в объектах классов

vector
,
list
и массивах.

• Организовывать данные

 • для печати;

 • для быстрого доступа.

• Искать данные

 • по индексу (например, найти 42-й элемент);

 • по значению (например, найти первую запись, в которой в поле “age” записано число 7);

 • по свойствам (например, все записи, в которых значение поля “temperature” больше 32 и меньше 100).

• Модифицировать контейнер

 • добавлять данные;

 • удалять данные;

 • сортировать (в соответствии с каким-то критерием).

• Выполнять простые математические операции (например, умножить все элементы на 1,7).


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

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

• Использование типа

int
мало отличается от использования типа
double
.

• Использование типа

vector
мало отличается от использования типа
vector
.

• Использование массива чисел типа

double
мало отличается от использования типа
vector
.


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

• Поиск значения в объекте класса

vector
не должен отличаться от поиска значения в массиве.

• Поиск объекта класса

string
без учета регистра не должен отличаться от поиска объекта класса
string
с учетом нижнего и верхнего регистров.

• Графическое изображение экспериментальных данных с точными значениями не должно отличаться от графического изображения экспериментальных данных с округленными значениями.

• Копирование файла не должно отличаться от копирования вектора.


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

• его легко читать;

• легко модифицировать;

• он имеет систематический характер;

• он короткий;

• быстро работает.


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

• Единообразный доступ к данным:

 • независимость от способа хранения данных;

 • независимость от типа данных.

• Доступ к данным, безопасный с точки зрения типа:

 • легкое перемещение по данным;

 • компактное хранение данных.

• Скорость работы:

 • поиск данных;

 • добавление данных;

 • удаление данных.

• Стандартные версии большинства широко распространенных алгоритмов таких как

copy
,
find
,
search
,
sort
,
sum
, ...


Библиотека STL обеспечивает не только эти возможности. Мы изучим эту библиотеку не только потому, что она представляет собой очень полезный набор инструментов, но и потому, что является примером максимальной гибкости и эффективности. Библиотека STL была разработана Алексом Степановым (Alex Stepanov) для того, чтобы создать базу для универсальных, правильных и эффективных алгоритмов, работающих с разнообразными структурами данных. Ее целью были простота, универсальность и элегантность математики.

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

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

20.3. Последовательности и итераторы

Основным понятием в библиотеке STL является последовательность. С точки зрения авторов этой библиотеки, любая коллекция данных представляет собой последовательность. Последовательность имеет начало и конец. Мы можем перемещаться по последовательности от начала к концу, при необходимости считывая или записывая значение элементов. Начало и конец последовательности идентифицируются парой итераторов. Итератор (iterator) — это объект, идентифицирующий элемент последовательности.

Последовательность можно представить следующим образом:



Здесь

begin
и
end
— итераторы; они идентифицируют начало и конец последовательности. Последовательность в библиотеке STL часто называют “полуоткрытой” (“half-open”); иначе говоря, элемент, идентифицированный итератором
begin
, является частью последовательности, а итератор
end
ссылается на ячейку, следующую за концом последовательности. Обычно такие последовательности (диапазоны) обозначаются следующим образом:
[begin:end]
. Стрелки, направленные от одного элемента к другому, означают, что если у нас есть итератор на один элемент, то мы можем получить итератор на следующий.

• Что такое итератор? Это довольно абстрактное понятие.

• Итератор указывает (ссылается) на элемент последовательности (или на ячейку, следующую за последним элементом).

• Два итератора можно сравнивать с помощью операторов

==
и
!=
.

• Значение элемента, на который установлен итератор, можно получить с помощью унарного оператора

*
(“разыменование”).

• Итератор на следующий элемент можно получить, используя оператор

++
.


Допустим, что

p
и
q
— итераторы, установленные на элементы одной и той же последовательности.



Очевидно, что идея итератора связана с идеей указателя (см. раздел 17.4). Фактически указатель на элемент массива является итератором. Однако многие итераторы являются не просто указателями; например, мы могли бы определить итератор с проверкой выхода за пределы допустимого диапазона, который генерирует исключение при попытке сослаться за пределы последовательности

[begin:end]
или разыменовать итератор
end
. Оказывается, что итератор обеспечивает огромную гибкость и универсальность именно как абстрактное понятие, а не как конкретный тип. В этой и следующей главах при приведем еще несколько примеров.


ПОПРОБУЙТЕ

Напишите функцию

void copy(int* f1, int* e1, int* f2)
, копирующую элементы массива чисел типа
int
, определенного последовательностью
[f1:e1]
в другую последовательность
[f2:f2+(e1–f1)]
. Используйте только упомянутые выше итераторы (а не индексирование).


Итераторы используются в качестве средства связи между нашим кодом (алгоритмами) и нашими данными. Автор кода знает о существовании итераторов (но не знает, как именно они обращаются к данным), а поставщик данных предоставляет итераторы, не раскрывая всем пользователям детали механизма хранения данных. В результате получаем достаточно независимые друг от друга алгоритмы и контейнеры. Процитируем Алекса Степанова: “Алгоритмы и контейнеры библиотеки STL потому так хорошо работают друг с другом, что ничего не знают друг о друге”. Вместо этого и алгоритмы, и контейнеры знают о последовательностях, определенных парами итераторов.



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

*
,
++
,
==
и
!=
. Это обеспечивает его простоту и быстродействие.

Библиотека STL содержит около десяти контейнеров и 60 алгоритмов, связанных с итераторами (см. главу 21). Кроме того, многие организации и отдельные лица создают контейнеры и алгоритмы в стиле библиотеки STL. Вероятно, библиотека STL в настоящее время является наиболее широко известным и широко используемым примером обобщенного программирования (см. раздел 19.3.2). Если вы знаете основы и несколько примеров, то сможете использовать и все остальное.

20.3.1. Вернемся к примерам

Посмотрим, как можно решить задачу “найти максимальный элемент” с помощью последовательности STL.


template

Iterator high(Iterator first, Iterator last)

// возвращает итератор на максимальный элемент в диапазоне [first:last]

{

 Iterator high = first;

 for (Iterator p = first; p!=last; ++p)

   if (*high<*p) high = p;

 return high;

}


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

h
, которую до сих пор использовали для хранения максимального элемента. Если вам неизвестен реальный тип элементов последовательности, то инициализация
–1
выглядит совершенно произвольной и странной. Она действительно является произвольной и странной! Кроме того, такая инициализация представляет собой ошибку: в нашем примере число
1
оправдывает себя только потому, что отрицательных скоростей не бывает. Мы знаем, что “магические константы”, такие как
–1
, препятствуют сопровождению кода (см. разделы 4.3.1, 7.6.1, 10.11.1 и др.). Здесь мы видим, что такие константы могут снизить полезность функции и свидетельствовать о неполноте решения; иначе говоря, “магические константы” могут быть — и часто бывают — свидетельством небрежности.

Обобщенную функцию

high()
можно использовать для любых типов элементов, которые можно сравнивать с помощью операции
<
. Например, мы могли бы использовать функцию
high()
для поиска лексикографически последней строки в контейнере
vector
(см. упр. 7).

Шаблонную функцию

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


double* get_from_jack(int* count); // Джек вводит числа типа double

 // в массив 
и возвращает количество

 // элементов в переменной *count

vector* get_from_jill(); // Джилл заполняет вектор


void fct()

{

 int jack_count = 0;

 double* jack_data = get_from_jack(&jack_count);

  vector* jill_data = get_from_jill();


 double* jack_high = high(jack_data,jack_data+jack_count);

 vector& v = *jill_data;

 double* jill_high = high(&v[0],&v[0]+v.size());

 cout << "Максимум Джилл " << *jill_high

    << "; Максимум Джека" << *jack_high;

 // ...

 delete[] jack_data;

 delete jill_data;

}


Здесь в двух вызовах функции

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


ПОПРОБУЙТЕ

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

20.4. Связанные списки

Еще раз рассмотрим графическое представление последовательности.



Сравним его с визуализацией вектора, хранящегося в памяти.



По существу, индекс

0
означает тот же элемент, что и итератор
v.begin()
, а функция
v.size()
идентифицирует элемент, следующий за последним, который можно также указать с помощью итератора
v.end()
.

Элементы в векторе располагаются в памяти последовательно. Понятие последовательности в библиотеки STL этого не требует. Это позволяет многим алгоритмам вставлять элементы между существующими элементами без их перемещения. Графическое представление абстрактного понятия последовательности предполагает возможность вставки (и удаления) элементов без перемещения остальных элементов. Понятие итераторов в библиотеки STL поддерживает эту концепцию.

Структуру данных, которая точнее всех соответствует диаграмме последовательности в библиотеке STL, называют связанным списком (linked list). Стрелки в абстрактной модели обычно реализуются как указатели. Элемент связанного списка — это часть узла, состоящего из элемента и одного или нескольких указателей. Связанный список, в котором узел содержит только один указатель (на следующий узел), называют односвязным списком (singly-linked list), а список, в которой узел ссылается как на предыдущий, так и на следующий узлы, — двусвязным списком (doubly-linked list). Мы схематично рассмотрим реализацию двухсвязных списков, которые в стандартной библиотеке языка С++ имеют имя

list
. Графически список можно изобразить следующим образом.



В виде кода он представляется так:


template struct Link {

 Link* prev; // предыдущий узел

 Link* succ; // следующий узел

 Elem val;  // значение

};


template struct list {

 Link* first;

 Link* last; // узел, находящийся за последним узлом

};


Схема класса

Link
приведена ниже.



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

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

20.4.1. Операции над списками

Какие операции необходимы для списка?

• Операции, эквивалентные операциям над векторами (создание, определение размера и т.д.), за исключением индексирования.

• Вставка (добавление элемента) и стирание (удаление элемента).

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


В библиотеке STL тип итератора является членом своего класса, поэтому и мы поступим так же.


template class list {

// детали представления и реализации

public:

 class iterator;   // тип — член класса :iterator

 iterator begin();  // итератор, ссылающийся на первый элемент

 iterator end( );   // итератор, ссылающийся на последний элемент


 iterator insert(iterator p, const Elem& v); // вставка v

            // в список
 после элемента,

            // на который установлен 
итератор p


 iterator erase(iterator p);   // удаление из списка элемента,

            // на который установлен 
итератор p


 void push_back(const Elem& v);  // вставка v в конец списка

  void push_front(const Elem& v); // вставка v в начало списка

  void pop_front();        // удаление первого элемента

  void pop_back();         // удаление последнего элемента


  Elem& front();          // первый элемент

  Elem& back();          // последний элемент

  // ...

}


Так же как наш класс

vector
не совпадал с полной версией стандартного вектора, так и класс
list
— это далеко не полное определение стандартного списка. В этом определении все правильно; просто оно неполное. Цель “нашего” класса
list
— объяснить устройство связанных списков, продемонстрировать их реализацию и показать способы использования их основных возможностей. Более подробная информация приведена в приложении Б и в книгах о языке С++, предназначенных для экспертов.

Итератор играет главную роль в определении класса

list
в библиотеке STL. Итераторы используются для идентификации места вставки или удаления элементов. Кроме того, их используют для “навигации” по списку вместо оператора индексирования. Такое применение итераторов очень похоже на использование указателей при перемещении по массивам и векторам, описанном в разделах 20.1 и 20.3.1. Этот вид итераторов является основным в стандартных алгоритмах (разделы 21.1–21.3).

Почему в классе

list
не используется индексирование? Мы могли бы проиндексировать узлы, но эта операция удивительно медленная: для того чтобы достичь элемента
lst[1000]
, нам пришлось бы начинать с первого элемента и пройти все элементы по очереди, пока мы не достигли бы элемента с номером
1000
. Если вы хотите этого, то можете реализовать эту операцию сами (или применить алгоритм
advance()
; см. раздел 20.6.2). По этой причине стандартный класс
list
не содержит операции индексирования.

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

iterator
. В стандартной библиотеке есть
list::iterator
,
vector::iterator
,
map::iterator
и т.д.

20.4.2. Итерация

Итератор списка должен обеспечивать выполнение операций

*
,
++
,
==
и
!=
. Поскольку стандартный список является двухсвязным, в нем также есть операция –– для перемещения назад, к началу списка.


template class list::iterator {

 Link* curr; // текущий узел

public:

  iterator(Link* p):curr(p) { }


  // вперед

  iterator& operator++() {curr = curr–>succ; return *this; }


  // назад

  iterator& operator––() { curr = curr–>prev; return *this; }


  // (разыменовать)

  Elem& operator*() { return curr–>val; } // получить значение

  bool operator==(const iterator& b) const

   { return curr==b.curr; }

  bool operator!= (const iterator& b) const

   { return curr!=b.curr; }

};


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

list::iterator
сильно отличается от обычного указателя, который использовался в качестве итератора для векторов и массивов, их семантика одинакова. По существу, итератор списка обеспечивает удобные операции
++
,
––
,
*
,
==
, and
!=
для указателя на узел.

Посмотрим на функцию

high()
еще раз.


template

Iterator high(Iterator first, Iterator last)

// возвращает итератор на максимальный элемент в диапазоне

// [first,last)

{

 Iterator high = first;

 for (Iterator p = first; p!=last; ++p)

   if (*high<*p) high = p;

 return high;

}


Мы можем применить ее к объекту класса

list
.


void f()

{

 list lst;

 int x;

 while (cin >> x) lst.push_front(x);


 list::iterator p = high(lst.begin(), lst.end());

 cout << "Максимальное значение = " << *p << endl;

}


Здесь значением аргумента класса

Iterator
argument является класс
list::iterator
, а реализация операций
++
,
*
и
!=
совершенно отличается от массива, хотя ее смысл остается неизменным. Шаблонная функция
high()
по-прежнему перемещается по данным (в данном случае по объекту класса
list
) и находит максимальное значение. Мы можем вставлять элементы в любое место списка, так что мы использовали функцию
push_front()
для добавления элементов в начало списка просто для иллюстрации. С таким же успехом мы могли бы использовать функцию
push_back()
, как делали это для объектов класса
vector
.


ПОПРОБУЙТЕ

В стандартном классе

vector
нет функции
push_front()
. Почему? Реализуйте функцию
push_front()
для класса
vector
и сравните ее с функцией
push_back()
.


Итак, настало время спросить: “А что, если объект класса

list
будет пустым?” Иначе говоря, “что если
lst.begin()==lst.end()
?” В данном случае выполнение инструкции
*p
будет попыткой разыменования элемента, следующего за последним, т.е.
lst.end()
. Это катастрофа! Или, что еще хуже, в результате можно получить случайную величину, которая исказит правильный ответ.

Последняя формулировка вопроса содержит явную подсказку: мы можем проверить, пуст ли список, сравнив итераторы

begin()
и
end()
, — по существу, мы можем проверить, пуста ли последовательность, сравнивая ее начало и конец.



Существует важная причина, по которой итератор

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

В нашем примере можно поступить следующим образом:


list::iterator p = high(lst.begin(), lst.end());

if (p==lst.end())     // мы достигли конца?

 cout << "Список пустой";

else

 cout << "максимальное значение = " << *p << endl;


Работая с алгоритмами из библиотеки STL, мы систематически используем эту проверку. Поскольку в стандартной библиотеке список предусмотрен, не будем углубляться в детали его реализации. Вместо этого кратко укажем, чем эти списки удобны (если вас интересуют детали реализации списков, выполните упр. 12–14).

20.5. Еще одно обобщение класса vector

Из примеров, приведенных в разделах 20.3 и 20.4, следует, что стандартный вектор имеет член класса, являющийся классом

iterator
, а также функции-члены
begin()
и
end()
(как и класс
std::list
). Однако мы не указали их в нашем классе
vector
в главе 19. Благодаря чему разные контейнеры могут использоваться более или менее взаимозаменяемо в обобщенном программировании, описанном в разделе 20.3? Сначала опишем схему решения (игнорируя для простоты распределители памяти), а затем объясним ее.


template class vector {

public:

 typedef unsigned long size_type;

 typedef T value_type;

  typedef T* iterator;

 typedef const T* const_iterator;


 // ...


  iterator begin();

 const_iterator begin() const;

 iterator end();

 const_iterator end() const;


 size_type size();

 // ...

};


Оператор

typedef
создает синоним типа; иначе говоря, для нашего класса
vector
имя
iterator
— это синоним, т.е. другое имя типа, который мы решили использовать в качестве итератора:
T*
. Теперь для объекта
v
класса
vector
можно написать следующие инструкции:


vector::iterator p = find(v.begin(), v.end(),32);


и


for (vector::size_type i = 0; i

 cout << v[i] << '\n';


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

iterator
и
size_type
. В частности, в приведенном выше коде, выраженном через типы iterator и
size_type
, мы будем работать с векторами, в которых тип
size_type
— это не
unsigned long
(как во многих процессорах встроенных систем), а тип
iterator
— не простой указатель, а класс (как во многих широко известных реализациях языка C++).

В стандарте класс

list
и другие стандартные контейнеры определены аналогично. Рассмотрим пример.


template class list {

public:

 class Link;

 typedef unsigned long size_type;

 typedef Elem value_type;

 class iterator;     // см. раздел 20.4.2

 class const_iterator;  // как iterator, но допускает изменение

             // элементов


 // ...


 iterator begin();

 const_iterator begin() const;

 iterator end();

 const_iterator end() const;

  size_type size();

  // ...

};


Таким образом, можно писать код, не беспокоясь о том, что он использует: класс

list
или
vector
. Все стандартные алгоритмы определены в терминах этих имен типов, таких как
iterator
и
size_type
, поэтому они не зависят от реализации контейнеров или их вида (подробнее об этом — в главе 21).

20.6. Пример: простой текстовый редактор

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

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

• Создание документа из потока байтов, поступающих из потока ввода.

• Вставка одного или нескольких символов.

• Удаление одного или нескольких символов.

• Поиск строки.

• Генерирование потока байтов для вывода в файл или на экран.


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

vector
. Однако, чтобы добавить или удалить символ в векторе, нам пришлось бы переместить все последующие символы в документе. Рассмотрим пример.


This is he start of a very long document.

There are lots of ...


Мы могли бы добавить недостающий символ t и получить следующий текст:


This is the start of a very long document.

There are lots of ...


Однако, если бы эти символы хранились в отдельном объекте класса

vector
, мы должны были бы переместить все символы, начиная с буквы
h
на одну позицию вправо. Для этого пришлось бы копировать много символов. По существу, для документа, состоящего из 70 тыс. символов (как эта глава с учетом пробелов), при вставке или удалении символа в среднем нам пришлось бы переместить 35 тыс. символов. В результате временная задержка стала бы заметной и досадной для пользователей. Вследствие этого мы решили разбить наше представление на “порции” и изменять часть документа так, чтобы не перемещать большие массивы символов. Мы представим документ в виде списка строк с помощью класса
list
, где шаблонный параметр
Line
— это класс
vector
. Рассмотрим пример.



Теперь для вставки символа

t
достаточно переместить только остальные символы из этой строки. Более того, при необходимости можем добавить новую строку без перемещения каких-либо символов. Для примера рассмотрим вставку строки “This is a new line.” после слова “document.”.


This is the start of a very long document.

This is a new line.

There are lots of ...


Все, что нам для этого нужно, — добавить новую строку в середину.



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

vector::iterator>
, в котором хранятся итераторы, установленные на начало каждого заголовка и подзаголовка из текущего объекта класса
Document
.



Мы можем добавить строки в “paragraph 20.2”, не нарушая целостности итератора, установленного “paragraph 20.3.”

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

vector
” по-прежнему действует. Нужна особая причина, чтобы предпочесть класс
list
классу
vector
, — даже, если вы представляете свои данные только в виде списка! (См. раздел 20.7.) Список — это логическое понятие, которое в вашей программе можно представить с помощью как класса
list
(связанного списка), так и класса
vector
. В библиотеке STL ближайшим аналогом нашего бытового представления о списке (например, список дел, товаров или расписание) является последовательность, а большинство последовательностей лучше всего представлять с помощью класса
vector
.

20.6.1. Строки

Как решить, что такое строка в нашем документе? Есть три очевидные альтернативы.

1. Полагаться на индикаторы новых строк (например,

'\n'
) в строке ввода.

2. Каким-то образом разделить документ и использовать обычную пунктуацию (например,

.
).

3. Разделить строку, длина которой превышает некий порог (например, 50 символов), на две.


Кроме этого, несомненно, существуют менее очевидные варианты. Для простоты выберем первую альтернативу.

Представим документ в нашем редакторе в виде объекта класса

Document
. Схематически наш тип должен выглядеть примерно так:


typedef vector Line;  // строка — это вектор символов


struct Document {

 list line;     // документ — список строк

  Document() { line.push_back(Line()); }

};


Каждый объект класса

Document
начинается с пустой строки: конструктор класса
Document
сначала создает пустую строку, а затем заполняет список строка за строкой.

Чтение и разделение на строки можно выполнить следующим образом:


istream& operator>>(istream& is, Document& d)

{

 char ch;

 while (is.get(ch)) {

   d.line.back().push_back(ch); // добавляем символ

   if (ch=='\n')

    d.line.push_back(Line());  // добавляем новую строку

 }

 if (d.line.back().size())

   d.line.push_back(Line());   // добавляем пустую строку

 return is;

}


Классы

vector
и
list
имеют функцию-член
back()
, возвращающую ссылку на последний элемент. Для ее использования вы должны быть уверены, что она действительно ссылается на последний элемент, — функцию
back()
нельзя применять к пустому контейнеру. Вот почему в соответствии с определением каждый объект класса
Document
должен содержать пустой объект класса
Line
. Обратите внимание на то, что мы храним каждый введенный символ, даже символы перехода на новую строку (
'\n'
). Хранение символов перехода на новую строку сильно упрощает дело, но при подсчете символов следует быть осторожным (простой подсчет символов будет учитывать пробелы и символы перехода на новую строку).

20.6.2. Итерация

Если бы документ хранился как объект класса

vector
, перемещаться по нему было бы просто. Как перемещать итератор по списку строк? Очевидно, что перемещаться по списку можно с помощью класса
list::iterator
. Однако, что, если мы хотим пройтись по символам один за другим, не беспокоясь о разбиении строки? Мы могли бы использовать итератор, специально разработанный для нашего класса
Document
.


class Text_iterator { // отслеживает позицию символа в строке

 list::iterator ln;

 Line::iterator pos;

public:

 // устанавливает итератор на позицию pp в ll-й строке

 Text_iterator(list::iterator ll, Line::iterator pp)

 :ln(ll), pos(pp) { }


 char& operator*() { return *pos; }

 Text_iterator& operator++();


  bool operator==(const Text_iterator& other) const

   { return ln==other.ln && pos==other.pos; }


 bool operator!=(const Text_iterator& other) const

   { return !(*this==other); }

};


Text_iterator& Text_iterator::operator++()

{

 if (pos==(*ln).end()) {

   ++ln; // переход на новую строку

   pos = (*ln).begin();

 }

 ++pos; // переход на новый символ

 return *this;

}


Для того чтобы класс

Text_iterator
стал полезным, необходимо снабдить класс
Document
традиционными функциями
begin()
и
end()
.


struct Document {

 list line;

 Text_iterator begin() // первый символ первой строки

   { return Text_iterator(line.begin(),

   (*line.begin()).begin()); }

 Text_iterator end()  // за последним символом последней строки

   { return(line.end(), (*line.end()).end));}

};


Мы использовали любопытную конструкцию

(*line.begin()).begin()
, потому что хотим начинать перемещение итератора с позиции, на которую ссылается итератор
line.begin()
; в качестве альтернативы можно было бы использовать функцию
line.begin()–>begin()
, так как стандартные итераторы поддерживают операцию
–>
.

Теперь можем перемещаться по символам документа.


void print(Document& d)

{

 for (Text_iterator p = d.begin();

 p!=d.end(); ++p) cout << *p;

}

print(my_doc);


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

n
.


void erase_line(Document& d, int n)

{

  if (n<0 || d.line.size()<=n) return; // игнорируем строки,

                    // находящиеся

                     // за пределами диапазона

 d.line.erase(advance(d.line.begin(), n));

}


Вызов

advance(p,n)
перемещает итератор
p
на
n
элементов вперед; функция
advance()
— это стандартная функция, но мы можем сами написать подобный код.


template Iter advance(Iter p, int n)

{

   while (n>0) { ++p; ––n; } // перемещение вперед

   return p;

}


Обратите внимание на то, что функцию

advance()
можно использовать для имитации индексирования. Фактически для объекта класса
vector
с именем
v
выражение
*advance(v.begin(),n)
почти эквивалентно конструкции
v[n]
. Здесь слово “почти” означает, что функция
advance()
старательно проходит по каждому из первых
n–1
элементов шаг за шагом, в то время как операция индексирования сразу обращается к
n
-му элементу. Для класса
list
мы вынуждены использовать этот неэффективный метод. Это цена, которую мы должны заплатить за гибкость списка.

Если итератор может перемещаться вперед и назад, например в классе

list
, то отрицательный аргумент стандартной библиотечной функции
advance()
означает перемещение назад. Если итератор допускает индексирование, например в классе
vector
, стандартная библиотечная функция
advance()
сразу установит его на правильный элемент и не будет медленно перемещаться по всем элементам с помощью оператора
++
. Очевидно, что стандартная функция
advance()
немного “умнее” нашей. Это стоит отметить: как правило, стандартные средства создаются более тщательно, и на них затрачивается больше времени, чем мы могли бы затратить на самостоятельную разработку, поэтому мы отдаем предпочтение стандартным инструментам, а не “кустарным”.


ПОПРОБУЙТЕ

Перепишите нашу функцию

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


Вероятно, поиск — это самый очевидный вид итерации. Мы ищем отдельные слова (например,

milkshake
или
Gavin
), последовательности букв (например,
secret\nhomestead
— т.е. строка, заканчивающаяся словом
secret
, за которым следует строка, начинающаяся словом
homestead
), регулярные выражения (например,
[bB]\w*ne
— т.е. буква
B
в верхнем или нижнем регистре, за которой следует
0
или больше букв, за которыми следуют буквы
ne
; см. главу 23) и т.д. Покажем, как решить вторую задачу: найдем строку, используя нашу схему хранения объекта класса Document. Будем использовать простой — не оптимальный — алгоритм.

• Найдем первый символ искомой строки в документе.

• Проверим, совпадают ли эти и следующие символы с символами искомой строки.

• Если совпадают, то задача решена; если нет, будем искать следующее появление первого символа.


Для простоты примем правила представления текстов в библиотеке STL в виде последовательности, определенной парой итераторов. Это позволит нам применить функцию поиска не только ко всему документу, но и к любой его части. Если мы найдем нашу строку в документе, то вернем итератор, установленный на ее первый символ; если не найдем, то вернем итератор, установленный на конец последовательности.


Text_iterator find_txt(Text_iterator first,

  Text_iterator last, const string& s)

{

 if (s.size()==0) return last; // нельзя искать пустую строку

 char first_char = s[0];

  while (true) {

   Text_iterator p = find(first,last,first_char);

   if (p==last || match(p,last,s)) return p;

   ++first;           // ищем следующий символ

  }

}


Возврат конца строки в качестве признака неудачного поиска является важным соглашением, принятым в библиотеке STL. Функция

match()
является тривиальной; она просто сравнивает две последовательности символов. Попробуйте написать ее самостоятельно. Функция
find()
, используемая для поиска символа в последовательности, вероятно, является простейшим стандартным алгоритмом (раздел 21.2). Мы можем использовать свою функцию
find_txt()
примерно так:


Text_iterator p =

  find_txt(my_doc.begin(), my_doc.end(),"secret\nhomestead");

if (p==my_doc.end())

  cout << "Не найдена ";

else {

  // какие-то действия

}


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

list
и
vector
) в сочетании с правилами программирования (приемами), принятыми в библиотеке STL, согласно которым возврат итератора, установленного на конец последовательности, является признаком неудачи. Обратите внимание на то, что если бы мы захотели, то могли бы превратить класс
Document
в контейнер STL, снабдив его итератором
Text_iterator
. Мы сделали главное для представления объекта класса
Document
в виде последовательности значений.

20.7. Классы vector, list и string

Почему для хранения строк мы используем класс

list
, а для символов — класс
vector
? Точнее, почему для хранения последовательности строк мы используем класс
list
, а для хранения последовательности символов — класс
vector
? Более того, почему для хранения строки мы не используем класс
string
?

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

char[]
(массив символов)

vector

string

list


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

++
и использовать оператор
*
для доступа к символам. Если посмотреть на примеры кода, связанного с классом Document, то мы действительно можем заменить наш класс
vector
классом
list
или
string
без каких-либо проблем. Такая взаимозаменяемость является фундаментальным преимуществом, потому что она позволяет нам сделать выбор, ориентируясь на эффективность. Но, перед тем как рассматривать вопросы эффективности, мы должны рассмотреть логические возможности этих типов: что такого может делать каждый из них, чего не могут другие?

Elem[]
. Не знает своего размера. Не имеет функций
begin()
,
end()
и других контейнерных функций-членов. Не может систематически проверять выход за пределы допустимого диапазона. Может передаваться функциям, написанным на языке C или в стиле языка C. Элементы в памяти располагаются последовательно в смежных ячейках. Размер массива фиксируется на этапе компиляции. Операции сравнения (
==
и
!=
) и вывода (
<<
) используют указатель на первый элемент массива, а не на все элементы.

vector
. Может выполнять практически все, включая функции
insert()
и
erase()
. Предусматривает индексирование. Операции над списками, такие как
insert()
и
erase()
, как правило, связаны с перемещением элементов (что может оказаться неэффективным для крупных элементов и при большом количестве элементов). Может проверять выход за пределы допустимого диапазона. Элементы в памяти располагаются последовательно в смежных ячейках. Объект класса
vector
может увеличиваться (например, использует функцию
push_back()
). Элементы вектора хранятся в массиве (непрерывно). Сравнение элементов осуществляется с помощью операторов
==
,
!=
,
<
,
<=
,
>
и
>=
.

string
. Предусматривает все обычные и полезные операции, а также специфические манипуляции текстами, такие как конкатенация (
+
и
+=
). Элементы хранятся в смежных ячейках памяти. Объект класса
string
можно увеличивать. Сравнение элементов осуществляется с помощью операторов
==
,
!=
,
<
,
<=
,
>
и
>=
.

list
. Предусматривает все обычные и полезные операции, за исключением индексирования. Операции
insert()
и
delete()
можно выполнять без перемещения остальных элементов. Для хранения каждого элемента необходимы два дополнительных слова (для указателей на узлы). Объект класса
list
можно увеличивать. Сравнение элементов осуществляется с помощью операторов (
==
,
!=
,
<
,
<=
,
>
и
>=
).


Как мы уже видели (см. разделы 17.2 и 20.5), массивы полезны и необходимы для управления памятью на самом нижнем уровне, а также для обеспечения взаимодействия с программами, написанными на языке C (подробнее об этом — в разделах 27.1.2 и 27.5). В отличие от этого, класс

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


ПОПРОБУЙТЕ

Что означает этот список отличий в реальном коде? Определите массивы объектов типа

char
,
vector
,
list
и
string
со значением "
Hello
", передайте его в функцию в качестве аргумента, напишите количество символов в передаваемой строке, попытайтесь сравнить его со строкой "
Hello
" в функции (чтобы убедиться, что вы действительно передали строку "
Hello
"), а затем сравните аргумент со строкой "
Howdy
", чтобы увидеть, какое из этих слов появляется в словаре первым. Скопируйте аргумент в другую переменную того же типа.


ПОПРОБУЙТЕ

Выполните предыдущее задание ПОПРОБУЙТЕ для массива объектов типа

int
,
vector
и
list
со значениями {
1
,
2
,
3
,
4
,
5
} .

20.7.1. Операции insert и erase

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

insert()
и
erase()
), в векторе происходит перемещение остальных элементов; это может оказаться связано с неприемлемыми затратами, если вектор содержит большое количество элементов или элементы вектора сами являются крупными объектами. Однако слишком беспокоиться об этом не следует. Мы без заметных проблем считали полмиллиона значений с плавающей точкой в вектор, используя функцию
push_back()
. Измерения подтвердили, что предварительное выделение памяти не приводит к заметным последствиям. Прежде чем вносить значительные изменения, стремясь к эффективности, проведите измерения (угадать степень эффективности кода трудно даже экспертам).

Как указывалось в разделе 20.6, перемещение элементов связано с логическим ограничением: выполняя операции, характерные для списков (такие как

insert()
,
erase()
,
and push_back()
), не следует хранить итераторы или указатели на элементы вектора. Если элемент будет перемещен, ваш итератор или указатель будет установлен на неправильный элемент или вообще может не ссылаться на элемент вектора. В этом заключается принципиальное преимущество класса
list
(и класса
map
; см. раздел 21.6) над классом
vector
. Если вам необходима коллекция крупных объектов или приходится ссылаться на объекты во многих частях программы, рассмотрите возможность использовать класс
list
.

Сравним функции

insert()
и
erase()
в классах
vector
и
list
. Сначала рассмотрим пример, разработанный специально для того, чтобы продемонстрировать принципиальные моменты.


vector::iterator p = v.begin();  // получаем вектор

++p; ++p; ++p;             // устанавливаем итератор

                    // на 4-й элемент

vector::iterator q = p;

++q;                  // устанавливаем итератор

                    // на 5-й элемент



p = v.insert(p,99); // итератор p ссылается на вставленный элемент



Теперь итератор

q
является неправильным. При увеличении размера вектора элементы могли быть перемещены в другое место. Если вектор
v
имеет запас памяти, то он будет увеличен на том же самом месте, а итератор
q
скорее всего будет ссылаться на элемент со значением
3
, а не на элемент со значением
4
, но не следует пытаться извлечь из этого какую-то выгоду.


p = v.erase(p); // итератор p ссылается на элемент,

         // следующий за стертым



Иначе говоря, если за функцией

insert()
следует функция
erase()
, то содержание вектора не изменится, но итератор
q
станет некорректным. Однако если между ними мы переместим все элементы вправо от точки вставки, то вполне возможно, что при увеличении размера вектора
v
все элементы будут размещены в памяти заново.

Для сравнения мы проделали то же самое с объектом класса

list
:


list::iterator p = v.begin(); // получаем список

++p; ++p; ++p;           // устанавливаем итератор

                   // на 4-й элемент

list::iterator q = p;

++q;                // устанавливаем итератор

                   // на 5-й элемент



p = v.insert(p,99); // итератор р ссылается на вставленный элемент



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

q
по-прежнему ссылается на элемент, имеющий значение
4
.


p = v.erase(p);  // итератор р ссылается на элемент, следующий

         // за удаленным


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

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

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

list
занимает по меньшей мере в три раза больше памяти, чем остальные три альтернативы, — в компьютере объект класса
list
использует
12
байтов на элемент; объект класса
vector
— один байт на элемент. Для большого количества символов это обстоятельство может оказаться важным. В чем заключается преимущество класса
vector
над классом
string
? На первый взгляд, список их возможностей свидетельствует о том, что класс
string
может делать все то же, что и класс
vector
, и даже больше. Это оказывается проблемой: поскольку класс
string
может делать намного больше, его труднее оптимизировать. Оказывается, что класс
vector
можно оптимизировать с помощью операций над памятью, таких как
push_back()
, а класс
string
— нет. В то же время в классе
string
можно оптимизировать копирование при работе с короткими строками и строками в стиле языка C. В примере, посвященном текстовому редактору, мы выбрали класс
vector
, так как использовали функции
insert()
и
delete()
. Это решение объяснялось вопросами эффективности. Основное логическое отличие заключается в том, что мы можем создавать векторы, содержащие элементы практически любых типов. У нас появляется возможность выбора, только если мы работаем с символами. В заключение мы рекомендуем использовать класс
vector
, а не
string
, если нам нужны операции на строками, такие как конкатенации или чтение слов, разделенных пробелами.

20.8. Адаптация нашего класса vector к библиотеке STL

После добавления функций

begin()
,
end()
и инструкций
typedef
в разделе 20.5 в классе vector не достает только функций
insert()
и
erase()
, чтобы стать близким аналогом класса
std::vector
.


template > class vector {

 int sz;   // размер

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

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

 A alloc;  // использует распределитель памяти для элементов

public:

 // ...все остальное описано в главе 19 и разделе 20.5...

 typedef T* iterator; // T* — максимально простой итератор


 iterator insert(iterator p, const T& val);

 iterator erase(iterator p);

};


Здесь мы снова в качестве типа итератора использовали указатель на элемент типа

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

Как правило, люди не пишут операции над списками, такие как

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

По существу, мы реализовали функцию

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


template

vector::iterator vector::erase(iterator p)

{

 if (p==end()) return p;

 for (iterator pos = p+1; pos!=end(); ++pos)

   *(pos–1) = *pos; // переносим элемент на одну позицию влево

 alloc.destroy(&*(end()-1)); // уничтожаем лишнюю копию

               // последнего элемента

 ––sz;

 return p;

}


Этот код легче понять, если представить его в графическом виде.



Код функции

erase()
довольно прост, но, возможно, было бы проще попытаться разобрать несколько примеров на бумаге. Правильно ли обрабатывается пустой объект класса
vector
? Зачем нужна проверка
p==end()
? Что произойдет после удаления последнего элемента вектора? Не было бы легче читать этот код, если бы мы использовали индексирование?

Реализация функции

vector::insert()
является немного более сложной.


template

vector::iterator vector::insert(iterator p, const T& val)

{

 int index = p–begin();

  if (size()==capacity())

   reserve(size() = 0 ? 8 : 2*size()); // убедимся, что

                     // есть место

 // сначала копируем последний элемент в неинициализированную ячейку:

 alloc.construct(elem+sz,*back());

 ++sz;

 iterator pp = begin()+index; // место для записи значения val

 for (iterator pos = end()–1; pos!=pp; ––pos)

   *pos = *(pos–1); // переносим элемент на одну позицию вправо

 *(begin()+index) = val; // "insert" val

 return pp;

}


Обратите внимание на следующие факты.

• Итератор не может ссылаться на ячейку, находящуюся за пределами последовательности, поэтому мы используем указатели, такие как

elem+space
. Это одна из причин, по которым распределители памяти реализованы на основе указателей, а не итераторов.

• Когда мы используем функцию

reserve()
, элементы могут быть перенесены в новую область памяти. Следовательно, мы должны запомнить индекс вставленного элемента, а не итератор, установленный на него. Когда элементы вектора перераспределяются в памяти, итераторы, установленные на них, становятся некорректными — их можно интерпретировать как ссылки на старые адреса.

• Наше использование распределителя памяти

A
является интуитивным, но не точным. Если вам придется реализовывать контейнер, то следует внимательно изучить стандарт.

• Тонкости, подобные этим, позволяют избежать непосредственной работы с памятью на нижнем уровне. Естественно, стандартный класс

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


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

insert()
и
erase()
к среднему элементу вектора, состоящего из 100 тыс. элементов; для этого лучше использовать класс
list
(и класс map; см. раздел 21.6). Однако операции
insert()
и
erase()
можно применять ко всем векторам, а их производительность при перемещении небольшого количества данных является непревзойденной, поскольку современные компьютеры быстро выполняют такое копирование (см. упр. 20). Избегайте (связанных) списков, состоящих из небольшого количества маленьких элементов.

20.9. Адаптация встроенных массивов к библиотеке STL

Мы многократно указывали на недостатки встроенных массивов: они неявно преобразуют указатели при малейшем поводе, их нельзя скопировать с помощью присваивания, они не знают своего размера (см. раздел 20.5.2) и т.д. Кроме того, мы отмечали их преимущества: они превосходно моделируют физическую память.

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

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


template  // не вполне стандартный массив

struct array {

 typedef T value_type;

 typedef T* iterator;

 typedef T* const_iterator;

 typedef unsigned int size_type; // тип индекса


 T elems[N];

 // не требуется явное создание/копирование/уничтожение


 iterator begin() { return elems; }

 const_iterator begin() const { return elems; }

 iterator end() { return elems+N; }

 const_iterator end() const { return elems+N; }


 size_type size() const;


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

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


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

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


 T * data() { return elems; }

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

};


Это определение не полно и не полностью соответствует стандарту, но оно хорошо иллюстрирует основную идею. Кроме того, оно позволяет использовать класс

array
, если его нет в вашей стандартной библиотеке. Если же он есть, то искать его следует в заголовке
. Обратите внимание на то, что поскольку объекту класса
array
известен его размер
N
, мы можем (и должны) предусмотреть операторы
=
,
==
,
!=
как для класса
vector
.

Например, используем массив со стандартной функцией

high()
из раздела 20.4.2:


void f()

{

 array a = { 0.0, 1.1, 2.2, 3.3, 4.4, 5.5 };

 array::iterator p = high(a.begin(), a.end());

 cout << " максимальное значение " << *p << endl;

}


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

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

20.10. Обзор контейнеров

В библиотеке STL есть несколько контейнеров.



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

Austern, Matt, ed. “Technical Report on C++ Standard Library Extensions,” ISO/IEC PDTR 19768. (Colloquially known as TR1.)

Austern, Matthew H. Generic Programming and the STL. Addison-Wesley, 1999. ISBN 0201309564. Koenig, Andrew, ed. The C++ Standard. Wiley, 2003. ISBN 0470846747. (Not suitable for novices.)

Lippman, Stanley B., Josée Lajoie, and Barbara E. Moo. The C++ Primer. AddisonWesley, 2005. ISBN 0201721481. (Use only the 4th edition.)

Musser, David R., Gillmer J. Derge, and Atul Saini. STL Tutorial and Reference Guide: C++ Programming with the Standard Template Library, Second Edition. AddisonWesley, 2001. ISBN 0201379236.

Stroustrup, Bjarne. The C++ Programming Language. Addison-Wesley, 2000. ISBN 0201700735.


Документацию о реализации библиотеки STL и библиотеки потоков ввода-вывода компании SGI (Silicon Graphics International) можно найти на веб-странице www.sgi.com/tech/stl>. Обратите внимание, что на этой веб-странице приводятся законченные программы.

Документацию о реализации библиотеки STL компании Dinkumware можно найти на веб-странице www.dinkumware.com/manuals/default.aspx. (Имейте в виду, что существует несколько версий этой библиотеки.)

Документацию о реализации библиотеки STL компании Rogue Wave можно найти на веб-странице www2.roguewave.com/support/docs/index.cfm.


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

С другой стороны, вы обнаружите, что, освоив классы

vector
,
list
и
map
, а также стандартные алгоритмы, описанные в главе 21, вы легко научитесь работать с остальными контейнерами из библиотеки STL. Вы обнаружите также, что знаете все, что требуется для работы с нестандартными контейнерами, и сможете их программировать сами.

Что такое контейнер? Определение этого понятия можно найти в любом из указанных выше источников. Здесь лишь дадим неформальное определение. Итак, контейнер из библиотеки STL обладает следующими свойствами.

• Представляет собой последовательность элементов

[begin():end()]
.

• Операции над контейнером копируют элементы. Копирование можно выполнить с помощью присваивания или конструктора копирования.

• Тип элементов называется

value_type
.

• Контейнер содержит типы итераторов с именами

iterator
и
const_iterator
. Итераторы обеспечивают операции
*
,
++
(как префиксные, так и постфиксные),
==
и
!=
с соответствующей семантикой. Итераторы для класса
list
также предусматривают оператор
для перемещения по последовательности в обратном направлении; такие итераторы называют двунаправленными (bidirectional iterator). Итераторы для класса
vector
также предусматривает операции
––
,
[]
,
+
и
-
. Эти итераторы называют итераторами с произвольным доступом (random-access iterators) (см. раздел 20.10.1).

• Контейнеры имеют функции

insert()
и
erase()
,
front()
и
back()
,
push_back()
и
pop_back()
,
size()
и т.д.; классы
vector
и
map
также обеспечивают операцию индексирования (например, оператор
[]
).

• Контейнеры обеспечивают операторы (

==
,
!=
,
<
,
<=
,
>
и
>=
) для сравнения элементов. Контейнеры используют лексикографическое упорядочивание для операций
<
,
<=
,
>
и
>=
; иначе говоря, они сравнивают элементы, чтобы начинать перемещение с первого элемента.

• Цель этого списка — дать читателям некий обзор. Более детальная информация приведена в приложении Б. Более точная спецификация и полный список операций приведены в книге The C++ Programming Language или в стандарте.


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



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

Если у вас есть сомнения, используйте класс

vector
. Если у вас нет весомых причин не делать этого, используйте класс
vector
.

20.10.1. Категории итераторов

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



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



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


Задание

1. Определите массив чисел типа

int
с десятью элементами { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }.

2. Определите объект класса

vector
с этими же десятью элементами.

3. Определите объект класса

list
с этими же десятью элементами.

4. Определите второй массив, вектор и список, каждый из которых инициализируется первым массивом, вектором или списком соответственно.

5. Увеличьте значение каждого элемента в массиве на два; увеличьте значение каждого элемента в массиве на три; увеличьте значение каждого элемента в массиве на пять.

6. Напишите простую операцию

copy()


template

Iter2 copy(Iter f1, Iter1 e1, Iter2 f2);


копирующую последовательность

[f1,e1]
в последовательность
[f2,f2+(e1–f1)]
и, точно так же, как стандартная библиотечная функция копирования, возвращающую число
f2+(e1–f1)
. Обратите внимание на то, что если
f1==e1
, то последовательность пуста и копировать нечего.

7. Используйте вашу функцию

copy()
для копирования массива в вектор или списка — в массив.

8. Используйте стандартную библиотечную функцию

find()
для того, чтобы убедиться, что вектор содержит значение
3
, и выведите на экран соответствующую позицию этого числа в векторе, если это число в нем есть. Используйте стандартную библиотечную функцию
find()
, чтобы убедиться, что список содержит значение
27
, и выведите на экран соответствующую позицию этого числа в списке, если это число в нем есть. Позиция первого элемента равна нулю, позиция второго элемента равна единице и т.д. Если функция
find()
возвращает итератор, установленный на конец последовательности, то значение в ней не найдено. Не забывайте тестировать программу после каждого этапа.


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

1. Почему программы, написанные разными людьми, выглядят по-разному? Приведите примеры.

2. Какие простые вопросы мы обычно задаем, думая о данных?

3. Перечислите разные способы хранения данных?

4. Какие основные операции можно выполнить с коллекцией данных?

5. Каких принципов следует придерживаться при хранении данных?

6. Что такое последовательность в библиотеке STL?

7. Что такое итератор в библиотеке STL? Какие операции поддерживают итераторы?

8. Как установить итератор на следующий элемент?

9. Как установить итератор на предыдущий элемент?

10. Что произойдет, если вы попытаетесь установить итератор на ячейку, следующую за концом последовательности?

11. Какие виды итераторов могут перемещаться на предыдущий элемент?

12. Почему полезно отделять данные от алгоритмов?

13. Что такое STL?

14. Что такое связанный список? Чем он в принципе отличается от вектора?

15. Что такое узел (в связанном списке)?

16. Что делает функция

insert()
? Что делает функция
erase()
?

17. Как определить, что последовательность пуста?

18. Какие операции предусмотрены в итераторе для класса

list
?

19. Как обеспечить перемещение по контейнеру, используя библиотеку STL?

20. В каких ситуациях лучше использовать класс

string
, а не
vector
?

21. В каких ситуациях лучше использовать класс

list
, а не
vector
?

22. Что такое контейнер?

23. Что должны делать функции

begin()
и
end()
в контейнере?

24. Какие контейнеры предусмотрены в библиотеке STL?

25. Перечислите категории итераторов? Какие виды итераторов реализованы в библиотеке STL?

26. Какие операции предусмотрены в итераторе с произвольным доступом, но неподдерживаются двунаправленным итератором?


Термины


Упражнения

1. Если вы еще не выполнили задания из врезок ПОПРОБУЙТЕ, то сделайте это сейчас.

2. Попробуйте запрограммировать пример с Джеком и Джилл из раздела 20.1.2. Для тестирования используйте несколько небольших файлов.

3. Проанализируйте пример с палиндромом (см. раздел 20.6); еще раз выполните задание из п. 2, используя разные приемы.

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

5. Определите операторы ввода и вывода (

>>
и
<<
) для класса
vector
.

6. Напишите операцию “найти и заменить” для класса

Document
, используя информацию из раздела 20.6.2.

7. Определите лексикографически последнюю строку в неупорядоченном классе

vector
.

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

Document
.

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

Document
. Предусмотрите две версии: одну, в которой слово — это последовательность символов, разделенных пробелами, и вторую, в которой слово — это неразрывная последовательность символов из алфавита. Например, при первом определении выражения
alpha.numeric
и
as12b
— это слова, а при втором — каждое из них рассматривается как два слова.

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

11. Создайте объект класса

vector
и скопируйте в него элементы списка типа
list
, передавая его как параметр (по ссылке). Проверьте, что копия полна и верна. Затем выведите на экран элементы в порядке возрастания их значений.

12. Завершите определение класса

list
из разделов 20.4.1 и 20.4.2 и продемонстрируйте работу функции
high()
. Выделите память для объекта класса
Link
, представляющего узел, следующий за концом списка.

13. На самом деле в классе

list
нам не нужен реальный объект класса
Link
, расположенный за последним элементом. Модифицируйте свое решение из предыдущего упражнения так, чтобы в качестве указателя на несуществующий объект класса
Link (list::end())
использовалось значение
0
; иначе говоря, размер пустого списка может быть равен размеру отдельного указателя.

14. Определите односвязный список

slist
, ориентируясь на стиль класса
std::list
. Какие операции из класса list стоило бы исключить из класса
slist
, поскольку он не содержит указателя на предыдущий элемент?

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

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

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

ovector
, похожий на класс
pvector
, за исключением того, что операции
[ ]
и
*
возвращают не указатели, а ссылки на объект, на который ссылается соответствующий элемент.

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

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

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

vector
(итератор с произвольным доступом).

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

list
(двунаправленный итератор).

20. Выполните эксперимент, посвященный сравнению временных затрат при работе с классами

vector
и
list
. Способ измерения длительности работы программы изложен в разделе 26.6.1. Сгенерируйте N случайных целых чисел в диапазоне [0:N]. Вставьте каждое сгенерированное число в вектор
vector
(после каждой вставки увеличивающийся на один элемент). Храните объект класса
vector
в упорядоченном виде; иначе говоря, значение должно быть вставлено так, чтобы все предыдущие значения были меньше или равны ему, а все последующие значения должны быть больше него. Выполните тот же эксперимент, используя класс
list
для хранения целых чисел. При каких значениях N класс
list
обеспечивает более высокое быстродействие, чем класс
vector
? Попробуйте объяснить результаты эксперимента. Впервые этот эксперимент был предложен Джоном Бентли (John Bentley).


Послесловие

Если бы у нас было N видов контейнеров, содержащих данные, и M операций, которые мы хотели бы над ними выполнить, то мы могли бы легко написать N*M фрагментов кода. Если бы данные имели K разных типов, то нам пришлось бы написать N*M*K фрагментов кода. Библиотека STL решает эту проблему, разрешая задавать тип элемента в виде параметра (устраняя множитель K) и отделяя доступ к данным от алгоритмов. Используя итераторы для доступа к данным в любом контейнере и в любом алгоритме, мы можем ограничиться N+M алгоритмами. Это огромное облегчение. Например, если бы у нас было 12 контейнеров и 60 алгоритмов, то прямолинейный подход потребовал бы создания 720 функций, в то время как стратегия, принятая в библиотеке STL, требует только 60 функций и 12 определений итераторов: тем самым мы экономим 90% работы. Кроме того, в библиотеке STL приняты соглашения, касающиеся определения алгоритмов, упрощающие создание корректного кода и облегчающие его композицию с другими кодами, что также экономит много времени.

Глава 21 Алгоритмы и ассоциативные массивы

“Теоретически практика проста”.

Тригве Рийнскауг (Trygve Reenskaug)


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

map
,
set
и
unordered_map
.

21.1. Алгоритмы стандартной библиотеки

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



По умолчанию проверка равенства выполняется с помощью оператора

==
, а упорядочивание — на основе оператора
<
(меньше). Алгоритмы из стандартной библиотеки определены в заголовке
. Более подробную информацию читатели найдут в приложении Б.5 и в источниках, перечисленных в разделе 20.7. Эти алгоритмы работают с одной или двумя последовательностями. Входная последовательность определяется парой итераторов; результирующая последовательность — итератором, установленным на ее первый элемент. Как правило, алгоритм параметризуется одной или несколькими операциями, которые можно определить либо с помощью объектов-функций, либо собственно функций. Алгоритмы обычно сообщают о сбоях, возвращая итератор, установленный на конец входной последовательности. Например, алгоритм
find(b,e,v)
вернет элемент
e
, если не найдет значение
v
.

21.2. Простейший алгоритм: find()

Вероятно, простейшим из полезных алгоритмов является алгоритм

find()
. Он находит элемент последовательности с заданным значением.


template

In find(In first, In last, const T& val)

// находит первый элемент в последовательности [first,last], равный val

{

 while (first!=last && *first != val) ++first;

 return first;

}


Посмотрим на определение алгоритма

find()
. Естественно, вы можете использовать алгоритм
find()
, не зная, как именно он реализован, — фактически мы его уже применяли (например, в разделе 20.6.2). Однако определение алгоритма
find()
иллюстрирует много полезных проектных идей, поэтому оно достойно изучения.

Прежде всего, алгоритм

find()
применяется к последовательности, определенной парой итераторов. Мы ищем значение
val
в полуоткрытой последовательности
[first:last]
. Результат, возвращаемый функцией
find()
, является итератором. Он указывает либо на первый элемент последовательности, равный значению
val
, либо на элемент
last
. Возвращение итератора на элемент, следующий за последним элементом последовательности, — самый распространенный способ, с помощью которого алгоритмы библиотеки STL сообщают о том, что элемент не найден. Итак, мы можем использовать алгоритм
find()
следующим образом:


void f(vector& v,int x)

{

 vector::iterator p = find(v.begin(),v.end(),x);

 if (p!=v.end()) {

   // мы нашли x в v

 }

 else {

   // в v нет элемента, равного x

 }

 // ...

}


В этом примере, как в большинстве случаев, последовательность содержит все элементы контейнера (в данном случае вектора). Мы сравниваем возвращенный итератор с концом последовательности, чтобы узнать, найден ли искомый элемент.

Теперь мы знаем, как используется алгоритм

find()
, а также группу аналогичных алгоритмов, основанных на тех же соглашениях. Однако, прежде чем переходить к другим алгоритмам, внимательнее посмотрим на определение алгоритма
find()
.


template

In find(In first,In last,const T& val)

 // находит первый элемент в последовательности [first,last],

 // равный val

{

 while (first!=last && *first != val) ++first;

 return first;

}


Вы полагаете, что этот цикл вполне тривиален? Мы так не думаем. На самом деле это минимальное, эффективное и непосредственное представление фундаментального алгоритма. Однако, пока мы не рассмотрим несколько примеров, это далеко не очевидно. Сравним несколько версий алгоритма.


template

In find(In first,In last,const T& val)

 // находит первый элемент в последовательности [first,last],

 // равный val

 for (In p = first; p!=last; ++p)

  if (*p == val) return p;

 return last;

}


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

p
) и перестроить код так, чтобы все проверки выполнялись в одном месте. Зачем это нужно? Частично потому, что стиль первой (рекомендуемой) версии алгоритма
find()
стал очень популярным, и мы должны понимать его, чтобы читать чужие программы, а частично потому, что для небольших функций, работающих с большими объемами данных, большее значение имеет эффективность.


ПОПРОБУЙТЕ

Уверены ли вы, что эти два определения являются логически эквивалентными? Почему? Попробуйте привести аргументы в пользу их эквивалентности. Затем примените оба алгоритма к одному и тому же набору данных. Знаменитый специалист по компьютерным наукам Дон Кнут ((Don Knuth) однажды сказал: “Я только доказал, что алгоритм является правильным, но я его не проверял”. Даже математические доказательства содержат ошибки. Для того чтобы убедиться в своей правоте, нужно иметь как доказательства, так и результаты тестирования.

21.2.1. Примеры использования обобщенных алгоритмов

Алгоритм

find()
является обобщенным. Это значит, что его можно применять к разным типам данных. Фактически его обобщенная природа носит двойственный характер.

• Алгоритм

find()
можно применять к любой последовательности в стиле библиотеки STL.

• Алгоритм

find()
можно применять к любому типу элементов.


Рассмотрим несколько примеров (если они покажутся вам сложными, посмотрите на диаграммы из раздела 20.4).


void f(vector& v,int x) // работает с целочисленными векторами

{

 vector::iterator p = find(v.begin(),v.end(),x);

 if (p!=v.end()) { /* мы нашли x */ }

   // ...

}


Здесь операции над итераторами, использованные в алгоритме

find()
, являются операциями над итераторами типа
vector::iterator
; т.е. оператор
++
(в выражении
++first
) просто перемещает указатель на следующую ячейку памяти (где хранится следующий элемент вектора), а операция
*
(в выражении
*first
) разыменовывает этот указатель. Сравнение итераторов (в выражении
first!=last
) сводится к сравнению указателей, а сравнение значений (в выражении
*first!=val
) — к обычному сравнению целых чисел.

Попробуем применить алгоритм к объекту класса

list
.


void f(list& v,string x) // работает со списком строк

{

 list::iterator p = find(v.begin(),v.end(),x);

 if (p!=v.end()) { /* мы нашли x */ }

   // ...

}


Здесь операции над итераторами, использованные в алгоритме

find()
, являются операциями над итераторами класса
list::iterator
. Эти операторы имеют соответствующий смысл, так что логика их работы совпадает с логикой работы операторов из предыдущего примера (для класса
vector
). В то же время они реализованы совершенно по-разному; иначе говоря, оператор
++
(в выражении
++first
) просто следует за указателем, установленным на следующий узел списка, а оператор
*
(в выражении
*first
) находит значение в узле
Link
. Сравнение итераторов (в выражении
first!=last
) сводится к сравнению указателей типа
Link*
, а сравнение значений (в выражении
*first!=val
) означает сравнение строк с помощью оператора
!=
из класса
string
.

Итак, алгоритм

find()
чрезвычайно гибкий: если мы будем соблюдать простые правила работы с итераторами, то сможем использовать алгоритм
find()
для поиска элементов в любой последовательности любого контейнера. Например, с помощью алгоритма
find()
мы можем искать символ в объекте класса Document, определенного в разделе 20.6.


void f(Document& v,char x) // работает с объектами класса Document

{

 Text_iterator p = find(v.begin(),v.end(),x);

 if (p!=v.end()) { /* мы нашли x */ }

   // ...

}


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

21.3. Универсальный алгоритм поиска: find_if()

Нам редко приходится искать какое-то конкретное значение. Чаще нас интересует значение, удовлетворяющее определенным критериям. Мы смогли бы выполнять намного более полезную операцию

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

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

17 Cherry Tree Lane
".

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

find_if()
.


template

In find_if(In first,In last,Pred pred)

{

 while (first!=last && !pred(*first)) ++first;

 return first;

}


Очевидно (если сравнить исходные коды), что он похож на алгоритм

find()
, за исключением того, что в нем используется условие
!pred(*first)
, а не
*first!=val
; иначе говоря, алгоритм останавливает поиск, как только предикат
pred()
окажется истинным, а не когда будет обнаружен элемент с заданным значением.

Предикат (predicate) — это функция, возвращающая значение

true
или
false
. Очевидно, что алгоритм
find_if()
требует предиката, принимающего один аргумент, чтобы выражение
pred(*first)
было корректным. Мы можем без труда написать предикат, проверяющий какое-то свойство значения, например “содержит ли строка букву x”, “превышает ли число значение 42” или “является ли число нечетным?” Например, мы можем найти первое нечетное число в целочисленном векторе.


bool odd(int x) { return x%2; } // % — деление по модулю


void f(vector& v)

{

 vector::iterator p = find_if(v.begin(), v.end(), odd);

 if (p!=v.end()) { /* мы нашли нечетное число */ }

   // ...

}


При данном вызове алгоритм

find_if()
применит функцию
odd()
к каждому элементу, пока не найдет первое нечетное число. Аналогично, мы можем найти первый элемент списка, значение которого превышает 42.


bool larger_than_42(double x) { return x>42; }


void f(list& v)

{

 list::iterator p = find_if(v.begin(), v.end(),

 larger_than_42);

 if (p!=v.end()) { /* мы нашли значение, превышающее 42 */ }

   // ...

}


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

Если мы хотим сравнивать элемент с произвольным значением

v
, то должны как-то сделать это значение неявным аргументом предиката алгоритма
find_if()
. Мы могли бы попробовать (выбрав в качестве удобного имени идентификатор
v_val
).


double v_val; // значение, с которым предикат larger_than_v()

        // сравнивает свой аргумент

bool larger_than_v(double x) { return x>v_val; }


void f(list& v,int x)

{

 v_val = 31; // устанавливаем переменную v_val равной 31,

        // для следующего вызова предиката larger_than_v

 list::iterator p = find_if(v.begin(),v.end(),

                   larger_than_v);

 if (p!=v.end()) { /* мы нашли значение, превышающее 31 */ }

   v_val = x; // устанавливаем переменную v_val равной x

         // для следующего вызова предиката larger_than_v

 list::iterator q = find_if(v.begin(), v.end(),

                   larger_than_v);

 if (q!=v.end()) { /* мы нашли значение, превышающее x*/ }

   // ...

}


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


ПОПРОБУЙТЕ

Почему такое использование переменной

v
вызывает у нас такое отвращение? Назовите по крайней мере три способа, которые приведут к непонятным ошибкам. Назовите три приложения, в которых такие программы особенно недопустимы.

21.4. Объекты-функции

Итак, мы хотим передавать предикат алгоритму

find_if()
и чтобы этот предикат сравнивал элементы со значением, которое мы зададим как его аргумент. В частности, мы хотим написать примерно такой код:


void f(list& v, int x)

{

 list::iterator p = find_if(v.begin(), v.end(),

 Larger_than(31));

 if (p!=v.end()) { /* мы нашли число, превышающее 31 */ }

   list::iterator q = find_if(v.begin(), v.end(),

 Larger_than(x));

  if (q!=v.end()) { /* мы нашли число, превышающее x */ }

   // ...

}


Очевидно, что функция

Larger_than
должна удовлетворять двум условиям.

• Ее можно вызывать как предикат, например

pred(*first)
.

• Она может хранить значение, например

31
или
x
, передаваемое при вызове.


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


class Larger_than {

 int v;

public:

 Larger_than(int vv) : v(vv) { } // хранит аргумент

 bool operator()(int x) const { return x>v; } // сравнение

};


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

Larger_than(31)
, мы (очевидно) создаем объект класса
Larger_than
, хранящий число
31
в члене
v
. Рассмотрим пример.


find_if(v.begin(),v.end(),Larger_than(31))


Здесь мы передаем объект

Larger_than(31)
алгоритму
find_if()
как параметр с именем
pred
. Для каждого элемента v алгоритм
find_if()
осуществляет вызов


pred(*first)


Это активизирует оператор вызова функции, т.е. функцию-член operator(), для объекта-функции с аргументом

*first
. В результате происходит сравнение значения элемента, т.е.
*first
, с числом
31
.

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

()
, аналогично любому другому оператору. Оператор
()
называют также оператором вызова функции (function call operator) или прикладным оператором (application operator). Итак, оператор
()
в выражении
pred(*first)
эквивалентен оператору
Larger_than::operator()
, точно так же, как оператор
[]
в выражении
v[i]
эквивалентен оператору
vector::operator[]
.

21.4.1. Абстрактная точка зрения на функции-объекты

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


class F {  // абстрактный пример объекта-функции

    S s; // состояние

public:

 F(const S& ss):s(ss) { /* устанавливает начальное значение */ }

 T operator() (const S& ss) const

 {

   // делает что-то с аргументом ss

   // возвращает значение типа T (часто T — это void,

   // bool или S)

 }

 const S& state() const { return s; } // демонстрирует

 // состояние

 void reset(const S& ss) { s = ss; }  // восстанавливает

 // состояние

};


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

F
хранит данные в своем члене
s
. По мере необходимости объект-функция может иметь много данных-членов. Иногда вместо фразы “что-то хранит данные” говорят “нечто пребывает в состоянии”. Когда мы создаем объект класса
F
, мы можем инициализировать это состояние. При необходимости мы можем прочитать это состояние. В классе
F
для считывания состояния предусмотрена операция
state()
, а для записи состояния — операция
reset()
. Однако при разработке объекта-функции мы свободны в выборе способа доступа к его состоянию.

Разумеется, мы можем прямо или косвенно вызывать объект-функцию, используя обычную систему обозначений. При вызове объект-функция

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

Использование объектов-функций является основным способом параметризации в библиотеке STL. Мы используем объекты-функции для того, чтобы указать алгоритму поиска, что именно мы ищем (см. раздел 21.3), для определения критериев сортировки (раздел 21.4.2), для указания арифметических операций в численных алгоритмах (раздел 21.5), для того, чтобы указать, какие объекты мы считаем равными (раздел 21.8), а также для многого другого. Использование объектов-функций — основной источник гибкости и универсальности алгоритмов.

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

<
) и определен как подставляемая функция (например, если его определение содержится в теле класса). Большинство примеров в этой главе — и в книге в целом — соответствует этому правилу. Основная причина высокой производительности небольших и простых объектов-функций состоит в том, что они предоставляют компилятору объем информации о типе, достаточный для того, чтобы сгенерировать оптимальный код. Даже устаревшие компиляторы с несложными оптимизаторами могут генерировать простую машинную инструкцию “больше” для сравнения в классе
Larger_than
, вместо вызова функции. Вызов функции обычно выполняется в 10–50 раз дольше, чем простая операция сравнения. Кроме того, код для вызова функции больше, чем код простого сравнения.

21.4.2. Предикаты на членах класса

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

int
и
double
. Однако в некоторых предметных областях более широко используются контейнеры объектов пользовательских классов. Рассмотрим пример, играющий главную роль во многих областях, — сортировка записей по нескольким критериям.


struct Record {

 string name;  // стандартная строка

 char addr[24]; // старый стиль для согласованности

          // с базами данных

 // ...

};


vector vr;


Иногда мы хотим сортировать вектор

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


// ...

sort(vr.begin(),vr.end(),Cmp_by_name()); // сортировка по имени

// ...

sort(vr.begin(),vr.end(),Cmp_by_addr()); // сортировка по адресу

// ...


Cmp_by_name
— это объект-функция, сравнивающий два объекта класса
Record
по членам
name
. Для того чтобы дать пользователю возможность задавать критерий сравнения, в стандартном алгоритме
sort
предусмотрен необязательный третий аргумент, указывающий критерий сортировки. Функция
Cmp_by_name()
создает объект
Cmp_by_name
для алгоритма
sort()
, чтобы использовать его для сравнения объектов класса
Record
. Это выглядит отлично, в том смысле, что нам не приходится об этом беспокоиться самим. Все, что мы должны сделать, — определить классы
Cmp_by_name
и
Cmp_by_addr
.


// разные сравнения объектов класса Record:

struct Cmp_by_name {

 bool operator()(const Record& a,const Record& b) const

   { return a.name < b.name; }

};


struct Cmp_by_addr {

 bool operator()(const Record& a, const Record& b) const

   { return strncmp(a.addr,b.addr,24) < 0; }  // !!!

};


Класс

Cmp_by_name
совершенно очевиден. Оператор вызова функции
operator()()
просто сравнивает строки
name
, используя оператор
<
из стандартного класса
string
. Однако сравнение в классе
Cmp_by_addr
выглядит ужасно. Это объясняется тем, что мы выбрали неудачное представление адреса — в виде массива, состоящего из 24 символов (и не завершающегося нулем). Мы сделали этот выбор частично для того, чтобы показать, как объект-функцию можно использовать для сокрытия некрасивого и уязвимого для ошибок кода, а частично для того, чтобы продемонстрировать, что библиотека STL может решать даже ужасные, но важные с практической точки зрения задачи. Функция сравнения использует стандартную функцию
strncmp()
, которая сравнивает массивы символов фиксированной длины и возвращает отрицательное число, если вторая строка лексикографически больше, чем первая. Как только вам потребуется выполнить такое устаревшее сравнение, вспомните об этой функции (см., например, раздел Б.10.3).

21.5. Численные алгоритмы

Большинство стандартных алгоритмов из библиотеки STL связаны с обработкой данных: они их копируют, сортируют, выполняют поиск среди них и т.д. В то же время некоторые из них предназначены для вычислений. Они могут оказаться полезными как для решения конкретных задач, так и для демонстрации общих принципов реализации численных алгоритмов в библиотеке STL. Существуют всего четыре таких алгоритма.



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

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

21.5.1. Алгоритм accumulate()

Простейшим и наиболее полезным численным алгоритмом является алгоритм

accumulate()
. В простейшем варианте он суммирует значения, принадлежащие последовательности.


template T accumulate(In first, In last, T init)

{

 while (first!=last) {

   init = init + *first;

   ++first;

 }

 return init;

}


Получив начальное значение

init
, он просто добавляет к нему каждое значение из последовательности
[first:last]
и возвращает сумму. Переменную
init
, в которой накапливается сумма, часто называют аккумулятором (accumulator). Рассмотрим пример.


int a[] = { 1, 2, 3, 4, 5 };

cout << accumulate(a, a+sizeof(a)/sizeof(int), 0);


Этот фрагмент кода выводит на экран число 15, т.е. 0+1+2+3+4+5 (0 является начальным значением). Очевидно, что алгоритм

accumulate()
можно использовать для всех видов последовательностей.


void f(vector& vd,int* p,int n)

{

 double sum = accumulate(vd.begin(),vd.end(),0.0);

 int sum2 = accumulate(p,p+n,0);

}


Тип результата (суммы) совпадает с типом переменной, которую алгоритм

accumulate()
использует в качестве аккумулятора. Это обеспечивает высокую степень гибкости которая может играть важную роль. Рассмотрим пример.


void f(int* p,int n)

{

 int s1 = accumulate(p, p+n, 0);     // суммируем целые числа в int

 long sl = accumulate(p, p+n, long(0)); // суммируем целые числа

                     // в long

 double s2 = accumulate(p, p+n, 0.0);   // суммируем целые числа

                     // в double

}


На некоторых компьютерах переменная типа

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

Использование переменной

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


void f(vector& vd,int* p,int n)

{

 double s1 = 0;

 s1 = accumulate(vd.begin(),vd.end(),s1);

 int s2 = accumulate(vd.begin(), vd.end(),s2); // Ой

 float s3 = 0;

 accumulate(vd.begin(), vd.end(), s3);     // Ой

}


Не забудьте инициализировать аккумулятор и присвоить результат работы алгоритма

accumulate()
какой-нибудь переменной. В данном примере в качестве инициализатора использовалась переменная
s2
, которая сама еще не получила начальное значение до вызова алгоритма; результат такого вызова будет непредсказуем. Мы передали переменную
s3
алгоритму
accumulate()
(по значению; см. раздел 8.5.3), но результат ничему не присвоили; такая компиляция представляет собой простую трату времени.

21.5.2. Обобщение алгоритма accumulate()

Итак, основной алгоритм

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


template

T accumulate(In first, In last, T init, BinOp op)

{

 while (first!=last) {

   init = op(init, *first);

   ++first;

 }

 return init;

}


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


array a = { 1.1, 2.2, 3.3, 4.4 };  // см. раздел 20.9

cout << accumulate(a.begin(),a.end(), 1.0, multiplies());


Этот фрагмент кода выводит на печать число 35.1384, т.е. 1.0*1.1*2.2*3.3*4.4 (1.0 — начальное значение). Бинарный оператор

multiplies()
, передаваемый как аргумент, представляет собой стандартный объект-функцию, выполняющий умножение; объект-функция
multiplies
перемножает числа типа
double
, объект-функция
multiplies
перемножает числа типа
int
и т.д. Существуют и другие бинарные объекты-функции:
plus
(сложение),
minus
(вычитание),
divides
и
modulus
(вычисление остатка от деления). Все они определены в заголовке
(раздел Б.6.2).

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

1.0
. Как и в примере с алгоритмом
sort()
(см. раздел 21.4.2), нас часто интересуют данные, хранящиеся в объектах классов, а не обычные данные встроенных типов. Например, мы могли бы вычислить общую стоимость товаров, зная стоимость их единицы и общее количество.


struct Record {

 double unit_price;

 int units;   // количество проданных единиц

 // ...

};


Мы можем поручить какому-то оператору в определении алгоритма

accumulate
извлекать данные units из соответствующего элемента класса
Record
и умножать на значение аккумулятора.


double price(double v,const Record& r)

{

 return v + r.unit_price * r.units; // вычисляет цену

                   // и накапливает итог

}


void f(const vector& vr)

{

 double total = accumulate(vr.begin(),vr.end(),0.0,price);

  // ...

}


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

• Если между вызовами необходимо сохранять данные.

• Если они настолько короткие, что их можно объявлять подставляемыми (по крайней мере, для некоторых примитивных операций).


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


ПОПРОБУЙТЕ

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

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

21.5.3. Алгоритм inner_product

Возьмите два вектора, перемножьте их элементы попарно и сложите эти произведения. Результат этих вычислений называется скалярным произведением (inner product) двух векторов и является наиболее широко используемой операцией во многих областях (например, в физике и линейной алгебре; раздел 24.6).

Если вы словам предпочитаете программу, то прочитайте версию этого алгоритма из библиотеки STL.


template

T inner_product(In first, In last, In2 first2, T init)

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

{

 while(first!=last) {

   init = init + (*first) * (*first2); // перемножаем

   // элементы

   ++first;

   ++first2;

 }

 return init;

}


Эта версия алгоритма обобщает понятие скалярного произведения для любого вида последовательностей с любым типом элементов. Рассмотрим в качестве примера биржевой индекс. Он вычисляется путем присваивания компаниям неких весов. Например, индекс Доу–Джонса Alcoa на момент написания книги составлял 2,4808. Для того чтобы определить текущее значение индекса, умножаем цену акции каждой компании на ее вес и складываем полученные результаты. Очевидно, что такой индекс представляет собой скалярное произведение цен и весов. Рассмотрим пример.


// вычисление индекса Доу-Джонса

vector dow_price;    // цена акции каждой компании

dow_price.push_back(81.86);

dow_price.push_back(34.69);

dow_price.push_back(54.45);

// ...


list dow_weight;      // вес каждой компании в индексе

dow_weight.push_back(5.8549);

dow_weight.push_back(2.4808);

dow_weight.push_back(3.8940);

// ...


double dji_index = inner_product( // умножаем пары (weight,value)

                  // и суммируем

  dow_price.begin(),dow_price.end(),
dow_weight.begin(),
0.0);

cout << "Значение DJI" << dji_index << '\n';


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

inner_product()
получает две последовательности. В то же время он получает только три аргумента: у второй последовательности задается только начало. Предполагается, что вторая последовательность содержит не меньше элементов, чем первая. В противном случае мы получим сообщение об ошибке во время выполнения программы. В алгоритме
inner_product()
вторая последовательность вполне может содержать больше элементов, чем первая; лишние элементы просто не будут использоваться.

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

vector
, а веса — в объект класса
list
.

21.5.4. Обобщение алгоритма inner_product()

Алгоритм

inner_product()
можно обобщить так же, как и алгоритм
accumulate()
. Однако в отличие от предыдущего обобщения алгоритму
inner_product()
нужны еще два аргумента: первый — для связывания аккумулятора с новым значением, точно так же как в алгоритме
accumulate()
, а второй — для связывания с парами значений.


template

T inner_product(In first,In last,In2 first2,T init,
BinOp op,BinOp2 op2)

{

 while(first!=last) {

   init = op(init,op2(*first,*first2));

   ++first;

   ++first2;

 }

 return init;

}


В разделе 21.6.3 мы еще вернемся к примеру с индексом Доу–Джонса и используем обобщенную версию алгоритма

inner_product()
как часть более элегантного решения задачи.

21.6. Ассоциативные контейнеры

После класса

vector
вторым по частоте использования, вероятно, является стандартный контейнер
map
, представляющий собой упорядоченную последовательность пар (ключ,значение) и позволяющий находить значение по ключу; например, элемент
my_phone_book["Nicholas"]
может быть телефонным номером Николаса. Единственным достойным конкурентом класса map по популярности является класс
unordered_map
(см. раздел 21.6.4), оптимизированный для ключей, представляющих собой строки. Структуры данных, аналогичные контейнерам
map
и
unordered_map
, известны под разными названиями, например ассоциативные массивы (associative arrays), хеш-таблицы (hash tables) и красно-черные деревья (red-black trees). Популярные и полезные понятия всегда имеют много названий. Мы будем называть их всех ассоциативными контейнерами (associative containers).

В стандартной библиотеке предусмотрены восемь ассоциативных контейнеров.



Эти контейнеры определены в заголовках

,
,
и
.

21.6.1. Ассоциативные массивы

Рассмотрим более простую задачу: создадим список номеров вхождений слов в текст. Для этого вполне естественно записать список слов вместе с количеством их вхождений в текст. Считывая новое слово, мы проверяем, не появлялось ли оно ранее; если нет, вставляем его в список и связываем с ним число 1. Для этого можно было бы использовать объект типа

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


int main()

{

 map words;   // хранит пары (слово, частота)

 string s;

 while (cin>>s) ++words[s]; // контейнер words индексируется

               // строками

 typedef map::const_iterator Iter;

 for (Iter p = words.begin(); p!=words.end(); ++p)

  cout << p–>first << ": " << p–>second << '\n';

}


Самой интересной частью этой программы является выражение

++words[s]
. Как видно уже в первой строке функции
main()
, переменная
words
— это объект класса map, состоящий из пар (
string
,
int
); т.е. контейнер
words
отображает строки
string
в целые числа
int
. Иначе говоря, имея объект класса
string
, контейнер
words
дает нам доступ к соответствующему числу типа
int
. Итак, когда мы индексируем контейнер words объектом класса
string
(содержащим слово, считанное из потока ввода), элемент
words[s]
является ссылкой на число типа
int
, соответствующее строке
s
. Рассмотрим конкретный пример.


words["sultan"]


Если строки "

sultan
" еще не было, то она вставляется в контейнер
words
вместе со значением, заданным по умолчанию для типа
int
, т.е.
0
. Теперь контейнер
words
содержит элемент
("sultan", 0
). Следовательно, если строка "
sultan
" ранее не вводилась, то выражение
++words["sultan"]
свяжет со строкой "
sultan
" значение
1
. Точнее говоря, объект класса map выяснит, что строки "
sultan
" в нем нет, вставит пару
("sultan",0
), а затем оператор
++
увеличит это значение на единицу, в итоге оно станет равным
1
.

Проанализируем программу еще раз: выражение

++words[s]
получает слово из потока ввода и увеличивает его значение на единицу. При первом вводе каждое слово получает значение
1
. Теперь смысл цикла становится понятен.


while (cin>>s) ++words[s];


Он считывает каждое слово (отделенное пробелом) из потока ввода и вычисляет количество его вхождений в контейнер. Теперь нам достаточно просто вывести результат. По контейнеру

map
можно перемещаться так же, как по любому другому контейнеру из библиотеки STL. Элементы контейнера
map
имеют тип
pair
. Первый член объекта класса pair называется
first
, второй —
second
. Цикл вывода выглядит следующим образом:


typedef map::const_iterator Iter;

for (Iter p = words.begin(); p!=words.end(); ++p)

 cout << p–>first << ": " << p–>second << '\n';


Оператор

typedef
(см. разделы 20.5 и A.16) предназначен для обеспечения удобства работы и удобочитаемости программ. В качестве текста мы ввели в программу вступительный текст из первого издания книги The C++ Programming Language.

  C++ is a general purpose programming language designed to make

  programming more enjoyable for the serious programmer. Except

  for minor details, C++ is a superset of the C programming language.

  In addition to the facilities provided by C, C++ provides flexible and

  efficient facilities for defining new types.


Результат работы программы приведен ниже.


C: 1

C++: 3

C,: 1

Except: 1

In: 1

a: 2

addition: 1

and: 1

by: 1

defining: 1

designed: 1

details,: 1

efficient: 1

enjoyable: 1

facilities: 2

flexible: 1

for: 3

general: 1

is: 2

language: 1

language.: 1

make: 1

minor: 1

more: 1

new: 1

of: 1

programmer.: 1

programming: 3

provided: 1

provides: 1

purpose: 1

serious: 1

superset: 1

the: 3

to: 2

types.: 1


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

21.6.2. Обзор ассоциативных массивов

Так что же такое контейнер map? Существует много способов реализации ассоциативных массивов, но в библиотеке STL они реализованы на основе сбалансированных бинарных деревьев; точнее говоря, они представляют собой красно-черные деревья. Мы не будем вдаваться в детали, но поскольку вам известны эти технические термины, вы можете найти их объяснение в литературе или в веб.

Дерево состоит из узлов (так же как список состоит из узлов; см. раздел 20.4). В объекте класса

Node
хранятся ключ, соответствующее ему число и указатели на два последующих узла.



Вот как может выглядеть объект класса

map
в памяти компьютера, если мы вставили в него пары (Kiwi,100), (Quince,0), (Plum,8), (Apple,7), (Grape,2345) и (Orange,99).



Поскольку ключ хранится в члене класса

Node
с именем
first
, основное правило организации бинарного дерева поиска имеет следующий вид:


left–>firstfirst


Иначе говоря, для каждого узла выполняются два условия.

• Ключ его левого подузла меньше ключа узла.

• Ключ узла меньше, чем ключ правого подузла.


Можете убедиться, что эти условия выполняются для каждого узла дерева. Это позволяет нам выполнять поиск вниз по дереву, начиная с корня. Забавно, что в литературе по компьютерным наукам деревья растут вниз. Корневым узлом является узел, содержащий пару (Orange, 99). Мы просто перемещаемся по дереву вниз, пока не найдем подходящее место. Дерево называется сбалансированным (balanced), если (как в приведенном выше примере) каждое его поддерево содержит примерно такое же количество узлов, как и одинаково удаленные от корня поддеревья. В сбалансированном дереве среднее количество узлов, которые мы должны пройти, пока не достигнем заданного узла, минимально.

В узле могут храниться дополнительные данные, которые контейнер может использовать для поддержки баланса. Дерево считается сбалансированным, если каждый узел имеет примерно одинаковое количество наследников как слева, так и справа. Если дерево, состоящее из N узлов, сбалансировано, то для обнаружения узла необходимо просмотреть не больше log2N узлов. Это намного лучше, чем N/2 узлов в среднем, которые мы должны были бы просмотреть, если бы ключи хранились в списке, а поиск выполнялся с начала (в худшем случае линейного поиска нам пришлось бы просмотреть N узлов). (См. также раздел 21.6.4.)

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



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


left–>firstfirst


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

map
используются сбалансированные деревья.

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

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


template > class map

{

 // ...

 typedef pair value_type; // контейнер map хранит

                    // пары (Key,Value)

 typedef sometype1 iterator;     // указатель на узел дерева

  typedef sometype2 const_iterator;


 iterator begin();   // указывает на первый элемент

 iterator end();     // указывает на следующий за последним

             // элемент

 Value& operator[](const Key& k); // индексирование

                  // по переменной k

 iterator find(const Key& k);   // поиск по ключу k

 void erase(iterator p);      // удаление элемента, на который

                  // указывает итератор p

 pair insert(const value_type&);

 // вставляет пару (key,value)

 // ...

};


Настоящий вариант контейнера определен в заголовке

. Можно представить себе итератор в виде указателя
Node*
, но при реализации итератора нельзя полагаться на какой-то конкретный тип.

Сходство интерфейсов классов

vector
и
list
(см. разделы 20.5 и B.4) очевидно. Основное отличие заключается в том, что при перемещении по контейнеру элементами теперь являются пары типа
pair
. Этот тип является очень полезным в библиотеке STL.


template struct pair {

 typedef T1 first_type;

 typedef T2 second_type;

 T1 first;

 T2 second;


 pair():first(T1()),second(T2()) { }

 pair(const T1& x,const T2& y):first(x),second(y) { }

 template

   pair(const pair& p):first(p.first), second(p.second) { }

};


template
 pair make_pair(T1 x, T2 y)

{

 return pair(x,y);

}


Мы скопировали полное определение класса

pair
и его полезную вспомогательную функцию
make_pair()
из стандарта.

При перемещении по контейнеру

map
элементы перебираются в порядке, определенном ключом. Например, если мы перемещаемся по контейнеру, описанному в примере, то получим следующий порядок обхода:


(Apple,7) (Grape,2345) (Kiwi,100) (Orange,99) (Plum,8) (Quince,0)


Порядок вставки узлов значения не имеет.

Операция

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

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

Cmp
в объявлении класса
map
). Рассмотрим пример.


map m;


Предикат

No_case
определяет сравнение символов без учета регистра (см. раздел 21.8). По умолчанию порядок обхода определяется предикатом
less
, т.е. отношением “меньше”.

21.6.3. Еще один пример ассоциативного массива

Для того чтобы оценить полезность контейнера

map
, вернемся к примеру с индексом Доу–Джонс из раздела 21.5.3. Описанный там код работает правильно, только если все веса записаны в объекте класса
vector
в тех же позициях, что и соответствующие имена. Это требование носит неявный характер и легко может стать источником малопонятных ошибок. Существует много способов решения этой проблемы, но наиболее привлекательным является хранение всех весов вместе с их тикером, например (“AA”,2.4808). Тикер — это аббревиатура названия компании. Аналогично тикер компании можно хранить вместе с ценой ее акции, например (“AA”,34.69). В заключение для людей, редко сталкивающихся с фондовым рынком США, мы можем записывать тикер вместе с названием компании, например (“AA”,“Alcoa Inc.”); иначе говоря, можем хранить три аассоциативных массива соответствующих значений.

Сначала создадим ассоциативный контейнер, содержащий пары (символ,цена).


map dow_price;

 // Индекс Доу - Джонса (символ, цена);

 // текущие котировки см. на веб-сайте www.djindexes.com

dow_price["MMM"] = 81.86;

dow_price ["AA"] = 34.69;

dow_price ["MO"] = 54.45;

// ...


Ассоциативный массив, содержащий пары (символ, вес), объявляется так:


map dow_weight; // Индекс Доу-Джонса (символ, вес)

dow_weight.insert(make_pair("MMM", 5.8549));

dow_weight.insert(make_pair("AA",2.4808));

dow_weight.insert(make_pair("MO",3.8940));

// ...


Мы использовали функции

insert()
и
make_pair()
для того, чтобы показать, что элементами контейнера
map
действительно являются объекты класса
pair
. Этот пример также иллюстрирует значение обозначений; мы считаем, что индексирование понятнее и — что менее важно — легче записывается.

Ассоциативный контейнер, содержащий пары (символ, название).


map dow_name; // Доу-Джонс (символ, название)

dow_name["MMM"] = "3M Co.";

dow_name["AA"] = "Alcoa Inc.";

dow_name["MO"] = "Altria Group Inc.";

// ...


С помощью этих ассоциативных контейнеров можно легко извлечь любую информацию. Рассмотрим пример.


double alcoa_price = dow_price ["AAA"]; // считываем значения из

                    // ассоциативного массива

double boeing_price = dow_price ["BA"];

if (dow_price.find("INTC") != dow_price.end()) // находим элемент

                        // ассоциативного

                        // массива

cout << "Intel is in the Dow\n";


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

first
, а значение —
second
.


typedef map::const_iterator Dow_iterator;


// записывает цену акции для каждой компании, входящей в индекс

// Доу - Джонса

for (Dow_iterator p = dow_price.begin(); p!=dow_price.end(); ++p) {

 const string& symbol = p–>first; // тикер

   cout << symbol << '\t'

     << p–>second << '\t'

     << dow_name[symbol] << '\n';

}


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

map
.


double weighted_value(

 const pair& a,

 const pair& b
)  // извлекает значения и перемножает

 {

   return a.second * b.second;

 }


Теперь просто подставим эту функцию в обобщенную версию алгоритма


inner_product() и получим значение индекса.

double dji_index =

 inner_product(dow_price.begin(), dow_price.end(),

  // все компании

         dow_weight.begin(), // их веса

         0.0,         // начальное значение

         plus(),   // сложение (обычное)

         weighted_value);   // извлекает значение и веса,

                   // а затем перемножает их


Почему целесообразно хранить такие данные в ассоциативных массивах, а не в векторах? Мы использовали класс map, чтобы связь между разными значениями стала явной. Это одна из причин. Кроме того, контейнер

map
хранит элементы в порядке, определенном их ключами. Например, при обходе контейнера
dow
мы выводили символы в алфавитном порядке; если бы мы использовали класс
vector
, то были бы вынуждены сортировать его. Чаще всего класс
map
используют просто потому, что хотят искать значения по их ключам. Для крупных последовательностей поиск элементов с помощью алгоритма
find()
намного медленнее, чем поиск в упорядоченной структуре, такой как контейнер
map
.


ПОПРОБУЙТЕ

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

21.6.4. Алгоритм unordered_map()

Для того чтобы найти элемент в контейнере

vector
, алгоритм
find()
должен проверить все элементы, начиная с первого и заканчивая искомым или последним элементом вектора. Средняя сложность этого поиска пропорциональна длине вектора (N); в таком случае говорят, что алгоритм имеет сложность O(N).

Для того чтобы найти элемент в контейнере map, оператор индексирования должен проверить все элементы, начиная с корня дерева и заканчивая искомым значением или листом дерева. Средняя сложность этого поиска пропорциональна глубине дерева. Максимальная глубина сбалансированного бинарного дерева, содержащего N элементов, равна log2N, а сложность поиска в нем имеет порядок O(log2N), т.е. пропорциональна величине log2N. Это намного лучше, чем O(N).



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

find()
).

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

map
. Не вдаваясь в подробности, укажем, что идея заключается в том, что по ключу мы можем вычислить индекс в контейнере
vector
. Этот индекс называется значением хеш-функции (hash value), а контейнер, в котором используется этот метод, — хеш-таблицей (hash table). Количество возможных ключей намного больше, чем количество ячеек в хеш-таблице. Например, хеш-функция часто используется для того, чтобы отобразить миллиарды возможных строк в индекс вектора, состоящего из тысячи элементов. Такая задача может оказаться сложной, но ее можно решить. Это особенно полезно при реализации больших контейнеров
map
. Основное преимущество хеш-таблицы заключается в том, что средняя сложность поиска в ней является (почти) постоянной и не зависит от количества ее элементов, т.е. имеет порядок O(1). Очевидно, что это большое преимущество для крупных ассоциативных массивов, например, содержащих 500 тысяч веб-адресов. Более подробную информацию о хеш-поиске читатели могут найти в документации о контейнере
unordered_map
(доступной в сети веб) или в любом учебнике по структурам данных (ищите в оглавлении хеш-таблицы и хеширование).

Рассмотрим графическую иллюстрацию поиска в (неупорядоченном) векторе, сбалансированном бинарном дереве и хеш-таблице.

• Поиск в неупорядоченном контейнере

vector
.



• Поиск в контейнере

map
(сбалансированном бинарном дереве).



• Поиск в контейнере

unordered_map
(хеш-таблица).



Контейнер

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

Эмпирическое правило гласит следующее.

• Используйте контейнер

vector
, если у вас нет веских оснований не делать этого.

• Используйте контейнер

map
, если вам необходимо выполнить поиск по значению (и если тип ключа позволяет эффективно выполнять операцию “меньше”).

• Используйте контейнер

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


Мы не будем подробно описывать контейнер

unordered_map
. Его можно использовать с ключом типа
string
или
int
точно так же, как контейнер map, за исключением того, что при обходе элементов они не будут упорядочены. Например, мы могли бы переписать фрагмент кода для вычисления индекса- Доу–Джонса из раздела 21.6.3 следующим образом:


unordered_map dow_price;


typedef unordered_map::const_iterator Dow_iterator;


for (Dow_iterator p = dow_price.begin(); p!=dow_price.end(); ++p) {

 const string& symbol = p–>first;     // the "ticker" symbol

   cout << symbol << '\t'

     << p–>second << '\t'

     << dow_name[symbol] << '\n';

 }


Теперь поиск в контейнере

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

Неупорядоченные ассоциативные массивы в стандарте языка С++ являются новшеством и еще не стали полноправным его элементом, поскольку они описаны в техническом отчете Комиссии по стандартизации языка С++ (Technical Report), а не в тексте самого стандарта. Тем не менее они широко распространены, а там, где их нет, часто можно обнаружить их аналоги, например, что-нибудь вроде класса

hash_map
.


ПОПРОБУЙТЕ

Напишите небольшую программу, используя директиву

#include
. Если она не работает, значит, класс
unordered_map
не был включен в вашу реализацию языка C++. Если вам действительно нужен контейнер
unordered_map
, можете загрузить одну из его доступных реализаций из сети веб (см., например, сайт www.boost.org).

21.6.5. Множества

Контейнер

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



Например, контейнер

set
, в котором перечислены фрукты (см. раздел 21.6.2), можно представить следующим образом:



Чем полезны контейнеры

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


struct Fruit {

 string name;

 int count;

 double unit_price;

 Date last_sale_date;

 // ...

};


struct Fruit_order

 {
bool operator()(const Fruit& a, const Fruit& b) const

 {

 return a.name

 }

};


set inventory; // использует функции класса

                  // Fruit_Order для сравнения

                  // объектов класса Fruit


Здесь мы снова видим, что объект-функция значительно расширяет спектр задач, которые удобно решать с помощью компонентов библиотеки STL.

Поскольку контейнер

set
не имеет значений, он не поддерживает операцию индексирования (
operator[]()
). Следовательно, вместо нее мы должны использовать “операции над списками”, такие как
insert()
и
erase()
. К сожалению, контейнеры
map
и
set
не поддерживают функцию
push_back()
по очевидной причине: место вставки нового элемента определяет контейнер
set
, а не программист.

Вместо этого следует использовать функцию

insert()
.


inventory.insert(Fruit("quince",5));

inventory.insert(Fruit("apple", 200, 0.37));


Одно из преимуществ контейнера

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


typedef set::const_iterator SI;

for (SI p = inventory.begin(),p!=inventory.end(); ++p)

  cout << *p 
<< '\n';


Разумеется, этот фрагмент работает, только если вы определили оператор

<<
для класса
Fruit
.

21.7. Копирование

В разделе 21.2 мы назвали функцию

find()
“простейшим полезным алгоритмом”. Естественно, эту точку зрения можно аргументировать. Многие простые алгоритмы являются полезными, даже тривиальными. Зачем писать новую программу, если можно использовать код, который кто-то уже написал и отладил? С точки зрения простоты и полезности алгоритм
copy()
даст алгоритму
find()
фору. В библиотеке STL есть три варианта алгоритма
copy()
.



21.7.1. Алгоритм copy()

Основная версия алгоритма

copy()
определена следующим образом:


template Out copy(In first, In last, Out res)

{

 while (first!=last) {

   *res = *first;  // копирует элемент

   ++res;

   ++first;

 }

 return res;

}


Получив пару итераторов, алгоритм

copy()
копирует последовательность в другую последовательность, заданную итератором на ее первый элемент. Рассмотрим пример.


void f(vector& vd, list& li)

 // копирует элементы списка чисел типа int в вектор чисел типа

 // double

{

 if (vd.size() < li.size()) error("целевой контейнер слишком мал");

 copy(li.begin(), li.end(), vd.begin());

 // ...

}


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

21.7.2. Итераторы потоков

Вы часто будете слышать выражения “копировать в поток вывода” или “копировать из потока ввода”. Это удобный и полезный способ описания некоторых видов ввода-вывода. Для выполнения этой операции действительно использует алгоритм

copy()
.

Напомним свойства последовательностей.

• Последовательность имеет начало и конец.

• Переход на следующий элемент последовательности осуществляется с помощью оператора

++
.

• Значение элемента последовательности можно найти с помощью оператора

*
.


Потоки ввода и вывода можно легко описать точно так же. Рассмотрим пример.


ostream_iterator oo(cout); // связываем поток *oo с потоком

                  // cout для записи

*oo = "Hello, ";          // т.е. cout << "Hello, "

++oo;                // "готов к выводу следующего

                  // элемента"

*oo = "World!\n";          // т.е. cout << "World!\n"


В стандартной библиотеке есть тип

ostream_iterator
, предназначенный для работы с потоком вывода;
ostream_iterator
— это итератор, который можно использовать для записи значений типа
T
.

В стандартной библиотеке есть также тип

istream_iterator
для чтения значений типа
T
.


istream_iterator ii(cin);  // чтение *ii — это чтение строки

                  // из cin


string s1 = *ii;          // т.е. cin>>s1

++ii;                // "готов к вводу следующего

                  // элемента"

string s2 = *ii; // т.е. cin>>s2


Используя итераторы

ostream_iterator
и
istream_iterator
, можно вводить и выводить данные с помощью алгоритма
copy()
. Например, словарь, сделанный наспех, можно сформировать следующим образом:


int main()

{

 string from, to;

 cin >> from >> to;     // вводим имена исходного

               // и целевого файлов


 ifstream is(from.c_str()); // открываем поток ввода

 ofstream os(to.c_str());  // открываем поток вывода


 istream_iterator ii(is); // создаем итератор ввода

                  // из потока

 istream_iterator eos;   // сигнальная метка ввода

 ostream_iterator oo(os,"\n"); // создаем итератор

                     // вывода в поток

 vector b(ii,eos);       // b — вектор, который

                     // инициализируется

                     // данными из потока ввода

 sort(b.begin(),b.end());        // сортировка буфера

 copy(b.begin(),b.end(),oo);      // буфер копирования для вывода

}


Итератор

eos
— это сигнальная метка, означающая “конец ввода.” Когда поток
istream
достигает конца ввода (который часто называется
eof
), его итератор
istream_iterator
становится равным итератору
istream_iterator
, который задается по умолчанию и называется
eos
.

Обратите внимание на то, что мы инициализируем объект класса vector парой итераторов. Пара итераторов

(a,b)
, инициализирующая контейнер, означает следующее: “Считать последовательность
[a:b]
в контейнер”. Естественно, для этого мы использовали пару итераторов
(ii,eos)
— начало и конец ввода. Это позволяет нам не использовать явно оператор
>>
и функцию
push_back()
. Мы настоятельно не рекомендуем использовать альтернативный вариант.


vector b(max_size); // не пытайтесь угадать объем входных

               // данных

copy(ii,eos,b.begin());


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


ПОПРОБУЙТЕ

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

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


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

Последнюю проблему можно решить, используя алгоритм

unique_copy()
вместо алгоритма
copy()
. Функция
unique_copy()
просто не копирует повторяющиеся идентичные значения. Например, при вызове обычной функции
copy()
программы введет строку


the man bit the dog


и выведет на экран слова


bit

dog

man

the

the


Если же используем алгоритм

unique_copy()
, то программа выведет следующие слова:


bit

dog

man

the


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

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


ostream_iterator oo(os,"\n"); // создает итератор для

                    // потока вывода


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


ostream_iterator oo(os," ");  // создает итератор для потока

                    // вывода


В этом случае результаты вывода выглядели бы так:


bit dog man the

21.7.3. Использование класса set для поддержания порядка

Существует еще более простой способ получить такой вывод: использовать контейнер

set
, а не
vector
.


int main()

{

 string from, to;

 cin >> from >> to;      // имена исходного и целевого файлов


 ifstream is(from.c_str());  // создаем поток ввода

 ofstream os(to.c_str());   // создаем поток вывода


 istream_iterator ii(is);   // создаем итератор ввода

                    // из потока

 istream_iterator eos;     // сигнальная метка для ввода

  ostream_iterator oo(os," "); // создаем итератор

                    // вывода в поток

 set b(ii,eos);   // b — вектор, который инициализируется

               // данными из потока ввода

 copy(b.begin(),b.end(),oo); // копируем буфер в поток вывода

}


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

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

21.7.4. Алгоритм copy_if()

Алгоритм

copy()
выполняет копирование без каких-либо условий. Алгоритм
unique_copy()
отбрасывает повторяющиеся соседние элементы, имеющие одинаковые значения. Третий алгоритм копирует только элементы, для которых заданный предикат является истинным.


template

Out copy_if(In first,In last,Out res,Pred p)

  // копирует элементы, удовлетворяющие предикату

{

 while (first!=last) {

   if (p(*first)) *res++ = *first;

   ++first;

 }

 return res;

}


Используя наш объект-функцию

Larger_than
из раздела 21.4, можем найти все элементы последовательности, которые больше шести.


void f(const vector& v)

  // копируем все элементы, которые больше шести

{

 vector v2(v.size());

 copy_if(v.begin(),v.end(),v2.begin(),Larger_than(6));

 // ...

}


Из-за моей ошибки этот алгоритм выпал из стандарта 1998 ISO Standard. В настоящее время эта ошибка исправлена, но до сих пор встречаются реализации языка С++, в которых нет алгоритма

copy_if
. В таком случае просто воспользуйтесь определением, данным в этом разделе.

21.8. Сортировка и поиск

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

map
и
set
, или выполняя сортировку. Наиболее распространенной и полезной операцией сортировки в библиотеке STL является функция
sort()
, которую мы уже несколько раз использовали. По умолчанию функция
sort()
в качестве критерия сортировки использует оператор
<
, но мы можем задавать свои собственные критерии.


template void sort(Ran first, Ran last);

template void sort(Ran first,Ran last,Cmp cmp);


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


struct No_case { // lowercase(x) < lowercase(y)

 bool operator()(const string& x, const string& y) const

 {

   for (int i = 0; i

    if (i == y.length()) return false;    // y

    char xx = tolower(x[i]);

    char yy = tolower(y[i]);

    if (xx

    if (yy

   }

   if (x.length()==y.length()) return false; // x==y

   return true;  // x

 }

};


void sort_and_print(vector& vc)

{

 sort(vc.begin(),vc.end(),No_case());

 for (vector::const_iterator p = vc.begin();

   p!=vc.end(); ++p)

 cout << *p << '\n';

}


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

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

Предположим, что мы ищем значение x; посмотрим на средний элемент.

• Если значение этого элемента равно

x
, мы нашли его!

• Если значение этого элемента меньше

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

• Если значение этого элемента больше

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

• Если мы достигли последнего элемента (перемещаясь влево или вправо) и не нашли значение

x
, то в контейнере нет такого элемента.


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

find()
(представляющий собой линейный поиск). Алгоритмы бинарного поиска в стандартной библиотеке называются
binary_search()
и
equal_range()
. Что мы понимаем под словом “длинные”? Это зависит от обстоятельств, но десяти элементов обычно уже достаточно, чтобы продемонстрировать преимущество алгоритма
binary_search()
над алгоритмом
find()
. На последовательности, состоящей из тысячи элементов, алгоритм
binary_search()
работает примерно в 200 раз быстрее, чем алгоритм
find()
, потому что он имеет сложность O(log2N) (см. раздел 21.6.4).

Алгоритм

binary_search
имеет два варианта.


template

bool binary_search(Ran first,Ran last,const T& val);


template

bool binary_search(Ran first,Ran last,const T& val,Cmp cmp);


Эти алгоритмы требуют, чтобы их входные последовательности были упорядочены. Если это условие не выполняется, то могут возникнуть такие интересные вещи, как бесконечные циклы. Алгоритм

binary_search()
просто сообщает, содержит ли контейнер заданное значение.


void f(vector& vs)  // vs упорядочено

{

 if (binary_search(vs.begin(),vs.end(),"starfruit")) {

   // в контейнере есть строка "starfruit"

 }

 // ...

}


Итак, алгоритм

binary_search()
— идеальное средство, если нас интересует, есть заданное значение в контейнере или нет. Если нам нужно найти этот элемент, мы можем использовать функции
lower_bound()
,
upper_bound()
или
equal_range()
(разделы 23.4 и Б.5.4). Как правило, это необходимо, когда элементы контейнера представляют собой объекты, содержащие больше информации, чем просто ключ, когда в контейнере содержатся несколько элементов с одинаковыми ключами или когда нас интересует, какой именно элемент удовлетворяет критерию поиска.


Задание

После выполнения каждой операции выведите содержание вектора на экран.

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

struct Item { string name; int iid; double value; /* ... */ };
, создайте контейнер
vector vi
и заполните его десятью строками из файла.

2. Отсортируйте контейнер

vi
по полю
name
.

3. Отсортируйте контейнер

vi
по полю
iid
.

4. Отсортируйте контейнер

vi
по полю
value
; выведите его содержание на печать в порядке убывания значений (т.е. самое большое значение должно быть выведено первым).

5. Вставьте в контейнер элементы

Item("horse shoe",99,12.34)
и
Item("Canon S400",9988,499.95)
.

6. Удалите два элемента Item из контейнера

vi
, задав поля
name
.

7. Удалите два элемента Item из контейнера

vi
, задав поля
iid
.

8. Повторите упражнение с контейнером типа

list
, а не
vector
.


Теперь поработайте с контейнером

map
.

1. Определите контейнер

map
с именем
msi
.

2. Вставьте в него десять пар (имя, значение), например

msi["lecture"]=21
.

3. Выведите пары (имя, значение) в поток

cout
в удобном для вас виде.

4. Удалите пары (имя, значение) из контейнера

msi
.

5. Напишите функцию, считывающую пары из потока

cin
и помещающую их в контейнер
msi
.

6. Прочитайте десять пар из потока ввода и поместите их в контейнер

msi
.

7. Запишите элементы контейнера

msi
в поток
cout
.

8. Выведите сумму (целых) значений из контейнера

msi
.

9. Определите контейнер

map
с именем
mis
.

10. Введите значения из контейнера

msi
в контейнер
mis
; иначе говоря, если в контейнере
msi
есть элемент
("lecture",21
), то контейнер mis также должен содержать элемент (
21,"lecture"
).

11. Выведите элементы контейнера

mis
в поток
cout
.


Несколько заданий, касающихся контейнера

vector
.

1. Прочитайте несколько чисел с плавающей точкой (не меньше 16 значений) из файла в контейнер

vector
с именем
vd
.

2. Выведите элементы контейнера

vd
в поток
cout
.

3. Создайте вектор

vi
типа
vector
с таким же количеством элементов, как в контейнере
vd
; скопируйте элементы из контейнера
vd
в контейнер
vi
.

4. Выведите в поток

cout
пары (
vd[i]
,
vi[i]
) по одной в строке.

5. Выведите на экран сумму элементов контейнера

vd
.

6. Выведите на экран разность между суммой элементов контейнеров

vd
и
vi
.

7. Существует стандартный алгоритм reverse, получающий в качестве аргументов последовательность (пару итераторов); поменяйте порядок следования элементов

vd
на противоположный и выведите их в поток
cout
.

8. Вычислите среднее значение элементов в контейнере

vd
и выведите его на экран.

9. Создайте новый контейнер

vector
с именем
vd2
и скопируйте в него элементы контейнера
vd
, которые меньше среднего значения.

10. Отсортируйте контейнер

vd
и выведите его элементы на экран.


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

1. Приведите примеры полезных алгоритмов из библиотеки STL?

2. Что делает алгоритм

find()
? Приведите по крайней мере пять примеров.

3. Что делает алгоритм

count_if()
?

4. Что алгоритм

sort(b,e)
использует в качестве критерия поиска?

5. Как алгоритмы из библиотеки STL получают контейнеры в качестве аргумента ввода?

6. Как алгоритмы из библиотеки STL получают контейнеры в качестве аргумента вывода?

7. Как алгоритмы из библиотеки STL обозначают ситуации “не найден” или “сбой”?

8. Что такое функция-объект?

9. Чем функция-объект отличается от функции?

10. Что такое предикат?

11. Что делает алгоритм

accumulate()
?

12. Что делает алгоритм

inner_product()
?

13. Что такое ассоциативный контейнер? Приведите не менее трех примеров.

14. Является ли класс

list
ассоциативным контейнером? Почему нет?

15. Сформулируйте принцип организации бинарного дерева.

16. Что такое (примерно) сбалансированное дерево?

17. Сколько места занимает элемент в контейнере

map
?

18. Сколько места занимает элемент в контейнере

vector
?

19. Зачем нужен контейнер

unordered_map
, если есть (упорядоченный) контейнер
map
?

20. Чем контейнер

set
отличается от контейнера
map
?

21. Чем контейнер

multimap
отличается от контейнера
map
?

22. Зачем нужен алгоритм

copy()
, если мы вполне могли бы написать простой цикл?

23. Что такое бинарный поиск?


Термины


Упражнения

1. Перечитайте главу и выполните все упражнения из врезок ПОПРОБУЙТЕ, если вы еще не сделали этого.

2. Найдите надежный источник документации по библиотеке STL и перечислите все стандартные алгоритмы.

3. Самостоятельно реализуйте алгоритм

count()
. Протестируйте его.

4. Самостоятельно реализуйте алгоритм

count_if()
. Протестируйте его.

5. Что нам следовало бы сделать, если бы мы не могли вернуть итератор

end()
, означающий, что элемент не найден? Заново спроектируйте и реализуйте алгоритмы
find()
и
count()
, чтобы они получали итераторы, установленные на первый и последний элементы. Сравните результаты со стандартными версиями.

6. В примере класса

Fruit
из раздела 21.6.5 мы копировали структуры
Fruit
в контейнер
set
. Что делать, если мы не хотим копировать эти структуры? Мы могли бы вместо этого использовать контейнер
set
. Однако в этом случае мы были бы вынуждены определить оператор сравнения для этого контейнера. Выполните это упражнение еще раз, используя контейнер
set
,
Fruit_comparison>
. Обсудите разницу между этими реализациями.

7. Напишите функцию бинарного поиска для класса

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

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

3: C++
, а не
C++: 3
.

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

Order
(заказ), члены которого содержат имя клиента, его адрес, дату рождения и контейнер
vector
. Класс
Purchase
должен содержать поля
name
,
unit_price
и
count
, характеризующие товар. Определите механизм считывания из файла и записи в файл объектов класса
Order
. Определите механизм для вывода на экран объектов класса
Order
. Создайте файл, содержащий по крайней мере десять объектов класса
Order
, считайте его в контейнер
vector
, отсортируйте по имени (клиента) и запишите обратно в файл. Создайте другой файл, содержащий по крайней мере десять объектов класса
Order
, примерно треть из которых хранится в первом файле, считайте их в контейнер
list
, отсортируйте по адресам (клиента) и запишите обратно в файл. Объедините два файла в третий файл, используя функцию
std::merge()
.

10. Вычислите общее количество заказов в двух файлах из предыдущего упражнения. Значение отдельного объекта класса

Purchase
(разумеется) равно
unitprice*count
.

11. Разработайте графический пользовательский интерфейс для ввода заказов из файла.

12. Разработайте графический пользовательский интерфейс для запроса файла заказов; например, “Найти все заказы от

Joe
,” “определить общую стоимость заказов в файле
Hardware
” или “перечислить все заказы из файла
Clothing
.” Подсказка: сначала разработайте обычный интерфейс и лишь потом на его основе начинайте разрабатывать графический.

13. Напишите программу, “очищающую” текстовый файл для использования в программе, обрабатывающей запросы на поиск слов; иначе говоря, замените знаки пунктуации пробелами, переведите слова в нижний регистр, замените выражения don’t словами do not (и т.д.) и замените существительные во множественном числе на существительные в единственном числе (например, слово ships станет ship). Не перестарайтесь. Например, определить множественное число в принципе трудно, поэтому просто удалите букву s, если обнаружите как слово ship, так и слово ships. Примените эту программу к реальному текстовому файлу, содержащему не менее 5 000 слов (например, к научной статье).

14. Напишите программу (используя результат предыдущего упражнения), отвечающую на следующие вопросы и выполняющую следующие задания: “Сколько раз слово ship встречается в файле?” “Какое слово встречается чаще всего?” “Какое слово в файле самое длинное?” “Какое слово в файле самое короткое?” “Перечислите все слова на букву s” и “Перечислите все слова, состоящие из четырех букв”.

15. Разработайте графический пользовательский интерфейс из предыдущего упражнения.


Послесловие

Библиотека STL является частью стандартной библиотеки ISO C++, содержащей контейнеры и алгоритмы. Она предоставляет обобщенные, гибкие и полезные базовые инструменты. Эта библиотека позволяет сэкономить массу усилий: изобретать колесо заново может быть забавным, но вряд ли продуктивным занятием. Если у вас нет весомых причин избегать библиотеки STL, то используйте ее контейнеры и основные алгоритмы. Что еще важнее, библиотека STL — это пример обобщенного программирования, демонстрирующий, как способы устранения конкретных проблем и набор конкретных решений могут вырасти в мощную и универсальную коллекцию полезных инструментов. Если вам необходимо манипулировать данными — а большинство программистов именно этим и занимаются, — библиотека STL продемонстрирует пример, идею и подход к решению задачи.

Загрузка...