Исключительная безопасность - когда, как, почему?
Я просто начинающий программист, который, по крайней мере, пытается программировать больше, чем в лучшем случае. Я читал "Исключительный C++" Херба Саттера и трижды проходил главы, посвященные безопасности исключений. Однако, исключая приведенный им пример (стек), я не совсем уверен, когда именно я должен стремиться к исключительной безопасности против скорости, а когда это просто глупо.
Например, мой текущий домашний проект представляет собой двусвязный список. Так как я уже запрограммировал пару из них, я хотел найти время, чтобы углубиться в некоторые более глубокие концепции, такие как ES.
Вот моя популярная функция:
void List::pop_front()
{
if(!head_)
throw std::length_error("Pop front: List is empty.\n");
else
{
ListElem *temp = head_;
head_ = head_->next;
head_->prev = 0;
delete temp;
--size_;
}
}
У меня были некоторые дилеммы с этим.
1) Должен ли я действительно выдавать ошибку, когда список не удается? Не лучше ли мне просто ничего не делать и возвращаться вместо того, чтобы заставлять пользователя списка выполнять операторы try {] catch() {} (которые также медленны).
2) Есть несколько классов ошибок (плюс ListException, который требует мой учитель, мы реализуем в классе). Является ли пользовательский класс ошибок действительно необходимым для такой вещи, и есть ли общее руководство о том, когда использовать определенный класс исключений? (Например, диапазон, длина и граница звучат одинаково)
3) Я знаю, что не должен менять состояние программы, пока не будет выполнен весь код, выдавший исключение. Вот почему я уменьшаю size_ last. Это действительно необходимо в этом простом примере? Я знаю, удалить не могу бросить. Возможно ли, чтобы голова _->prev когда-либо бросала, присваивая 0? (голова - первый узел)
Моя функция push_back:
void List::push_back(const T& data)
{
if(!tail_)
{
tail_ = new ListElem(data, 0, 0);
head_ = tail_;
}
else
{
tail_->next = new ListElem(data, 0, tail_);
tail_ = tail_->next;
}
++size_;
}
1) Я часто слышу, что в программе на C++ может произойти что- либо неудачное. Реально ли проверить, не завершился ли конструктор для ListElem (или tail_ во время new
луг)?
2) Будет ли когда-нибудь необходимо проверить тип данных (в настоящее время простой typedef int T
пока я все не шаблонизирую) чтобы убедиться, что тип жизнеспособен для структуры?
Я понимаю, что это слишком простые примеры, но в настоящее время я просто не понимаю, когда мне следует практиковать хорошие ES, а когда нет.
5 ответов
Должен ли я действительно выдавать ошибку при сбое списка? Не лучше ли мне просто ничего не делать и возвращаться вместо того, чтобы заставлять пользователя списка выполнять операторы try {] catch() {} (которые также медленны).
Абсолютно исключение.
Пользователь должен знать, что произошло, если список был пуст - иначе это будет ад для отладки. Пользователь не обязан использовать операторы try/catch; если исключение является неожиданным (то есть может возникнуть только из-за ошибки программиста), то нет причин пытаться его перехватить. Когда исключение не выполняется, оно переходит в std::terminate, и это очень полезное поведение. Сами по себе операторы try/catch тоже не медленные; сколько стоит фактическое выбрасывание исключения и раскрутка стека. Это примерно ничего не стоит, если исключение не выдается.
Есть несколько классов ошибок (плюс ListException, который требует мой учитель, мы реализуем в классе). Является ли пользовательский класс ошибок действительно необходимым для такой вещи, и есть ли общее руководство о том, когда использовать определенный класс исключений? (Например, диапазон, длина и граница звучат одинаково)
Будьте как можно конкретнее. Использование собственных классов ошибок - лучший способ сделать это. Используйте наследование для группировки связанных исключений (чтобы вызывающие абоненты могли их легче отлавливать).
Я знаю, что не должен изменять состояние программы, пока не будет выполнен весь код, выдавший исключение. Вот почему я уменьшаю size_ last. Это действительно необходимо в этом простом примере? Я знаю, удалить не могу бросить. Возможно ли, чтобы голова _->prev когда-либо бросала, присваивая 0? (голова - первый узел)
Если head_
является нулевым, а затем разыменовывает его (как часть попытки присвоить head_->prev
) неопределенное поведение. Создание исключения является возможным следствием неопределенного поведения, но маловероятным (оно требует, чтобы компилятор старался изо всех сил держать вас за руку, на языке, где подобные вещи считаются абсурдными;)), а не одним о чем мы беспокоимся, потому что неопределенное поведение - это неопределенное поведение - это означает, что ваша программа в любом случае уже ошибается, и нет смысла пытаться сделать так, чтобы это неправильно было более правильным.
Плюс, вы уже явно проверяете, что head_
не равно нулю в любом случае. Так что нет проблем, если вы ничего не делаете с потоками.
Я часто слышу, что что-то может не получиться в программе на C++.
Это немного параноидально.:)
Реально ли проверить, не завершился ли конструктор для ListElem (или tail_ при обновлении)?
Если new
не удается, то экземпляр std::bad_alloc
брошен Создание исключения - это именно то, что вы хотите, чтобы произошло здесь, поэтому вы не хотите или не должны ничего делать - просто дайте ему распространиться. Повторное описание ошибки как своего рода исключения из списка на самом деле не добавляет полезной информации и может просто затенить вещи дальше.
Если конструктор ListElem дает сбой, он должен завершиться сбоем, выдав исключение, и это примерно 999 к 1, что вы должны просто пропустить это тоже.
Ключевым моментом здесь является то, что всякий раз, когда здесь выдается исключение, не нужно выполнять очистку, потому что вы еще не изменили список и созданный / новый объект Officially Never Existed(TM). Просто убедитесь, что его конструктор безопасен для исключений, и все будет в порядке. Если new
вызов не может выделить память, конструктор даже не вызывается.
Время, когда вам нужно беспокоиться, это когда вы делаете более одного распределения в одном и том же месте. В этом случае вы должны убедиться, что при втором выделении происходит сбой, вы перехватываете исключение (независимо от того, что это такое), очищаете первое выделение и повторно выбрасываете. В противном случае вы пропустите первое выделение.
Нужно ли будет когда-либо проверять тип данных (в настоящее время это просто typedef int T, пока я все не буду шаблонизировать), чтобы убедиться, что тип является жизнеспособным для структуры?
Типы проверяются во время компиляции. Вы не можете реально ничего с ними поделать во время выполнения, и вам никогда не понадобится. (Если вам не нужна вся эта проверка типов, то почему вы используете язык, который заставляет вас печатать имена типов исключительно везде?:))
Я не совсем уверен, когда именно я должен стремиться к исключительной безопасности против скорости
Вы всегда должны стремиться к исключительной безопасности. Обратите внимание, что "безопасность исключений" не означает "выбросить исключение, если что-то пойдет не так". Это означает "предоставление одной из трех исключительных гарантий: слабая, сильная или нет". Бросать исключения не обязательно. Исключительная безопасность необходима для того, чтобы вызывающие стороны вашего кода были удовлетворены тем, что их код может корректно работать при возникновении ошибок.
Вы увидите очень разные стили от разных программистов / команд C++ относительно исключений. Некоторые часто их используют, другие едва ли вообще (или даже не совсем), хотя я думаю, что сейчас это довольно редко. Вероятно, Google является наиболее (не) известным примером. Если вам интересно, посмотрите их руководство по стилю C++. Встраиваемые устройства и внутренности игр, вероятно, являются следующими наиболее вероятными местами, где можно найти примеры людей, избегающих исключений полностью в C++). Стандартная библиотека iostreams позволяет вам установить флаг для потоков, должны ли они генерировать исключения при возникновении ошибок ввода-вывода. По умолчанию это не так, что является сюрпризом для программистов практически из любого другого языка, в котором существуют исключения.
Должен ли я действительно выдавать ошибку при сбое списка?
Это не провал "списка", это специально pop_front
вызывается, когда список пуст, что не удается. Вы не можете обобщать все операции над классом, чтобы они всегда выдавали исключения при сбое, вы должны учитывать конкретные случаи. В этом случае у вас есть как минимум пять разумных вариантов:
- вернуть значение, чтобы указать, было ли что-нибудь всплыло Абонент может делать с ними все что угодно или игнорировать это.
- документ, что это неопределенное поведение для вызова
pop_front
когда список пуст, тогда игнорируйте возможность в коде дляpop_front
, Нужно убирать пустой стандартный контейнер, и некоторые реализации стандартной библиотеки не содержат проверочного кода, особенно в сборках релизов. - документируйте, что это неопределенное поведение, но все равно выполните проверку и либо отмените программу, либо сгенерируйте исключение. Вы могли бы сделать проверку только в отладочных сборках (вот что
assert
для), и в этом случае у вас также может быть возможность запуска точки останова отладчика. - документ, который, если список пуст, вызов не имеет никакого эффекта.
- документ, который выдает исключение, если список пуст.
Все это, за исключением последнего, означает, что ваша функция может предложить гарантию "nothrow". Какой из них вы выберете, зависит от того, как вы хотите, чтобы ваш API выглядел, и какую помощь вы хотите дать своим звонящим в поиске их ошибок. Обратите внимание, что исключение не заставляет вашего непосредственного абонента его поймать. Исключения следует перехватывать только в коде, который может восстановиться после ошибки (или, возможно, в самой верхней части программы).
Лично я склоняюсь к тому, чтобы не выбрасывать исключения для пользовательских ошибок, и я также склоняюсь к тому, чтобы сказать, что появление пустого списка - это ошибка пользователя. Это не означает, что в режиме отладки бесполезно иметь все виды проверок, просто я обычно не определяю API, чтобы гарантировать, что такие проверки будут выполняться во всех режимах.
Действительно ли для такой вещи нужен собственный класс ошибок?
Нет, это не обязательно, потому что это ошибка, которую можно избежать. Вызывающий всегда может убедиться, что он не будет выдан, проверив, что список не пуст перед вызовом. pop_front
, std::logic_error
было бы совершенно разумным исключением, чтобы бросить. Основная причина использования специального класса исключений состоит в том, что вызывающие абоненты могут перехватить именно это исключение: вы сами решаете, должны ли вызывающие абоненты делать это в конкретном случае.
Возможно ли, чтобы голова _->prev когда-либо бросала, присваивая 0?
Нет, если ваша программа как-то не спровоцировала неопределенное поведение. Так что да, вы можете уменьшить размер до этого, и вы можете уменьшить его до delete
при условии, что вы уверены, что деструктор ListElem не может бросить. И при написании любого деструктора, вы должны убедиться, что он не выбрасывает.
Я часто слышу, что что-то может не получиться в программе на C++. Реально ли проверить, не завершился ли конструктор для ListElem (или tail_ при обновлении)?
Это не правда, что все может потерпеть неудачу. В идеале функции должны документировать, какую гарантию исключения они предлагают, что, в свою очередь, говорит вам, могут ли они выдавать или нет. Если они действительно хорошо документированы, они перечислят все, что они могут бросить, и при каких обстоятельствах они бросают это.
Вы не должны проверять, new
не удается, вы должны разрешить исключение из new
, если таковые имеются, для распространения от вашей функции до вашего абонента. Тогда вы можете просто документально, что push_front
может бросить std::bad_alloc
чтобы указать на нехватку памяти, и, возможно, также, что он может выбросить все, что выбрасывается конструктором копирования T
(ничего, в случае int
). Возможно, вам не придется документировать это отдельно для каждой функции - иногда достаточно общего примечания, охватывающего несколько функций. Никого не должно удивлять, что если push_front
может бросить, то одна из вещей, которые он может бросить, это bad_alloc
, Для пользователей контейнера-шаблона также не должно вызывать удивления то, что если содержащиеся элементы выдают исключения, то эти исключения могут распространяться.
Нужно ли будет когда-либо проверять тип данных (в настоящее время это просто typedef int T, пока я все не буду шаблонизировать), чтобы убедиться, что тип является жизнеспособным для структуры?
Вы, вероятно, можете написать свою структуру так, что все, что требуется от T, это то, что она может быть копируемой и назначаемой. Для этого не нужно добавлять специальные тесты - если кто-то попытается создать экземпляр вашего шаблона с типом, который не поддерживает операции, которые вы над ним выполняете, он получит ошибку компиляции. Вы должны документировать требования, хотя.
Это длинный вопрос. Я возьму все вопросы, которые пронумерованы 1)
,
1) Должен ли я действительно выдавать ошибку, когда список не удается? Не лучше ли мне просто ничего не делать и возвращаться вместо того, чтобы заставлять пользователя списка выполнять операторы try {] catch() {} (которые также медленны).
Нет. Если ваш пользователь заботится о производительности, он будет проверять длину, прежде чем пытаться выскочить, вместо того, чтобы выдавать и ловить исключение. Исключением является информирование вашего пользователя, если он сначала забудет проверить длину, и в этот момент вы действительно хотите, чтобы приложение взорвалось им в лицо. Если вы просто ничего не делаете, это может вызвать тонкие проблемы, которые появятся только позже, и это усложнит отладку.
1) Я часто слышу, что в программе на C++ может произойти что-либо неудачное. Реально ли проверить, не завершился ли конструктор для ListElem (или tail_ при обновлении)?
Например, конструктор может потерпеть неудачу, если вам не хватит памяти, но в этом случае он должен выдать исключение, а не вернуть ноль. Так что вам не нужно явно проверять наличие сбоя конструктора. Смотрите этот вопрос для более подробной информации:
Я часто слышу, что что-то может не получиться в программе на C++. Реально ли проверить, не завершился ли конструктор для ListElem (или tail_ при обновлении)?
Да, это реально. В противном случае, если вашей программе не хватает памяти и происходит сбой выделения (или конструктор завершается неудачей по какой-то другой внутренней причине), у вас будут проблемы позже.
По сути, вы должны сигнализировать об ошибке ЛЮБОЕ время, когда код не может полностью выполнить то, что его API заявляет, что будет делать.
Разница лишь в том, как вы сигнализируете об ошибке - через возвращаемое значение или через исключение. Если существуют соображения производительности, возвращаемые значения могут быть лучше, чем исключения. Но оба подхода требуют специального кода перехвата ошибок в вызывающей стороне.
Для вашего первого набора вопросов:
- Да, вы должны бросить, по всем причинам в ответе @ Марк. (+1 к нему)
- В этом нет необходимости, но это может значительно облегчить жизнь вашим абонентам. Одним из преимуществ обработки исключений является то, что он локализует код для обработки определенного класса ошибок вместе в одном месте. Выбрасывая определенный тип исключения, вы позволяете своим вызывающим абонентам специально перехватывать эту ошибку, или быть более общим о ней, перехватывая суперкласс конкретного выданного вами исключения.
- Все утверждения в вашем
else
предоставить гарантию nothrow.
Для вашего второго сета:
Нет, это нереально проверить. Вы не представляете, что может создать базовый конструктор. Это может быть ожидаемый элемент (т.е.
std::bad_alloc
) или это может быть что-то странное (т.е.int
), и поэтому единственный способ справиться с этим - поместить его вcatch(...)
что зло:)С другой стороны, ваш существующий метод уже безопасен для исключений, если фиктивный конечный узел создан внутри
if
Блок будет уничтожен деструктором вашего связанного списка. (т.е. все послеnew
s обеспечивает nothrow)Просто возьмите любую операцию на
T
может бросить, кроме деструктора.