С++: Безопасность относительно исключений при вызове функций

Этот пост - перевод статьи Герба Саттера: GotW #102: Exception-Safe Function Calls.

Простой вопрос

1. Что вы можете сказать о порядке выполнения функций f, g и h и выражений expr1 и expr2 в приведенных ниже фрагментах кода? Предпалагается, что expr1 и expr2 не содражат в себе вызовов других функций.
// Пример 1(a)
//
f( expr1, expr2 );
// Пример 1(b)
//
f( g( expr1 ), h( expr2 ) );

Вопросы посложнее

2. Разбирая доставшийся вам в наследство код, вы наткнулись на следующий фрагмент:
// Пример 2
 
// В заголовочном файле:
void f( T1*, T2* );
 
// В месте вызова:
f( new T1, new T2 );
Есть ли в этом коде проблемы с безопасностью относительно исключений или любые другие проблемы? Объясните.

3. Продолжая изучать старый код, вы обнаруживаете, что кому-то не нравился код, приведенный в примере 2, и позднее он был изменен на:
// Пример 3
 
// В заголовочном файле:
void f( std::unique_ptr<T1>, std::unique_ptr<T2> );
 
// В месте вызова:
f( std::unique_ptr<T1>{ new T1 }, std::unique_ptr<T2>{ new T2 } );
Какие улучшения привнес новый подход относительно старого (и привнес ли вообще)? Остались ли проблемы с безопасностью относительно исключений? Объясните.

4. Покажите, как можно реализовать функцию make_unique, которая бы решала проблемы с безопасностью в примере 3, и которую можно было бы вызывать следующим образом:
// Пример 4
 
// В заголовочном файле:
void f( std::unique_ptr<T1>, std::unique_ptr<T2> );
 
// В месте вызова:
f( make_unique<T1>(), make_unique<T2>() );

Решение

1. Что вы можете сказать о порядке выполнения функций fg и h и выражений expr1 и expr2 в приведенных ниже фрагментах кода? Предпалагается, что expr1 и expr2 не содеражат в себе вызовов других функций.

Ответ может быть дан исходя из следующих основных правил:
  • Значения всех аргументов функции должны быть вычислены перед тем, как сама функция будет вызвана. Это означает также полное выполнение всех дополнительных действий, заданных в выражениях, которые выступают в качестве аргумента функции. 
  • Как только начинается выполнение функции, никакие выражения принадлежащие вызывающей функции не могут начать или продолжить выполняться, пока вызванная функция не завершится. Выполнение инструкций одной функции не может чередоваться с выполнением выражений другой.
  • Выражения, которые используются в качестве аргумента функции, в общем случае могут выполняться в любом порядке, в том числе выполнение выражений, из которых состоит одно выражение, может чередоваться с выполнением выражений, из которых состоит другое, если это не противоречит другим правилам.
В ISO C++11 эти правила определeны отношением "следует до" ("sequenced before" - подробнее можно ознакомиться в англоязычной Википедии или на cppreference.com), которое касается преобразований, выполняемых компиляторами или "железом" внутри одного потока выполнения. Это отношение заменяет (но при этом предполагается, что оно аналогично) предыдущую концепцию точек следования в С и С++.
Давайте поймем, используя эти правила, что происходит в наших первых примерах.
// Пример 1(a)
//
f( expr1, expr2 );
В примере 1(а) мы можем сказать, что оба выражения expr1 и expr2 должны быть выполнены до того, как будет вызвана функция f.
И это все. Компилятор может выполнить выражение expr1 как до так и после выражения expr2, более того, составляющие их выражения могут даже чередоваться между собой при выполнении. Находится достаточное число людей, которые находят этот факт удивительным, чтобы этот вопрос постоянно всплывал на форумах, посвященных С++, тогда как это всего лишь прямое следствие правил С и С++ последовательности выполнения выражений внутри одного потока выполнения.
// Пример 1(b)
//
f( g( expr1 ), h( expr2 ) );
В примере 1(b) функции и выражения могут быть выполнены в любом порядке, который не противоречит следующим правилам:

  • выражение expr1 должно быть выполнено до того, как будет вызвана функция g;
  • выражение expr2 должно быть выполнение до того, как будет вызвана функция h;
  • функции g и h должны завершиться до того, как будет вызвана функция f.
Выражения, из которых состоят выражения expr1 и expr2, могут чередоваться при выполнении, но ни одно из них не может чередоваться с выполнением любой функции. Например, никакая часть вычислений внутри выражения expr2 и никакая часть функции h не может выполняться с того момента, как началось выполнение функции g и до того момента, как оно закончится. При этом функция h может быть вызвана как до так и после функции g.

Немного о проблемах безопасности относительно исключений при вызове функций

2. Разбирая доставшийся вам в наследство код, вы наткнулись на следующий фрагмент:
// Пример 2
 
// В заголовочном файле:
void f( T1*, T2* );
 
// В месте вызова:
f( new T1, new T2 );
Есть ли в этом коде проблемы с безопасностью относительно исключений или любые другие проблемы? Объясните.

Да, здесь есть несколько потенциальных проблем с безопасностью относительно исключений.
Небольшое повторение: что происходит когда выполняется выражение типа new T? Вспомним, что выражение с new на  на самом деле делает (опустим здесь формы опреатора new для выделения массива и Placement new, так как сейчас это не имеет значения):

  • оно выделяет память;
  • оно создает объект типа T в выделенной памяти; и
  • если создание объекта завершается исключением, оно освобождает выделенную память.
Таким образом, каждое выражение new состоит из двух вызовов функций: один вызов - это вызов оператора new (глобального или предоставленного типом создаваемого объекта), второй - вызов конструктора.
В случае с примером 2 рассмотрим, что случится, если компилятор решит сгенерировать код, который выполняет эти шаги в следующем порядке:

  1. выделить память для Т1;
  2. создать Т1;
  3. выделить память для Т2;
  4. создать Т2;
  5. вызвать f.
Проблема в следующем: стандарт С++ не гарантирует, что если на шаге 3 или шаге 4 будет выброшено исключение, то объект Т1 будет удален, а память из под него - освобождена. Это классическая утечка памяти, и это не очень хороший подход.
Есть и другая возможная последовательность шагов:

  1. выделить память для Т1;
  2. выделить память для Т2;
  3. создать Т1;
  4. создать Т2;
  5. вызвать f.
Здесь уже не одна, а две проблемы связанные с безопасностью относительно исключений, и они имеют разные последствия.
  • Если исключение будет выброшено на шаге 3, память, выделенная под объект T1 будет автоматически освобождена. Но стандарт не гарантирует, что память, выделенная под объект Т2 тоже будет освобождена. Следовательно, память "утекает".
  • Если исключение будет выброшено на шаге 4, то объект Т1 будет к этому времени уже полностью создан, а поэтому никто не гарантирует нам его удаление и освобождение выделенной под него памяти. Следовательно, объект Т1 "утекает".
"Хмм", - возможно, подумаете вы, - "почему эта лазейка в безопасности относительно исключений вообще существует? Почему стандарт не обязывает компиляторы все делать правильно, когда дело доходит до освобождения памяти?".
Следуя духу С в плане эффективности, стандарт С++ оставляет компилятору некоторую свободу в определении порядка выполнения выражений, потому что это позволяет компилятору выполнять оптимизации, которые в противном случае были бы невозможны. Чтобы позвоить проводить оптимизации, правила порядка выполнения выражений сформулированы таким образом, что не защищают от проблем с безопасностью относительно исключений. Поэтому, если вы хотите писать безопасный относительно исключений код, вам следует знать о подобных возможных проблемах и избегать их. К счастью, вы можете писать писать подобный код и при этом избегать уазанных проблем. Возможно, умный указатель типа unique_ptr мог бы помочь?

3. Продолжая изучать старый код, вы обнаруживаете, что кому-то не нравился код, приведенный в примере 2, и позднее он был изменен на:
// Пример 3
 
// В заголовочном файле:
void f( std::unique_ptr<T1>, std::unique_ptr<T2> );
 
// В месте вызова:
f( std::unique_ptr<T1>{ new T1 }, std::unique_ptr<T2>{ new T2 } );
Какие улучшения привнес новый подход относительно старого (и привнес ли вообще)? Остались ли проблемы с безопасностью относительно исключений? Объясните.

В этом коде делается попытка победить проблему с помощью unique_ptr. Многие люди считают, что умный указатель - это панацея, когда дело доходит до безопасности относительно исключений, некоторый краеугольный камень или амулет, который одним своим присутствием где-либо поблизости оберегает от беспутсв компилятора.
Это не такю Ничего не изменилось. Код в примере 3 по-прежнему не является безопасным относительно исключений и ровно по тем же причинам, что и раньше.
Конкретнее, проблема заключается в том, что ресурсы защищены, когда они уже находятся под управлением unique_ptr, но уже упомянутые неприятности могут произойти еще до того, как любой из конструкторов unique_ptr будет вызван. Это происходит потому, что операции могут по-прежнему выполнятся в неудачном порядке, как и в примере выше, с той лишь разницей, что теперь к ним перд вызовом f присоединяются вызовы конструкторов unique_ptr. Например:
  1. выделить память для Т1;
  2. создать Т1;
  3. выделить память для Т2;
  4. создать Т2;
  5. создать unique_ptr<T1>;
  6. создать unique_ptr<T2>;
  7. вызвать f.
В этом случае возможны те же проблемы, что и раньше, если либо на шаге 3 или на шаге 4 будет выброшено исключение. Аналогично с таким порядком вызовов:
  1. выделить память для Т1;
  2. выделить память для Т2;
  3. создать Т1;
  4. создать Т2;
  5. создать unique_ptr<T1>;
  6. создать unique_ptr<T2>;
  7. вызвать f.
И снова мы видим те же проблемы, если либо на шаге 3 или на шаге 4 будет выброшено исключение.
К счастью, проблема не в самом unique_ptr, а в том, что он используется неправильно.

Представляем make_unique

4. Покажите, как можно реализовать функцию make_unique, которая бы решала проблемы с безопасностью в примере 3, и которую можно было бы вызывать следующим образом:
// Пример 4
 
// В заголовочном файле:
void f( std::unique_ptr<T1>, std::unique_ptr<T2> );
 
// В месте вызова:
f( make_unique<T1>(), make_unique<T2>() );

Основная идея заключается в следующем:
  • мы хотим использовать тот факт, что инструкции из разных функций, вызываемых из одного потока, не чередуются между собой при выполнении. Поэтому мы хотим предоставить функцию, которая бы выполняла выделение памяти под объект, создание объекта, и создание  unique_ptr.
  • Так как функция должна быть в состоянии работать с любыми типами, мы хотим реализовать ее в виде шаблонной функции.
  • Так как вызывающий код должен иметь возможность передавать параметры конструктора объекта в make_unique, мы используем превосходный стиль пересылки параметров в С++11, чтобы передать параметры в выражение new.
  • Так как для shared_ptr уже имеется аналогичная функция make_shared, мы назовем нашу функцию make_unique. Тот факт, что в С++11 нет make_unique частично может быть объяснен недосмотром (он исправлен в С++14). Если у вас нет возможности использовать С++14 - используйте представленную реализацию. 
Сложив все вместе, мы получим следующее:
template<typename T, typename ...Args>
std::unique_ptr<T> make_unique( Args&& ...args )
{
    return std::unique_ptr<T>( new T( std::forward<Args>(args)... ) );
}
Такой подход решает наши проблемы с безопасностью относительно исключений. Больше нет такой возможной последовательности выполнения отдельных выражений, которая бы привела к утечке ресурсов, потому что сейчас у нас есть только две функции, и мы знаем, что одна из них будет полностью выполнена до того, как другая будет вызвана. Рассмотрим следующий порядок выполнения:

  • вызвать make_unique<T1>;
  • вызвать make_unique<T2>;
  • вызвать f.
Если на шаге 1 выбрасывается исключение, никаких утечек ресурсов не происходит, потому что make_unique безопасна относительно исключений. Если же исключение выбрасывается на шаге 2, будет ли временный объект unique_ptr<T1>, созданный на шаге 1, гарантированно уничтожен? Да, будет. Может возникнуть вопрос: "разве это не почти такая же ситуация, как в примере 2 с созданием объекта T2 с помощью оператора new, который не уничтожается автоматически?" Нет, ситуация не такая же, потому что здесь unique_ptr<T1> на самом деле является временным объектом, а удаление временных объектов четко описано в стандарте. А именно в разделе 12.2/3 (начиная с С++98):
Временные объекты уничтожаются на последнем шаге выполнения выражения которое (лексически) включает в себя их создание. Это верно даже если выражение выбрасывает исключение.

Рекомендации:


  • Предпочитайте создавать объекты, которые будут управляться shared_ptr, с помощью make_shared, а объекты, которые будут управляться unique_ptr, с помощью make_unique.
  • Хотя стандарт С++11 не предоставляет make_unique, это по большей части сделано по недосмотру (и исправлено в стандарте С++14). Если вы не можете использовать С++14, используйте реализацию make_unique, представленную выше.
  • Избегайте использования чистых вызовов оператора new, или других незащищенных способов выделения памяти. Вместо это используйте фабрику типа make_unique, которая является оберткой над чистым выделеним памяти и немедленно передает выделенную память в другой объект, который и будет этой памятью управлять. Часто этим объектом будет умный указатель, но это также может быть любой тип владеющего объекта, чей деструктор безопасно освободит ресурс.

Комментариев нет :

Отправить комментарий