Pull to refresh

Безопасные конструкторы

Reading time4 min
Views24K
Недавняя статья о порядке инициализации членов класса вызвала весьма любопытную дискуссию, в которой, среди прочих, обсуждался вопрос, как правильно оформлять члены класса, хранить ли их по значению и организовывать конструктор так:

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».

Всем успехов! И пусть ваша память никогда не течёт!

Tags:
Hubs:
+38
Comments58

Articles

Change theme settings