среда, 4 января 2023 г.

[prog.c++] Всегда ли выброс исключения из noexcept функции является ошибкой?

На написание этой заметки подтолкнула статья "Топ-10 ошибок в C++ проектах за 2022 год", а если точнее, то ошибка, которая там поставлена на шестое место. В двух словах: в операторе перемещения некого класса вызывается метод setName, в котором может быть брошено исключение. При этом сам оператор перемещения помечен как noexcept.

На мой взгляд, ситуация здесь не так проста, как может показаться.

И дело в том, что некоторые C++ программисты (включая меня самого в прошлом) смотрят на маркер noexcept (не бросает) как на маркер nofail (не сбоит, всегда OK). Т.е., если у нас есть noexcept-функция, то она не может завершиться с ошибкой.

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

Однако, noexcept означает nofail далеко не всегда.

Зато noexcept всегда означает norecover (если сбоит то без шансов на продолжение). Т.е., если в noexcept функции что-то пошло не так, то восстановиться до какого-то вменяемого состояния у нас уже нет возможности.

К сожалению, в C++ nofail от norecover не отличимы. Мы можем написать только noexcept, но не можем дать дополнительной информации: гарантируем ли мы отсутствие ошибок или же лишь подтверждаем, что при возникновении ошибки восстановление невозможно и единственный разумный путь -- это std::terminate.

И если рассматривать noexcept именно как norecover, то вызовы бросающих исключение функций в noexcept-функциях становятся вполне себе оправданными. Какой смысл оборачивать бросающие функции в try..catch чтобы в catch самому вызвать std::abort? Сам по себе noexcept сделает это не хуже нас.

В общем, я постепенно пришел к тому, чтобы рассматривать noexcept-функции прежде всего как norecover-функции. И только в некоторых, чаще всего в тривиальных случаях, как nofail-функции.

А посему теперь спокойно отношусь и к тому, что в noexcept-функция могут запросто вызываться бросающие исключения функции без оборачивания их в try..catch. И не считаю такое поведение ошибкой.

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

Полезно такое иметь потому, что:

  • человеку свойственно ошибаться и я могу думать, что вызванная мной функция f() является noexcept, а на самом деле нет;
  • все течет, все изменяется. Изначально f() могла быть noexcept, но со временем ее могли модифицировать и убрать маркер noexcept. Но я, как пользователь f(), об этом даже не узнаю.

Не думаю, однако, что когда-нибудь в C++ noexcept-блок появится.

Да и вообще, глядя на то, что подобавляли в C++20 (например, модули, в которые без поллитры не въедешь) и в C++23 (например, deducing this), все больше и больше прихожу к заключению, что я недостаточно умен, чтобы освоить настолько сложный инструмент :(

PS. Возвращаясь к упомянутой выше статье. Думаю, что предложенные автором статьи способы исправления обнаруженной анализатором "ошибки", мягко говоря, неуместны. Там нужно было копать совсем в другую сторону (а то и в несколько разных сторон).

4 комментария:

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

Ну вот не соглашусь насчёт deducing this. Мега-полезная фича. Неужели ты сам не замучился по нескольку раз одно и то же писать в классах, типа геттеры?

Насчёт блоков - я их хотел ещё в С++98... Но техническая возможность есть - если использовать оператор noexcept для проверки выражения. Но это дублировать код фактически. Надо посмотреть, можно ли плагинчик в clang приспособить для автоматизации этого дела.

А ещё для кода, вызывающего функцию noexcept, не должно генерироваться дополнительной обвязки для обработки исключений. Надо будет проверить на годболте

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

@jazzer

Я не говорил, что deducing this бесполезна. Говорил о том, что она сложная. И вообще C++ становится уж слишком сложным для меня :(

PS. Из-за спамеров пришлось включить премодерацию комментариев. Так что после отправки комментария до его публикации может пройти какое-то время (иногда долгое, т.к. я не всегда доступен).

Пётр Седов комментирует...

eao197, то есть вы хотите специальные блоки кода, чтобы компилятор проверял, что там не может возникнуть исключение. А зачем такие блоки нужны? Какие проблемы решают? В других языках разве такое есть? Когда пишу код на C++, у меня не возникает потребность в таких блоках. Зато часто сильно хочется try-finally, или defer (так оно обычно называется в новых языках; в языке D это пишется «scope(exit)»).

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

@Пётр Седов

> то есть вы хотите специальные блоки кода, чтобы компилятор проверял, что там не может возникнуть исключение

Да.

> А зачем такие блоки нужны?

Чтобы компилятор проверял мои предположения. Например, если я написал цепочку вызовов A(); B(); C(); в надежде, что они все noexcept, то компилятор может либо это подтвердить, либо ударить меня по рукам и указать, что я в своих предположениях ошибся.

Подробнее я когда-то расписывал здесь: https://habr.com/ru/post/466849/.

Примеры можно посмотреть в реализации RESTinio. Скажем, вот здесь (смотреть использование RESTINIO_ENSURE_NOEXCEPT_CALL).

> Какие проблемы решают?

Написание надежных деструкторов, обработчиков ошибок (грубо говоря, содержимого catch()), коллбэков для C API (т.е. коллбэк написан на C++, но отдается в чисто-Си-шную библиотеку).

> В других языках разве такое есть?

Когда мне нужно написать надежный код на C++, то мне как-то фиолетово что есть в других языках. Се ля ви.

> Когда пишу код на C++, у меня не возникает потребность в таких блоках.

Некоторые опросы говорят, что в половине C++ных проектов исключения вообще под запретом. Т.е. программисты, которые над такими проектами работают, вообще exception safety не заморачиваются. Да и среди оставшихся проектов далеко не все программисты отдают себе отчет насколько сложно писать exception safe код (к счастью, при написании программ для конечного пользователя это не так уж и часто нужно).

> Зато часто сильно хочется try-finally, или defer

В современном C++ (начиная с C++11) defer можно сделать самостоятельно.
Примерную реализацию можно подсмотреть в GSL: https://github.com/microsoft/GSL/blob/main/include/gsl/util#L63-L91

> в языке D это пишется «scope(exit)»

Как раз мне noexcept-блок нужен в том числе и для того, чтобы проще было писать код в условных finally и scope(exit): чтобы точно знать, что никакое исключение не выскочит у меня в середине моего scope(exit).