суббота, 5 сентября 2009 г.

[comp.prog.cpp] Спецификатор nothow в C++ -- имеет ли это смысл?

Из обсуждения заметки о простоте транзакционного программирования:

jazzer:
Я только добавлю, что у меня всегда в середине таких функций стоит строчка с комментарием
/// no throw after this point

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

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

Это обсуждение напомнило мне, что мы с jazzer-ом когда-то на RSDN уже затрагивали тему спецификатора nothrow. Насколько я знаю, он существует сейчас только в одном языке -- D. Да и то, изначально он был просто зарезервированным ключевым словом и никак не обрабатывался компилятором (может сейчас что-то и изменилось, не знаю).

На первый взгляд, такая штука, как nothrow могла бы облегчить программистам жизнь при написании устойчивого к исключениям кода. Например, с ее помощью можно было бы помечать те фрагменты, в которых программист надеется не порождать исключения. Например:

// Реализация оператора копирования для собственного класса.
my_class_t & my_class_t::operator=( const my_class_t & other )
  {
    my_class_t tmp( other );
    // Вот здесь мы гарантируем, что исключений быть не должно.
    nothrow {
      tmp.swap( *this );
    }
  } 

Смысл в том, чтобы компилятор бил по рукам, если в секции nothrow программист пытается выполнять какой-то код, который может порождать исключения. Например, вызывать new. Т.е. программист может использовать внутри nothrow только очень ограниченный набор встроенных инструкций и функции, помеченные спецификатором nothrow. Скажем, функции swap:

// Я уверен, что моя реализация swap не бросает исключений.
void my_class_t::swap( my_class_t & o ) nothrow {
  std::swap( m_a, o.m_a );
  ...
  std::swap( m_b, o.m_b );
} 

На первый взгляд, довольно заманчивая идея. Однако, как водится, дьявол кроется в деталях. (Disclaimer. Поскольку я не являюсь проффесиональным разрабочиком языков программирования и мало что в этом понимаю, то далее будут идти рассуждения о том, в чем я не разбираюсь. В общем, обычная история.)

Во-первых, сразу возникает вопрос: "А можно ли помечать спецификатором nothrow шаблонный код?". Взять, к примеру, тот же std::swap. Ведь он работает по очень простой схеме -- несколько копирований через временную переменную. Операторы копирования могут порождать исключения. Значит, обобщенная (т.е. исходная, шаблонная) версия std::swap не может быть помечена как nothrow. А вот ее конкретные версии-специализации для примитивных типов -- могут. Т.о. получается, что в случае с шаблонами обобщенная версия может не иметь спецификатора nothrow, а специализации -- могут. И наоборот. Получается, что для шаблонов какое-то решение существует. Может некрасивое, но все же.

Во-вторых, существует вопрос о том, насколько nothrow соотносится с системно-зависимыми вещами. Допустим, у нас есть некоторый код:

// Некоторый тип, который мы используем.
struct persistent_t : public ... {
  // Значение этого поля нам потребуется.
  int m_value; 
  ...
};

// Наша операция swap, которая не бросает исключений.
void my_class_t::swap( my_class_t & o ) nothrow {
  ...
}

// Какой-то код, в котором мы хотим получить от компилятора
// контроль за гарантиями безопасности исключений.
void resource_user_t::lock_resource(...) {
  // Вначале захватываем все нужные нам ресурсы.
  persistent_t * p = ...;
  my_class_t tmp_resource = ...;

  // А теперь остается сохранить все это у себя.
  // Но процесс сохранения не должен бросать исключений.
  nothrow {
    std::swap( m_value, p->m_value );
    m_resource.swap( tmp_resource );
  }
} 

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

В первой строке мы делаем swap для двух целочисленных переменных. Откуда здесь взяться проблемам? От хитрых баз данных :) Если мне не изменять память, первые объектно-ориентированные БД, типа Versant и Objectivity, работали через механизм захвата страниц виртуальной памяти. Т.е. когда вы создаете объект в БД, вы получаете на него указатель в своей виртуальной памяти. Но самого объекта в памяти нет. При первом обращении по этому указателю происходит системное исключение, которое перехватывается ран-таймом БД. Он определяет, что нужно загрузить и загружает объект в память. В нашем примере указатель p может как раз указывать на объект в подобной объектной БД. И когда мы попытаемся выполнить swap БД попробует поднять его содержимое из БД. Но это не обязательно увенчается успехом. Результатом чего будет какое-то исключение. В блоке, помеченном, как nothrow.

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

Конечно, в этих случаях будут происходить события, выходящие за юрисдикцию C++ (равно как и в случае с деления на ноль). Но ведь в некоторых ОС восстановление после подобных системных исключений возможно (если мне не обманывает мой склероз, в Windows это делается посредством механизма SEH -- Structured Exception Handling). Т.е., если нам сильно не повезет, то у нас в программе может выжить объект, работа которого была прервана внутри секции nothrow.

Такие вот дела. Наверное, если бы в C++ был nothrow, который не работал бы в экзотических случаях, но зато бил бы разработчиков по рукам в остальных ситуациях, это было бы хорошо. Но, как сказал jazzer, в обозримом будущем в C++ его все равно не планируется. Так что будем посмотреть, что получится у разработчиков D. И получится ли вообще ;)

21 комментарий:

  1. В D2 последних версий notrhow работает, притом как и const вирусный, фиг вызовешь из notrhow функции функцию не помеченную notrhow. В шаблонах тоже все работает. Проверка происходит при инстанцировании.

    ОтветитьУдалить
  2. Ну как ты правильно заметил, примеры с БД выходят за юрисдикцию языка.
    Т.е. в С++ присваивание в целочисленную переменную не может сломать в принципе (если эта переменная не получена путем реинтерпретации указателя, что сильно зависит от реализации и сам язык тут никаких гарантий не дает).

    Насчет шаблонного кода - при инстанцировании же компилятор видит, что зовется и какие атрибуты у того, что зовется, и может их "пропагировать" (примерно как сейчас делается со спецификациями исключений для функций, генерящихся по умолчанию). То же самое и с обычными функциями, кстати - если ты объявил функцию как nothrow, то компилятор даст тебе по рукам за попытку вызова внутри нее некошерной функции (т.е. если ты объявил функцию как nothrow, то к ее телу применяются обычные проверки, как для блока nothrow).
    Ну и явной спецификации никто не отменял.
    В крайнем случае - компилятор можно будет успокоить явным блоком try-catch.

    ОтветитьУдалить
  3. 2Rustam: ух ты! А я и не заметил по changelog-ам, когда в D2 обработка nothrow появилась. То, что он вирусный -- это правильно.

    Вообще появляется впечатление, что D2 готовят к релизу. В последней версии очень большое количество багфиксов.

    ОтветитьУдалить
  4. 2jazzer: ну так по сравнению с другими языками у C++ есть два преимущества: выскокая скорость работы и простая интеграция с системными вещами. Если при помощи nothrow второе преимущество будет сильно уменьшено, то это не есть хорошо, имхо.

    Ты больше следишь за тем, что обсуждают вокруг нового стандарта. Всплывали ли там вообще идеи nothrow?

    ОтветитьУдалить
  5. ну в системных вещах нету сonst, например, и ничего, выкручиваются :)

    Я общался на эту тему с Саттером, он сказал, что идея хорошая, но ничего такого не обсуждалось.

    ОтветитьУдалить
  6. Не, ну const (особенно в C++) и nothrow -- это разного поля ягоды. По сути, const тебе ничего не обещает, это всего лишь read-only view на данные. Тогда как nothrow-ом мы пытаемся что-то гарантировать.

    Вообще, жалко, что не обсуждалось. Может после выхода D2 и некоторого опыта использования nothrow там появится новая информация для размышления.

    ОтветитьУдалить
  7. Да мне тоже показалось что D хотят релизить :)

    ОтветитьУдалить
  8. А как с виртуальными функциями будет работать? Ловить в ран-тайм и вызывать terminate()?

    ОтветитьУдалить
  9. 2Dmitriy V'jukov: а в чем тут проблема?

    Виртуальная функция должна быть объявлена как nothrow. И в производных классах не должно быть возможности от этого избавиться.

    А вот на счет того, можно ли в C++ доверять try..catch внутри nothrow-функций -- это отдельный вопрос. По хорошему, можно доверять только catch(...). Но, насколько я знаю, в современных C++компиляторах есть проблемы с определением того, по всем ли путям исполнения выполняется return (в частности, MS C++ не всегда дает ошибки). Поэтому контроль того, не выпускает ли nothrow-функция наружу исключений, может быть так же не прост. И содержать серьезные баги в реализациях C++компиляторов.

    ОтветитьУдалить
  10. А понятно, тогда это будет что-то типа как сейчас предлагается для интеграции транзакций в язык:
    http://software.intel.com/en-us/blogs/2009/08/06/new-draft-specification-of-transactional-language-constructs-for-c/
    Т.е. везде аттрибуты и проверки компилятором.
    Там будут засады с тем, что например, если функция принимает указатель на функцию, то его тоже надо помечать аттрибутами.
    Ну и там например одна функция может что-то кидать, а другая что-то ловить. Т.е. надо будет отслеживать на уровне конкретных типов исключений.

    ОтветитьУдалить
  11. 2Dmitriy V'jukov

    Да, что-то типа (хотя предложения Intel-а я только по диагонали просмотрел).

    С прототипами функций и указателей на функции не вижу сложностей. Сейчас же на уровне нестандартных расширений языка различаются указатели на _cdecl или _stdcall функции. Будет точно так же.

    А вот учет конкретных типов бросаемых исключений, имхо, плохая идея. В Java она ведет к распуханию кода и все равно там есть RuntimeException.

    ОтветитьУдалить
  12. тоже по диагонали просмотрел, не увидел там вирусности и проверок а ля const - пропустил или действительно нету?

    ОтветитьУдалить
  13. А как быть без проверки конкретных типов исключений?
    Можно совсем запретить бросание исключений, но тогда придётся ограничиться совсем примитивными операциями. Применимость будет существенно скомпрометирована.
    Я имею в виду следующую ситуацию. Допустим nothrow блок вызывает функцию, которая вызывает вторую функцию. И вторая функция выделяет память, т.е. может бросить bad_alloc. Но первая функцию в свою очередь ловит bad_alloc, и преобразует в возвращаемое значение, говорящее об ошибке. Вроде как тут всё законно. Но что бы проверить это компилятору придётся контролировать на уровне отдельных исключений.
    Хммм... в итоге приходим просто к статической проверке исключений как в Java. Там throw() фактически есть искомый nothrow (RuntimeException пока не рассматриваем, считаем, что Java программа его не переживает).

    ОтветитьУдалить
  14. А как без вирусности? Функция, которая может выполняться внутри транзакции, может вызывать только функции, которые могут выполняться внутри транзакции.
    АФАИК Там правда есть такая штука, что компилятор может анализировать доступные функции и сам проверять безопасная функция или нет.

    ОтветитьУдалить
  15. 2Dmitry V'jukov: мне кажется, что в функциях и блоках nothrow нужно совсем запрещать выпускать исключения. Работаешь с чем-то, что может породить исключение -- будь добр написать try+catch(...).

    ОтветитьУдалить
  16. вирусность обязательна, иначе все это не имеет смысла.
    Отсечение по конкретным типам исключений в стиле "эти можно бросать, а эти - нельзя" - плохая идея, в ней те же самые проблемы, что и у нынешних спецификаций исключений.
    По большому счету, от функции нужно знать единственное - либо все хорошо либо что-то сломалось. Поломка сигнализируется каким-то исключением, не важно каким. И ограничивать типы исключений, которые может выбросить функция - бессмысленная идея, потому что ничего не дает, кроме головной боли при попытке расширения функциональности.

    ОтветитьУдалить
  17. 2Евгений Охотников & jazzer
    Т.е. по new я ничего не смогу выделить внутри nothrow блока и не смогу вызвать никакую функцию, которая содержит new?
    А вам не кажется, что это сильно сужает применимость? Т.е. я смогу реализовать swap для контейнера как nothrow, т.к. там будет только несколько манипуляций с указателями; а если что-то более сложное, то придётся отказываться от nothrow и делать по-старинке.

    ОтветитьУдалить
  18. 2Dmitriy V'jukov: грубо говоря, да, нельзя просто так вызвать new. А уж если очень хочется, то обрамляй ее в try+catch(...).

    Мне не кажется. Т.к. nothrow предназначен для случаев, когда нужно атомарно выполнить несколько простых действий (будь то какая-то простая арифметика и/или несколько swap-ов). Если пользователь в nothrow блоке пытается сделать что-то потенциально опасное, то компилятор об этом предупредит.

    ОтветитьУдалить
  19. Вот, кстати, если кто ещё не видел:
    http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2009/n2855.html#noexcept

    ОтветитьУдалить
  20. ага
    только вот в нынешнем новом стандарте этого не видать... :(
    Надеюсь, компиляторы как расширение сделают самостоятельно без стандарта...

    ОтветитьУдалить
  21. Но мне вот что не понятно по поводу noexcept: его же в текущем варианте C++0x нет, а проблема с бросающими исключениями move-конструкторами в том же std::pair имеет место быть. Получается, что либо в стандартной библиотеке C++0x придется отказываться от использования move-операций, либо разработчикам языка придется что-то вокруг noexcept придумывать.

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