четверг, 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 комментариев:

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

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

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

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

    /// no throw after this point

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

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

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

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

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

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

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

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

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

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

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

    ОтветитьУдалить
  8. 2jazzer:

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

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

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

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

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

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

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

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

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

    ОтветитьУдалить
  10. 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 );
    }

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

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

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

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

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

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

    ОтветитьУдалить