*** ВНИМАНИЕ: Блог переехал на другой адрес - demin.ws ***

четверг, 19 февраля 2009 г.

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

Правилом хорошего тона в С++ является использование списка инициализации для вызова конструкторов членов класса, например:
class A { ... };

class B {
public:
B(int n);
private:
A __a;
};

B::B(int n)
: __a(n) // вызов конструктора А() в списке инициализации.
{}
А что произойдет, если в одном из вызовов в списке инициализации произойдет исключение? Например:
class A {
public:
A(int n) {
throw 0; // Конструктор класса А бросает исключение int
}
};

class B {
public:
B(int n);
private:
A __a;
};

B::B(int n)
: __a(n) // Данный вызов бросает исключение
{}
Хотелось бы иметь возможность поймать это исключение и провести "чистку" уже распределенной на тот момент памяти, например:
class P { ... };

class A {
public:
A(int n) {
throw 0; // Конструктор класс А бросает исключение int
}
};

class B {
public:
B();
private:
P* __p;
A __a;
};

B::B()
: __p(new P), // Память для P распределяется до вызова конструктора класса А
__a(0) // Данный вызов бросает исключение
{}
На момент, когда конструктор А бросит исключение, мы уже будем иметь распределенную память под указателем __p и, не обработав исключение, эту память можно потерять.

В С++ есть форма задания try-catch блока на уровне функции. Используя ее, можно переписать пример так:

#include <iostream>

class A {
public:
A(int n) {
throw 0; // Конструктор класс А бросает исключение int
}
};

class P {
public:
P() { std::cout << "P(), constructor" << std::endl; }
~P() { std::cout << "~P(), destructor" << std::endl; }
};

class B {
public:
B();
private:
P* __p;
A __a;
};

B::B()
try
: __p(new P), __a(0) {
} catch (int& e) {
std::cout << "B(), exception " << e << std::endl;
delete __p;
};

int main(int argc, char* argv[]) {
try {
B b;
} catch (int& e) {
std::cout << "main(), exception " << e << std::endl;
}
}
Видно (см. тело конструктора B::B()), что лист инициализации ушел между словом try и началом try-блока, а тело конструктора теперь внутри try-блока (в данном примере оно пустое), а обработчик исключения находится в catch-блоке после тела конструктора. Данный пример сумеет обработать исключение класса А и освободит память из под указателя __p. Данный пример выведет следующее:
P(), constructor
B(), exception 0
~P(), destructor
main(), exception 0
Видно, что деструктор класса P был вызван.

Внимательный читатель заметит, что в функции main() тоже есть try-блок, а последней строкой программа печатает "main(), exception 0", что значит, что исключение было обработано дважды: в теле try-блока конструктора и затем в функции main(). Почему?

Правило гласит: исключение, пойманное в обрамляющем функцию виде try-catch блоке конструктора, будет переброшено еще раз при выходе из конструктора, если конструктор принудительно не сделал это сам, поймав это исключение. Сейчас очень важный момент: если хоть один из членов класса бросил исключение в процессе конструирования, то весь объект принудительно завершает конструирование аварийно с исключением вне зависимости от того, обработано это исключение в конструкторе или нет.
Единственное, что мы тут можем сделать, это "на лету" подправить исключение, брошенное членом класса (например, добавить туда дополнительную информацию). Следующий пример меняет код брошенного классом А исключения:
#include <iostream>

class A {
public:
A(int n) {
throw 0; // Конструктор класс А бросает исключение int
}
};

class B {
public:
B();
private:
A __a;
};

B::B()
try
: __a(0) {
} catch (int& e) {
std::cout << "B(), exception " << e << std::endl;
e = 1; // Меняем код исключения с 0 на 1.
};

int main(int argc, char* argv[]) {
try {
B b;
} catch (int& e) {
std::cout << "main(), exception " << e << std::endl;
}
}
Эта программы выведет следующее:
B(), exception 0
main(), exception 1
Видно, что когда исключение было поймано второй раз, код у него уже не 0 как в оригинальном исключении, а 1.

С конструкторами вроде разобрались. Перейдем к деструкторам.

Деструктор — это тоже функция. К нему тоже применим синтаксис ловли исключения на уровне тела функции, например:

#include <iostream>

class B {
public:
~B();
};

B::~B()
try {
throw 2;
} catch (int& e) {
std::cout << "~B(), exception " << e << std::endl;
}
Поведение ловли исключения в деструкторе на уровне функции схоже с конструктором, то есть исключение, пойманное в catch-блоке на уровне функции будет переброшено автоматически снова при завершении деструктора, если он это не сделал сам, обработав исключение. Например:
#include <iostream>

class B {
public:
~B();
};

B::~B()
try {
throw 2;
} catch (int& e) {
std::cout << "~B(), exception " << e << std::endl;
}

int main(int argc, char* argv[]) {
try {
B b;
} catch (int& e) {
std::cout << "main(), B(), exception " << e << std::endl;
}
}
выведет:
~B(), exception 2
main(), B(), exception 2
то есть исключение, после его обработки в деструкторе было переброшено снова. Конечно, не пойманные исключения в деструкторе являются большим "no-no!" в С++. Принято считать, что не пойманное в деструкторе исключение — это прямой путь к аварийному завершению программы, так как нарушается принцип целостности системы исключений. Если хотите, чтобы ваши программы на С++ работали стабильно, то не допускайте, чтобы исключения “вылетали” из деструктора. Например так:
#include <iostream>

class B {
public:
~B();
};

B::~B() {
try {
throw 2; // Бросаем исключение.
} catch (int& e) { // И тут же ловим его, не пропуская него “на волю”.
std::cout << "~B(), exception " << e << std::endl;
}
}

int main(int argc, char* argv[]) {
try {
B b;
} catch (int& e) {
std::cout << "main(), B(), exception " << e << std::endl;
}
}
Эта программа выведет:
~B(), exception 2
Видно, что исключение не дошло до функции main().

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

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

void f() 
try {
throw 1;
} catch (int& e) {
std::cout << "f(), exception " << e << std::endl;
}
Но целесообразность такого синтаксиса сомнительна, так как пойманное исключение не перебрасывается автоматически снова после окончания функции, как это было в случае с конструктором и деструктором. Программа:
#include <iostream>

void f()
try {
throw 1;
} catch (int& e) {
std::cout << "f(), B(), exception " << e << std::endl;
}

int main(int argc, char* argv[]) {
try {
f();
} catch (int& e) {
std::cout << "main(), f(), B(0), exception " << e << std::endl;
}
}
напечатает только:
f(), B(), exception 1
то есть исключение не было передано дальше, поэтому разумнее было бы просто оформить функцию традиционным образом с помощью try-блока, обрамляющего всё тело функции:
void f() {
try {
throw 1;
} catch (int& e) {
std::cout << "f(), B(), exception " << e << std::endl;
}
}
не внося в форматирование текста лишней каши непривычным положением слов try и catch.
Лично мне кажется, из всего выше написанного, реально для применения только try-catch блок на уровне функции для конструктора. Там это действительно актуально, чтобы не допустить объектов, сконструированных только наполовину и убитых в процессе создания исключением от собственного члена (простите за каламбур).
Выводы

Исключения, брошенные при обработке списка инициализации класса можно поймать в теле конструктора через синтаксис try-catch блока на уровне функции.

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

6 комментариев:

  1. Уродовать try/catch список инициализации никоим образом неможно. Везде нужно использовать RAII и спать спокойно.

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

    ОтветитьУдалить
  2. А как быть, если в приведенном мной примере класс А является сторонним (мы не можем его поменять) и известно, что он может бросать исключения в конструкторе? Переносить создание члена "а" и всех зависимых от его возможного исключения при конструировании членов в тело конструктора и там ловить исключения? как RAII тут может помочь?

    Печать на экран в данных примерах сделана исключительно в демострационных целях.

    Что значит "исключение в деструкторе должно аварийно завершать работу программы"? То есть я НЕ должен ловить каких-либо исключений в деструкторе? Это как-то неверно. Например, класс tcp-сервер. Один из его членов - слушаюший сокет. Когда деструктор tcp-сервера вызовет деструктор сокета последний может метнуть исключение, так как, например, физическое tcp соединение может быть уже разорвано и close/closesocket/shutdown могут вернуть ошибку. Но исключение в разрушении сокета никак не должно влиять на деструктор сервера. Просто это исключение ловится и деструктор сервера нормально завершается.

    ОтветитьУдалить
  3. При обработке исключений конструктора есть одно НО: в блоке catch нельзя делать delete __p равно как и delete любых других указателей, ввиду невозможности узнать был ли объект P сконструирован к моменту исключения ( веть может исключение произошло на инструкции __p(new P).
    т.е. в обработчике исключений конструктора никакие объекты удалять нельзя

    ОтветитьУдалить
  4. Так вроде для подобных случаев синтаксис try-catch блока на уровне функции и придумали. Список инициализации обрабатывается последовательно, значит можно по типу брошеного исключения понять, кто именно его бросил. А затем удалить всех, кто уже был создан до проблемного объекта.

    ОтветитьУдалить
  5. Во фразе "то есть исключение не было передано дальше, поэтому разумнее было бы просто оформить функцию традиционным образом с помощью try-блока, обрамляющего всё тело функции" есть намек на то что, при оформлении блока традиционным образом исключение будет передано (в любом случае), я даже немного испугался за свои познания. Поправьте, а то это может кого запутать.

    ОтветитьУдалить
  6. Я раз десять перечитал абзац, пытась как-то его улучшить. Не получается у меня таки уловить тут смысл, где намекается на возможность безусловного переброса исключения в обычных функциях. Тут наоборот говорится, что "обычный" синтаксис для исключений предпочтительнее для обычных функций, так как он выглядить привычнее, а ведет себя точно также, как и синтаксис try-блока-обрамления функции.

    Есть вариант, как это можно написать получше?

    ОтветитьУдалить