четверг, 3 сентября 2009 г.

[comp.prog] О простоте программирования в транзакционном стиле

В заметке, посвященной исключениям, остался без ответа комментарий 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 комментариев:

Dmitry Vyukov комментирует...

Я написал это как:
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
(ну и, конечно, не считая, что один класс - один неуправляемый ресурс)

eao197 комментирует...

Да, это уже хорошее решение. Правда я думаю, что от неудачного init-а следует все-таки ждать освобождение старого ресурса.

В любом случае, для получения устойчивого к исключениям решения нужно проделать больше телодвижений. Ну и думать нужно больше.

jazzer комментирует...

ну, remark уже ответил.
Я только добавлю, что у меня всегда в середине таких функций стоит строчка с комментарием

/// no throw after this point

а перед функцией - доксигеновский комментарий
/// \exc_safety{strong}

Ну и еще замечание по ходу дела - этот подход хорошо описан и разобран в книгах Герба Саттера "Exceptional C++".

eao197 комментирует...

2jazzer: о том, что дорогу переходить нужно в специально отведенных местах тоже много где написано :) Почему-то далеко не все об этом помнят.

Так же и с исключениями. Как писать при условии исключений многие читали. Только как доходит до практики, так начинается детский сад. Наверное, это объективно, т.к. количество деталей, которые приходится держать в памяти увеличивается.

Так что я по-прежнему придерживаюсь мнения, что устойчивый к исключениям код писать сложнее, т.к.:
- его требуется больше;
- нужно больше внимания и, как следствие
- больше вероятность ошибиться.

jazzer комментирует...

ну просто ты говоришь: "Да, это уже хорошее решение", и это звучит, как будто ты не знаешь, что оно самое что ни на есть каноническое.

Насчет того, что писать больше - не соглашусь. Имхо, с кодами ошибок писать не просто больше, а намного больше.

Ну а там где транзакционной гарантии достичь слишком сложно или они вообще недостижима- так для таких случаев есть базовый уровень гарантий, при котором надо объект после неудачи убивать и создавать заново (fail fast, ага).

Dmitry Vyukov комментирует...

Различия между исключения и кодами возвратов разные для низкоуровневого кода управления ресурсами и высокоуровнего кода, который сам никакими ресурсами не управляет.
Плюс сюда же - остаются ли русурсы жить за границами операции.
Плюс сюда же - вместе с исключениями идут лесом констуркторы и операторы классов.

Самый крайний вариант - хардкор манипуляции со строками, допустим надо сделать много конкатенаций, форматирования, выделения подстрок, вырезаний и т.д. При этом функция просто должна вернуть результирующую строку (или исключение/ошибка). Представь как это будет с std::string, используя конструкторы, операторы и исключения. И как это будет в С стиле.

Dmitry Vyukov комментирует...

Кстати, раз уж мы сравниваем. А как бы твой код выглядел с кодами возврата:
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;
}
}
?

eao197 комментирует...

2jazzer:

и это звучит, как будто ты не знаешь, что оно самое что ни на есть каноническое.

Я уже сказал -- я бы предпочел, чтобы неудавшийся init освободил старый ресурс даже в случае неудачи. В каноническом решении remark-а старый ресурс останется захваченным в случае, если новый ресурс не будет захвачен. Далеко не всегда такое поведение оправдано.

Насчет того, что писать больше - не соглашусь. Имхо, с кодами ошибок писать не просто больше, а намного больше.

Так это ясень пень. Более того, с кодами возврата даже написав больше кода есть больше шансов оставить программу в неправильном состоянии.

Я речь виду о том, что разрабатывая код при наличии исключений можно настраиваться на два сценария:
- при возникновении исключения выбрасывать все, что могло пострадать (т.е. при возникновении исключения не доверять экземпляру класса Sample);
- при возникновении исключения все-таки доверять выжившим объектам (тому же самому классу Sample).

Второй вариант сложнее первого. Как при разработке классов типа Sample (обеспечение базовой или даже строгой гарантии), так и при использовании класса Sample (поскольку ты вынужден доверять чужому коду).

jazzer комментирует...

да примерно так же и выглядел бы, только вместо throw был бы return соответствующего кода.
Нам никто не мешает требовать или не требовать транзакционность и при работе с кодами возврата.

Другое дело, что на вызывающей стороне теперь придется все эти коды проверять.

Т.е. затраты на написание транзакционного безопасного класса компенсируются исключительной легкостью и приятностью его использования. В противном случае мы облегчаем жизнь себе, но усложняем жизнь пользователю.

eao197 комментирует...

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 );
}

Miroslav комментирует...

мне вот интересно что эта программа делает на вылет bad_alloc из конструктора строчки :))

Ну и ценность комментария
/// no throw after this point
у меня вызывает большие сомнения.

А так я просто мимо проходил :)

eao197 комментирует...

2Rubanets Myroslav: если речь идет о фрагментах, которые приводились в тексте самого поста, то там вообще нет нормальных и надежных решений.

jazzer комментирует...

насчет ценности комментария - я не знаю другого способа, к сожалению.

Было бы замечательно, если бы можно было объявить блок как nothrow (через атрибуты, скажем), и чтоб компилятор проверял, что я зову только функции, которые тоже nothrow (проверки примерно как const), но такого, увы, нету и не предвидится.
Так что комментарий - это лучшее из возможного - человек, который полезет исправлять код, не сможет мимо него пройти (а если сможет, то не сможет ревьюер)