использует оператор 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<int> v;
// ...
if (x) p[x] = v.at(x);
// ...
delete[] p; // освобождаем память
}
Последняя возможность беспокоит нас больше всего. Когда люди впервые сталкиваются с такой проблемой, они считают, что она связана с исключениями, а не с управлением ресурсами. Не понимая истинных причин проблемы, они пытаются перехватывать исключения.
void suspicious(int s, int x) // плохой код
{
int* p = new int[s]; // занимаем память
vector<int> v;
// ...
try {
if (x) p[x] = v.at(x);
// ...
} catch (...) { // перехватываем все исключения
delete[] p; // освобождаем память
throw; // генерируем исключение повторно
}
// ...
delete[] p; // освобождаем память
}
Этот код решает проблему за счет дополнительных инструкций и дублирования кода, освобождающего ресурсы (в данном случае инструкции delete[] p;). Иначе говоря, это некрасивое решение; что еще хуже — его сложно обобщить. Представим, что мы задействовали несколько ресурсов.
void suspicious(vector<int>& v, int s)
{
int* p = new int[s];
vector<int>v1;
// ...
int* q = new int[s];
vector<double> v2;
// ...
delete[] p;
delete[] q;
}
Обратите внимание на то, что, если оператор new не сможет выделить свободную память, он сгенерирует стандартное исключение bad_alloc. Прием try ... catch в этом примере также успешно работает, но нам потребуется несколько блоков try, и код станет повторяющимся и ужасным. Мы не любим повторяющиеся и запутанные программы, потому что повторяющийся код сложно сопровождать, а запутанный код не только сложно сопровождать, но и вообще трудно понять.
ПОПРОБУЙТЕ
Добавьте блоки try в последний пример и убедитесь, что все ресурсы будут правильно освобождаться при любых исключениях.
19.5.2. Получение ресурсов — это инициализация
К счастью, нам не обязательно копировать инструкции try...catch, чтобы предотвратить утечку ресурсов. Рассмотрим следующий пример:
void f(vector<int>& v, int s)
{
vector<int> p(s);
vector<int> 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<int>* make_vec() // создает заполненный вектор
{
vector<int>* p = new vector<int>; // выделяем свободную память
// ...заполняем вектор данными;
// возможна генерация исключения...
return p;
}
Это довольно распространенный пример: мы вызываем функцию, чтобы создать сложную структуру данных, и возвращаем эту структуру как результат. Однако, если при заполнении вектора возникнет исключение, функция make_vec() потеряет этот объект класса vector.