В заметке, посвященной исключениям, остался без ответа комментарий jazzer-а:
Насчет существенно возрастающей трудоемкости транзакционности - она сильно преувеличена. Я уже несколько лет пишу в таком стиле, и трудоемким оно было всего один раз - когда я разрабатывал действительно сложный класс с кучей контейнеров внутри. Во всех же остальных случаях все было реально просто.
Мысль, в общем, правильная. Но дьявол, он же в деталях. И вот сегодня подвернулся хороший пример, демонстрирующий это.
Итак, есть некий класс, который захватывает ресурс и производит некоторые операции с помощью этого ресурса:
class Sample {
public :
void init( const std::string & resource );
void perform_action( const std::string & action_data );
...
};
Используется это все достаточно просто:
Sample action_performer;
...
while( true ) {
const std::string resource_name = next_resource();
action_performer.init( resource_name );
const std::string data = next_data();
action_performer.perform_action( data );
}
...
Дальше выяснилось, что в процессе работы resource_name очень редко меняются. И можно сэкономить время за счет операций освобождения/захвата ресурсов в методе init. Поэтому, было решено в классе Sample реализовать сохранение имени ресурса, чтобы выполнять переинициализацию только при изменении этого имени. После чего код метода Sample::init() быстренько принял вид:
void Sample::init(
const std::string & resource_name )
{
if( m_current_resource_name != resource_name )
{
m_current_resource_name = resource_name;
free_resource( m_resource );
m_resource = alloc_resource();
if( !m_resource )
throw ...;
}
}
Внимательный читатель, наверное, уже понял, в чем здесь проблема. Этот код будет нормально работать только в случае, если после возникновения исключения экземпляр Sample будет разрушен. А иначе может произойти следующее:
- где-то в программе будет создан объект Sample;
- где-то для него будет вызван метод init в который будет передано, скажем, имя "A". На данный момент это имя не правильное и будет выброшено исключение;
- исключение будет где-то поймано, будут предприняты какие-то действия по исправлению ситуации (например, ресурс с именем "A" будет создан);
- где-то опять будет вызван метод init для того же самого объекта Sample с тем же самым именем "A";
- но этот метод ничего не сделает, т.к. m_current_resource_name будет равно resource_name!
Казалось бы, это детская ошибка. Но ведь программирование как раз и переполнено ошибками, в том числе и детскими. И в этом одна из самых больших сложностей нашего занятия.
Кстати, исправление этой ошибки не так тривиально, как могло бы показаться. Самый первый вариант, который приходит в голову:
void Sample::init(
const std::string & resource_name )
{
if( m_current_resource_name != resource_name )
{
free_resource( m_resource );
m_resource = alloc_resource();
if( !m_resource )
throw ...;
m_current_resource_name = resource_name;
}
}
так же ошибочен. И чтобы написать более-менее корректный вариант init, нужно что-то вроде:
void Sample::init(
const std::string & resource_name )
{
if( m_current_resource_name != resource_name )
{
m_current_resource_name.clear();
free_resource( m_resource );
m_resource = alloc_resource();
if( !m_resource )
throw ...;
m_current_resource_name = resource_name;
}
}
Кстати говоря, в этой версии так же есть проблемы, но их обнаружение я оставляю в качестве упраждения читателям ;)
Так что повторюсь еще раз: программирование с исключениями и расчетом на восстановление после исключения требует повышенного внимания от программиста. Даже к мелочам. Или лучше сказать -- особенно к мелочам.
PS. Да, класс Sample мог бы быть спроектирован по другому. Многое из того, с чем мы сталкиваемся могло бы быть сделано гораздо лучше. Но приходится иметь дело с тем, что есть.
13 комментариев:
Я написал это как:
void Sample::init(
const std::string & resource_name )
{
if( m_current_resource_name != resource_name )
{
std::string tmp_name (resource_name);
resource_t tmp (alloc_resource(resource_name));
if (!tmp)
throw ...;
free_resource( m_resource );
m_resource.swap(tmp);
m_current_resource_name.swap(tmp_name);
}
}
Общее правило, что вначале надо создавать, потом разрушать. Вот хороший пример на этот счёт:
http://www.rsdn.ru/forum/cpp/3520502.1.aspx
(ну и, конечно, не считая, что один класс - один неуправляемый ресурс)
Да, это уже хорошее решение. Правда я думаю, что от неудачного init-а следует все-таки ждать освобождение старого ресурса.
В любом случае, для получения устойчивого к исключениям решения нужно проделать больше телодвижений. Ну и думать нужно больше.
ну, remark уже ответил.
Я только добавлю, что у меня всегда в середине таких функций стоит строчка с комментарием
/// no throw after this point
а перед функцией - доксигеновский комментарий
/// \exc_safety{strong}
Ну и еще замечание по ходу дела - этот подход хорошо описан и разобран в книгах Герба Саттера "Exceptional C++".
2jazzer: о том, что дорогу переходить нужно в специально отведенных местах тоже много где написано :) Почему-то далеко не все об этом помнят.
Так же и с исключениями. Как писать при условии исключений многие читали. Только как доходит до практики, так начинается детский сад. Наверное, это объективно, т.к. количество деталей, которые приходится держать в памяти увеличивается.
Так что я по-прежнему придерживаюсь мнения, что устойчивый к исключениям код писать сложнее, т.к.:
- его требуется больше;
- нужно больше внимания и, как следствие
- больше вероятность ошибиться.
ну просто ты говоришь: "Да, это уже хорошее решение", и это звучит, как будто ты не знаешь, что оно самое что ни на есть каноническое.
Насчет того, что писать больше - не соглашусь. Имхо, с кодами ошибок писать не просто больше, а намного больше.
Ну а там где транзакционной гарантии достичь слишком сложно или они вообще недостижима- так для таких случаев есть базовый уровень гарантий, при котором надо объект после неудачи убивать и создавать заново (fail fast, ага).
Различия между исключения и кодами возвратов разные для низкоуровневого кода управления ресурсами и высокоуровнего кода, который сам никакими ресурсами не управляет.
Плюс сюда же - остаются ли русурсы жить за границами операции.
Плюс сюда же - вместе с исключениями идут лесом констуркторы и операторы классов.
Самый крайний вариант - хардкор манипуляции со строками, допустим надо сделать много конкатенаций, форматирования, выделения подстрок, вырезаний и т.д. При этом функция просто должна вернуть результирующую строку (или исключение/ошибка). Представь как это будет с std::string, используя конструкторы, операторы и исключения. И как это будет в С стиле.
Кстати, раз уж мы сравниваем. А как бы твой код выглядел с кодами возврата:
void Sample::init(
const std::string & resource_name )
{
if( m_current_resource_name != resource_name )
{
m_current_resource_name.clear();
free_resource( m_resource );
m_resource = alloc_resource();
if( !m_resource )
throw ...;
m_current_resource_name = resource_name;
}
}
?
2jazzer:
и это звучит, как будто ты не знаешь, что оно самое что ни на есть каноническое.
Я уже сказал -- я бы предпочел, чтобы неудавшийся init освободил старый ресурс даже в случае неудачи. В каноническом решении remark-а старый ресурс останется захваченным в случае, если новый ресурс не будет захвачен. Далеко не всегда такое поведение оправдано.
Насчет того, что писать больше - не соглашусь. Имхо, с кодами ошибок писать не просто больше, а намного больше.
Так это ясень пень. Более того, с кодами возврата даже написав больше кода есть больше шансов оставить программу в неправильном состоянии.
Я речь виду о том, что разрабатывая код при наличии исключений можно настраиваться на два сценария:
- при возникновении исключения выбрасывать все, что могло пострадать (т.е. при возникновении исключения не доверять экземпляру класса Sample);
- при возникновении исключения все-таки доверять выжившим объектам (тому же самому классу Sample).
Второй вариант сложнее первого. Как при разработке классов типа Sample (обеспечение базовой или даже строгой гарантии), так и при использовании класса Sample (поскольку ты вынужден доверять чужому коду).
да примерно так же и выглядел бы, только вместо throw был бы return соответствующего кода.
Нам никто не мешает требовать или не требовать транзакционность и при работе с кодами возврата.
Другое дело, что на вызывающей стороне теперь придется все эти коды проверять.
Т.е. затраты на написание транзакционного безопасного класса компенсируются исключительной легкостью и приятностью его использования. В противном случае мы облегчаем жизнь себе, но усложняем жизнь пользователю.
2Dmitriy V'jukov:
Варианта с кодами возврата я не предлагаю :)
Мой вариант выглядел бы как-то так:
if( m_current_resource_name != resource_name ) {
m_current_resource_name.clear();
free_resource( m_resource );
m_resource = 0;
std::string current_name( resource_name );
m_resource = alloc_resource( resource_name );
if( !m_resource )
throw ...;
m_current_resource_name.swap( resource_name );
}
мне вот интересно что эта программа делает на вылет bad_alloc из конструктора строчки :))
Ну и ценность комментария
/// no throw after this point
у меня вызывает большие сомнения.
А так я просто мимо проходил :)
2Rubanets Myroslav: если речь идет о фрагментах, которые приводились в тексте самого поста, то там вообще нет нормальных и надежных решений.
насчет ценности комментария - я не знаю другого способа, к сожалению.
Было бы замечательно, если бы можно было объявить блок как nothrow (через атрибуты, скажем), и чтоб компилятор проверял, что я зову только функции, которые тоже nothrow (проверки примерно как const), но такого, увы, нету и не предвидится.
Так что комментарий - это лучшее из возможного - человек, который полезет исправлять код, не сможет мимо него пройти (а если сможет, то не сможет ревьюер)
Отправить комментарий