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

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

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 блока на уровне функции.

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


Оригинальный пост | Disclaimer

Комментарии