Недавня стаття про порядок ініціалізації членів класу викликала досить цікаву дискусію, в якій, серед інших, обговорювалося питання, як правильно оформляти члени класу, зберігати їх за значенням і організовувати конструктор так:
A::A(int x) : b(x) {}
Або зберігати їх за посиланням:
A::A(int x) { b = new B(x); }
Існує безліч «за» і «проти» для кожного з підходів, але в цій замітці мені б хотілося зосередитися на питаннях обробки винятків.
Почнемо по-порядку. Нехай у нас є якийсь клас, конструктор якого, в деяких випадках, може викликати виняток (немає файла, немає зв'язку, не підійшов пароль, недостатньо прав для виконання операції... що завгодно). Наш клас буде гранично простий і передбачуваний.
class X {
private:
int xx;
public:
X(int x) {
cout << ""X::X x="" << x << endl;
if (x == 0) throw(exception());
xx = x;
}
~X() {
cout << ""X::~X x="" << xx << endl;
}
};
Тобто, якщо аргумент конструктора дорівнює нулю, то запускається виняток.
Припустимо, нам потрібен якийсь клас, об'єкти якого повинні містити по два об'єкти класу X.
Варіант перший - з покажчиками (обережно, небезпечний код!)
Робимо все по-науці:
class Cnt {
private:
X *xa;
X *xb;
public:
Cnt(int a, int b) {
cout << ""Cnt::Cnt"" << endl;
xa = new X(a);
xb = new X(b);
}
~Cnt() {
cout << ""Cnt::~Cnt"" << endl;
delete xa;
delete xb;
}
};
Здавалося б, нічого не забули. (Хоча, строго кажучи, звичайно забули як мінімум конструктор копіювання і операцію привласнення, які б коректно працювали з нашими покажчиками; ну та гаразд.)
Скористаємося цим класом:
try {
Cnt c(1, 0);
} catch (...) {
cout << ""error"" << endl;
}
І розберемося, що і коли буде конструюватися і знищуватися.
- Спочатку буде запущено процес створення об'єкта Cnt.
- У ньому буде створено об "єкт * xa
- Почне створення об'єкта * xb...
- … і тут відбудеться виняток
Все. Конструктор припинить свою роботу, а деструктор об'єкта Cnt викликаний не буде (і це правильно, об'єкт-то не створився). Отже, що ми маємо? Один об'єкт X, вказівник на який (xa) назавжди втрачено. У цьому місці ми відразу отримуємо витік пам'яті, а можливо, отримуємо витік і більш цінних ресурсів, соктів, курсорів...
Зверніть увагу, що це одна з найбільш неприємних ситуацій, витік виникає не завжди, а тільки при певних аргументах (перший - не нуль, а другий - нуль). Відшукати такі витоки буває дуже складно.
Очевидно, таке рішення годиться тільки для дуже простеньких програмок, які в разі будь-якого винятку просто безпорадно валяться і все.
Які ж є рішення?
Найпростіше, надійне і природне рішення - зберігати об'єкт за значенням
Приклад:
class Cnt {
private:
X xa;
X xb;
public:
Cnt(int a, int b) : xa(a), xb(b) {
cout << ""Cnt::Cnt"" << endl;
}
~Cnt() {
cout << ""Cnt::~Cnt"" << endl;
}
};
Це компактно, це елегантно, це природно... але головне - це безпечно! У цьому випадку компілятор стежить за всім, що відбувається, і (по-можливості) вичищає все, що вже не знадобиться.
Результат роботи коду:
try {
Cnt c(1, 0);
} catch (...) {
cout << ""error"" << endl;
}
буде таким:
X::X x=1
X::X x=0
X::~X x=1
error
Тобто об'єкт Cnt::xa було автоматично коректно знищено.
Божевільне рішення з покажчиками
Справжнім кошмаром може стати ось таке рішення:
Cnt(int a, int b) {
cout << ""Cnt::Cnt"" << endl;
xa = new X(a);
try {
xb = new X(b);
} catch (...) {
delete xa;
throw;
}
}
Уявляєте, що буде, якщо з'явиться Cnt::xc? А якщо доведеться змінити порядок ініціалізації?.. Треба буде докласти не мало зусиль, щоб нічого не забути, супроводжуючи такий код. І, що найприкріше, це ви самі для себе ж розклали скрізь граблі.
Ліричний відступ про винятки.
Для чого були придумані винятки? Для того, щоб відокремити опис нормального ходу програми від опису реакції на якісь збої.
У цьому прикладі ми грубо зневажаємо цю чудову доктрину. Нам доводиться розміщувати код, що обробляє виняток, в безпосередній близькості з кодом, що викликає виняток.
Це зводить нанівець всю принадність механізму винятків. Фактично, ми повертаємося до концепції C, де після кожної операції треба перевіряти значення глобальних змінних або інші ознаки виникнення помилок.
Це робить код заплутаним і важким для розуміння і підтримки.
Рішення справжніх індіанців - розумні покажчики
Якщо вам все ж слід зберігати індекси, ви все одно можете убезпечити свій код, якщо зробите для індексів обертання. Їх можна писати самим, а можна використовувати безліч, вже існуючих. Приклад використання auto_ptr:
class Cnt {
private:
auto_ptr<X> ia;
auto_ptr<X> ib;
public:
Cnt(int a, int b) : ia(new X(a)), ib(new X(b)) {
cout << ""Cnt::Cnt"" << endl;
}
~Cnt() {
cout << ""Cnt::~Cnt"" << endl;
}
};
Ми практично повернулися до рішення зі зберіганням членів класу за значенням. Тут ми зберігаємо за значенням об'єкти класу auto_ptr<X>, про своєчасне видалення цих об'єктів знову піклується компілятор (зверніть увагу, тепер нам не треба самостійно викликати delete в деструкторі); а вони, в свою чергу, зберігають наші покажчики на об'єкти X і піклуються про те, щоб пам'ять вчасно звільнялася.
Так! І не забудьте з'єднати
#include <memory>
Там описано шаблон auto_ptr.
Ліричний відступ про new
Одна з переваг C++ перед C полягає в тому, що C++ дозволяє працювати зі складними структурами даних (об'єктами), як зі звичайними змінними. Тобто C++ сам створює ці структури і сам видаляє їх. Програміст може не замислюватися про звільнення ресурсів до тих пір, поки він (програміст) не почне сам створювати об'єкти. Як тільки ви написали «new», ви зобов'язали себе написати «delete» скрізь, де це потрібно. І це не тільки деструктори. Більше того, вам швидше за все доведеться самостійно реалізовувати операцію копіювання і операцію присвоєння... Одним словом, ви відмовилися від послуг С++ і потрапили на досить хиткий ґрунт.
Звичайно, в реальному житті часто доводиться використовувати «new». Це може бути пов'язано зі специфікою алгоритму, продиктовано вимогами щодо продуктивності або просто нав'язано чужими інтерфейсами. Але якщо у вас є вибір, то напевно варто тричі подумати, перш, ніж написати слово «new».
Всім успіхів! І нехай ваша пам'ять ніколи не тече!