суббота, 1 августа 2020 г.

[prog.thoughts] Какой способ информирования об ошибках мне бы хотелось иметь для написания надежного кода?

Много лет занимаюсь разработкой софта, который должен работать в режиме 24/7 и, зачастую, под приличной нагрузкой. Это не mission-critical софт, скорее business-critical. Т.е. если будет глючить и падать, то никто не умрет. Так что писать "пуленепробиваемый" и "не убиваемый" кода пока не приходилось. Тем не менее, нестабильная работа написанного нами софта -- это авралы, стрессы, неприятности с клиентами. Понятное дело, что никому такое не нужно.

В связи с этим при написании кода меня регулярно терзает мысль "а насколько он надежен?" Мысль понятная и вопрос вполне себе по теме. Но вот ответ на этот вопрос далеко не всегда очевиден.

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

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

Что хотелось бы иметь в языке программирования и почему это хочется иметь

Не "исключения vs коды возврата", а "и исключения, и коды возврата"

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

Мой многолетний опыт говорит, что использование исключений делает код компактнее и понятнее. Расширять код со временем проще. И при этом всем, код оказывается надежнее.

Однако, очень важно, чтобы исключения применялись лишь в исключительных ситуациях. Когда ошибка не предполагается и не ожидается. Но вдруг все-таки происходит. Вот в таких случаях исключения должны использоваться. (Сюда же добавляются контексты, в которых кроме исключения ничего больше и не подходит: конструкторы, перегруженные операторы)

В остальных случаях следует использовать возвращаемые значения для информирования об ошибках. И тут дело даже не в том, что возвращаемые значения эффективнее исключений (про эффективность речь пойдет ниже). А в том, что логика "попробовали, если все хорошо, то делаем это, если не хорошо, то делаем вот это" гораздо проще записывается в коде и затем восстанавливается из кода, если она реализована на возвращаемых значениях. А не на перехвате и обработке исключений. ИМХО, конечно же.

Сделал для себя именно такой вывод исходя из того, что очень уж много кода (особенно не библиотечного, а кода по реализации "бизнес-логики") приходится писать вот в таком стиле:

expected<Result, Error>
some_handler::do_something()
{
   const auto r1 = do_first_part();
   if(!r1)
   {
      log(...);
      return make_some_error_code(r1.error());
   }

   const auto r2 = do_second_part(*r1);
   if(!r2)
   {
      log(...);
      return make_another_error_code(r2.error());
   }

   ...
   const auto rN = do_last_part(*rK);
   if(!rN)
   {
      log(...);
      return make_yet_another_error_code(rN.error());
   }
}

Т.е. попробовали сделать что-то. Если все хорошо, то пошли дальше. Если не хорошо, то на это "не хорошо" нужно среагировать здесь и сейчас. И эта реакция может повлиять на общий результат всей операции, внутри которой неудачно завершилось одно какое-то действие.

Если делать такое на исключениях, то получится, что каждое мелкое действие будет заключено в свой try/catch, что не сделает код читабельнее.

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

Грубо говоря, когда у нас есть код вида:

const auto r = connection_value_t::try_parse( field_value );
if( r )
{
   std::move( r->values.begin(), r->values.end(),
         std::back_inserter( aggregated.values ) );

   return restinio::http_header_fields_t::continue_enumeration;
}
else
{
   // Возникла ошибка разбора очередного заголовка.
   log_message(
         spdlog::level::err,
         fmt::format(
               "unexpected case: unable to parse value of {} header: {}",
               field_name,
               make_error_description( r.error(), field_value )
         ) );

   opt_error = invalid_state_t{ request_parse_error_detected };

   // Идти дальше нет смысла.
   return restinio::http_header_fields_t::stop_enumeration;
}

То здесь могут произойти разные ошибки в разных местах. Скажем, при выполнении std::move может возникнуть исключение при добавлении очередного элемента в aggregated.values. Или исключение может возникнуть при попытке залогировать ошибки (исключение может бросить как make_error_description, так и fmt::format, так и log_message). Но нас не интересует что именно и почему пошло не так: либо все OK и исключений нет, либо не OK и тогда все равно по какой именно причине.

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

Исключения должны быть не просто дешевыми, а очень дешевыми

На мой взгляд, исключение должно быть value-объектом. Выбрасываться и перехватываться исключения должны по значению. Чтобы стоимость выбрасывания исключения равнялась возврату объекта вроде std::error_code.

Как по мне, исключения должны представляться структурами, похожими на std::error_code (т.е. некий код + указатель на std::error_category). Только я бы туда добавил бы еще unique_ptr для некого простого типа вроде:

class error_payload
{
public:
   virtual ~error_payload();

   virtual const char * what() const noexcept = 0;
};

Этот класс могли бы использовать те, кто обязательно хочет передать вместе с исключением какую-то сопутствующую информацию (вроде детального описания причины ошибки). Нужно бросить исключение с пояснением почему какой-то конкретный файл не удалось открыть? Нет проблем: делаем наследника для error_payload, в котором хранится это описание (скажем file_not_found), затем динамически создаем экземпляр file_not_found и помещаем этот указатель в экземпляр бросаемого исключения.

А если мы не хотим вместе с исключением давать какую-то дополнительную информацию, то просто бросаем исключение без каких-либо дополнительных объектов. Тем самым получаем дешевое и предсказуемое поведение при выбросе исключения.

Кроме спецификатора noexcept нужен еще и noexcept блок кода

Уже несколько раз об этом говорил, но не грех и повторить: кроме спецификатора noexcept, который указывает, что функция/метод не бросают исключений, очень нужно иметь еще и noexcept блок кода. Чтобы компилятор бил по рукам за попытку выполнить бросающие операции внутри noexcept блока.

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

some_my_complex_class::~some_my_complex_class()
{
   try
   {
      perform_some_cleanup();
   }
   catchconst cleanup_error & x )
   {
      log_message(spdlog::level::warn,
            fmt::format("{}: cleanup failure: {}", name(), x.what());
   }
}

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

Поэтому я бы предпочел иметь возможность писать так:

some_my_complex_class::~some_my_complex_class()
{
   noexcept
   {
      try
      {
         perform_some_cleanup();
      }
      catchconst cleanup_error & x )
      {
         log_message(spdlog::level::warn,
               fmt::format("{}: cleanup failure: {}", name(), x.what());
      }
   }
}

Блок noexcept, на мой взгляд, хорош тем, что его можно применять локально, в тех местах, которые заслуживают особого внимания. Вроде секций catch:

expected<Result, Error>
some_action()
{
   try
   {
      do_first_action();
   }
   catch(...)
   {
      // Здесь мы не должны выбрасывать исключения.
      noexcept
      {
         ... // Какие-то действия по обработке ошибки.
         return make_error_code();
      }
   }

   do_second_action();

   try
   {
      do_third_action();
   }
   catch(...)
   {
      // Здесь мы не должны выбрасывать исключения.
      noexcept
      {
         ... // Какие-то действия по обработке ошибки.
         return make_another_error_code();
      }
   }

   return Result{...};
}

Из коробки должны быть доступны Sum-Types (aka variant) и паттерн-матчинг для них

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

Но здесь следует сделать важную оговорку: это будет хорошо работать только если из функций/методов можно будет возвращать либо нормальное значение, либо ошибку (одну или одну из нескольких возможных). Т.е. если результат работы функции/метода представляется т.н. sum-type (в C++ эта возможность представлена через std::variant в C++17, либо через boost::variant для предшествующих стандартов).

При этом sum-types -- это только часть истории. Причем не самая важная. Самая важная часть -- это когда sum-types дополнены нормальным паттерн-матчингом на уровне языка. Чтобы возвращенное значение a) было легко разобрать на варианты и b) чтобы компилятор мог ударить по рукам, если не все варианты обработаны.

Вот при наличии sum-types и паттерн-матчинга, действительно, будет удобно возвращать ошибки в виде значений и обрабатывать из тут же, не отходя от кассы. По типу такого (синтаксис взят из текущего предложения по добавлению паттерн-матчинга в C++):

variant<UserParams, AuthFailure, InternalError>
try_authentificate_user(const UserCredentials & credentials) {...}
...

inspect(try_authentificate_user(extract_credentials()))
{
   <UserParams> up: check_user_permissions(up);

   <AuthFailure> af: { log_failure(af); pause(); send_negative_reply(); }

   <InternalError> ie: { log_failure(ie); send_negative_reply_server_error(); }
}

И если в inspect не будет варианта для, например, InternalError, то компилятор должен отказаться принимать такой код.

Зачем это все нужно?

Все перечисленное выше мне бы хотелось иметь потому, что в совокупности это позволяет легко, просто и дешево сообщить о возникновении ошибки. А после её возникновения нельзя просто так взять и ошибку проигнорировать:

  • исключение прерывает нормальное выполнение и нужно явно вписывать в код try/catch для того, чтобы исключение не уронило всю программу;
  • при этом исключение -- это не приговор, исключение можно перехватить на том уровне, где с последствиями можно справиться, после чего можно продолжить исполнение;
  • при этом исключения не обязывают явным образом "протаскивать" имена типов, связанных с ошибками, через API. Что упрощает и интеграцию с чужим кодом, и развитие собственного кода;
  • при этом исключения настолько дешевы, что не нужно запрещать их использование по соображениям производительности и/или предсказуемости времени исполнения кода;
  • noexcept блоки кода позволяют создать дополнительные проверки для кусков кода, в которых мы не хотим иметь дело с исключениями;
  • sum-types и паттерн-матчинг делают простым и надежным распространение информации об ошибках через возвращаемые значения. Тем самым можно оставлять исключения для исключительных ситуаций, а в "повседневной" жизни использовать явное распространение ошибок через возвращаемые значения.

Что не так с C++ (и не только с C++)

Что не так с C++?

Если не все, то очень многое ;)

Исключения в C++ -- это не value-объекты. Как я понимаю, при выбросе исключения бросаемый объект создается в динамической памяти и далее пробрасывается указатель на него. Что уже не дешево.

А далее в C++ в дело вступает еще одна дорогая штука: поиск обработчика для исключения. Для чего используются механизмы, аналогичные RTTI (в принципе, они и используются, только в органиченном масштабе, несколько я понимаю).

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

Так же я придерживаюсь мнения, что время показало две ошибки, которые были допущены при добавлении механизма исключений в C++:

  • отсутствие единого базового класса для всех типов исключений. Т.е. не смотря на наличие std::exception ничто не запрещает вам бросить простой int. Из-за чего, если вы заботитесь о надежности кода, то кроме catch(const std::exception&) вам нужно делать еще и catch(...);
  • возможность построения иерархий для классов исключений. Вроде бы классная штука, которую я сам раньше считал одной из killer features языка C++. Но с годами приходит понимание, что развесистая иерархия собственных исключений -- это скорее признак проблем, чем реальная помощь в проекте. И что гораздо лучше иметь просто одно какое-то специфическое для проекта исключение, нежели целый набор различных типов исключений на разные случаи жизни.

Так что со временем я пришел к выводу, что если бы выброс исключения был эквивалентом возврата чего-то вроде std::error_code, то это было бы и проще, и дешевле, и удобнее, чем тот механизм исключений, который сейчас есть в C++.

Ну а по поводу sum-types и паттерн-матчинга вообще говорить пока не о чем. Хоть std::variant уже и завезли в C++17 (а boost::variant или variant_lite были доступны и еще раньше), но без полноценного паттерн-матчинга на уровне языка это больше костыль, нежели хорошее решение на долгие годы. Ибо код с std::variant и чем-то из дополняющих его функций (будь то std::get, std::get_if, std::holds_alternative или std::visit) по своей лаконичности, понятности и надежности очень сильно отстает от того, что можно было бы писать будь в языке продвинутый switch или inspect.

Так что, с моей текущей точки зрения, C++ не есть язык с удобными, эффективными и надежными средствами информирования об ошибках :(

А что же у конкурентов?

C#

Если в качестве конкурента рассматривать C#, то C# выглядит достаточно продвинуто. Насколько я понимаю, в C# начиная с 7-й версии добавляют возможности паттерн-матчинга, что очень круто. Только вот я не в курсе того, как в C# реализуются sum-types и насколько принято в C# использовать sum-types для возврата одного из нескольких возможных типов результата из методов.

Но, если не ошибаюсь, в C# нет аналога C++ного noexcept. И нет noexcept блока. Так что, наверное, в C# несколько сложнее писать куски года, в которых мы не хотим иметь дело с исключениями. Плюс к тому, я не знаю, насколько дороги исключения в C# и настолько их использование "просаживает" производительность.

Java

Если в качестве конкурента рассматривать Java, то там, насколько я понимаю, все плохо. Поддержки sum-types на уровне языка нет. И эту поддержку пытаются эмулировать подручными средствами, что выглядит ничуть не лучше, чем в C++ (можно посмотреть здесь, здесь и здесь).

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

В общем, Java как была говном, так и остается. Уж пусть мне простят эту оценку те, кто Java на жизнь зарабатывает.

Rust

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

Go

Если в качестве конкурента рассматривать Go, то там (насколько я знаю) нет sum-types и паттерн-матчинга. Хотя и можно возвращать несколько значений из функции, чем активно и пользуются для возврата либо нормального результата, либо ошибки. Ну и вместо исключений паники. Которые, вроде как, можно перехватывать и восстанавливаться после перехвата. Но вот насколько паники эффективны (в смысле стоимости) -- это мне не ведомо.

Swift

Вряд ли Swift на данный момент можно рассматривать в качестве конкурента C++. За пределами платформ Apple его пока не видно. Да и, насколько я понимаю, производительность кода не является приоритетом для разработчиков Swift.

Тем не менее, в Swift хотя бы попытались переосмыслить подход к распространению информации об ошибках. И получилось весьма интересно. Хотя и излишне многословно, как на мой вкус.

Однако, поскольку это пока что совсем свежий эксперимент и Swift не достиг такого массового использования как C++, Java, C# или Go, сложно сказать, насколько хороши идеи Swift-а и пройдут ли они испытания практикой. Так что нужно посмотреть, что из этого в итоге получится.

А как же Kotlin/Ceylon/Scala/OCaml/Haskell и, тем более, D?

А вот хз. Маргинальщина меня на данный момент не интересует. Точнее так: не каждая маргинальщина меня интересует.

Как по мне, то по большому счету, сейчас у C++ прямыми конкурентами являются чистый C, Rust и Go. Это из того, что либо в мейнстриме, либо на подходе к нему. Из маргинальщины я бы добавил в конкуренты еще Ada (на самом деле прямой конкурент, но малоизвестный в наших палестинах) и Eiffel.

Все эти языки компилируются в нативный код и запросто могут использоваться в тех нишах, в которых C++ все еще живет (системное и околосистемное ПО, высокая производительность, реальное время). Правда, по поводу "запросто" следует сделать оговорки для Go и Eiffel, т.к. это языки с GC, а GC налагает свои ограничения для тех или иных применений языка программирования.

Тогда как языки для .NET или JVM, а так же махровая функциональщина типа OCaml/Haskell -- это уже совсем другие ниши. В которых если C++ и остался, то разве что по историческим причинам. И я слабо себе представляю, зачем кому-то брать C++ для задачи, которую проще и дешевле решить на C# или Haskell. Как и не представляю зачем тащить Haskell туда, где гораздо более естественно взять C++, Ada или Rust.

Поэтому я не буду пытаться оценивать то, что меня на данный момент не интересует, и в чем я мало разбираюсь.

А да, про D. Ну вот не нужен он никому. Се ля ви.

Заключение

Данная статья является результатом переосмысления многолетнего опыта разработки на C++ с оглядкой на то, что доступно (или принято) использовать в других языках. И с учетом того, что постепенно проникает в C++.

Написана она, во-первых, для того, чтобы лучше сформулировать то, что крутится у меня в голове. И, во-вторых, для того, чтобы поделится своим мнением с окружающими. Может быть кому-то это будет интересно/полезно. А может быть кто-то укажет на какие-то мои заблуждения и это принесет дополнительную пользу мне самому.

Так что спасибо всем, кто дочитал до этого места. Надеюсь, ваше время было потрачено не зря.


На правах рекламы. Мы давно занимаемся разработкой, написанными нами софт работает годами. К работе подходим вдумчиво, делаем качественно. Оценить это можно по OpenSource проектам (SObjectizer, RESTinio). И мы открыты для сотрудничества.

16 комментариев:

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

Kotlin сам по себе не маргинальщина уже. А вот Kotlin/native - да, пока еще маргинальщина. Но пилят потихоньку, со счетов списывать рано - давеча вон допилили корутины для native - и пообещали новый memory manager. Очень хотелось бы чтобы взлетело, я бы с радостью с С++ слез бы на него.

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

@Left

В наших Палестинах еще маргинальщина. Которая кроме как для программирования под Android больше нигде не видна.

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

Ну как бы - не андроидом единым, везде где есть Джава ее можно смело менять на Котлин, как JS меняется на TypeScript. Мы котлин на серваке гоняем и просто счастливы, волосы стали шелковистыми а пипяка - длиннее и крепче. Но у нас куча С++ кода который бы мы с ОГРОМНЫМ удовольствием на котлине переписали бы...

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

@Left

Я в курсе, что Kotlin начинают использовать не только для Android-а. Но говорю с позиции того, что слышу вокруг. А у нас пока Java доминирует, а Kotlin разве что для мобилок.

Ну а так-то в мире и COBOL используется, и Fortran. И даже D :)

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

Ну как бы - Кобол такими темпами не развивается. И D тоже не развивается. Я тут давеча рассуждал с товарисчем по поводу развития языка - и говорил ему о том что рискую прослыть ретроградом-ынтырпрайзником - но считаю что в 21м веке язык должен писаться конторой (желательно крупной и с нужным бекграундом) а не комитетом, быть 100% опенсорсным и иметь при этом не слишком много новых идей. Тогда оно имеет шанс взлететь, иначе - помрет.

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

@Left

Я не слежу за Kotlin-ом, но вот D, как по мне, развивается слишком активно и никак не может достичь точки стабильности, при которой его использование в долгоживущих проектах стало бы безопасным.

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

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

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

@Left

Да разве же D -- это дизайн комитетом? Вот C++, Ada, Fortran. Может быть даже Java сейчас. Вот это работа комитета. А в D там все зависит от настроения двух-трех человек. Захотелось Брайту сделать betterC и добавить borrow checker в язык. Ну и понеслось. Вне зависимости от того, что другие пользователи D думали по этому поводу.

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

легаси и многобразие в с++ ставит исключения в исключительную ситуацию ))
1. исключения являются неявной частью интерфейса функции/метода. фактически без чтения документации и надежды на ее корректность тяжело/невозможно написать корректный код который эту функцию вызывает. без доков если функция не noexcept остается использовать catch (...) на каждый вызов.
2. noexcept не помогает писать код т.к. компилятор не знает бросают ли исключения те функции которые вызывается под noexcept. т.е. клиенсткому коду хорошо - он взывает уже noexcept а вот тому кто пишет noexcept функции тяжело. да и ответ программы на простую багу типа забыл тут try/catch - terminate
3. является ли ошиба исключением или нет решать должен клиенсткий код. например для fmt::format некорректный формат это ужас ужас, а для вас - просто запись в логе. ну некорректная и ладно
какие исключения не придумай все равно существующий код не даст перейти на это нововведение. в общем получается как всегда 100500+1 стандарт

писал как-то фреймфорк для автомобильного применения. пробовал boost::outcome. в целом понравилось.
есть логирование ошибок - удобно и централизованно. не пропустить случайно.
ошибка всегда возвращается явно - нет неявного интерфейса.
клиентский код решает бросать или нет.
[[nodiscard]] дает сообщение что возвращаемое значение нужно обработать.
из того что не понравилось - муторно передавать ошибки дальше по стэку вызовов.

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

@Alex

ИМХО, noexcept нужны не столько для того, чтобы писать "обычный" код, сколько для того, чтобы писать код по восстановлению после проблем (например, обработчики исключений в catch) и код по очистке ресурсов (деструкторы). Но пока использовать noexcept сложнее, чем хотелось бы именно потому, что "компилятор не знает бросают ли исключения те функции которые вызывается под noexcept". Эту сложность можно попробовать обойти подручными средствами. Но лучше бы иметь помощь от компилятора (например, в виде описанного в посте noexcept блока).

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

@eao197
"noexcept нужны не столько для того, чтобы писать "обычный" код, сколько для того, чтобы писать код по восстановлению после проблем" я бы сказал и там и там - зависит от применения
"Но пока использовать noexcept сложнее, чем хотелось" именно, и не очень понятно как noexcept блок может помочь - все равно компилятор не знает кто и что бросает, а раз так то получаем terminate вместо хотя бы стэка.
в обшем не понятно как это все реализовать при возможности использовать существующий код...

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

@Alex

> я бы сказал и там и там - зависит от применения

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

> все равно компилятор не знает кто и что бросает, а раз так то получаем terminate вместо хотя бы стэка.

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

Знать что именно бросается, кмк, нужно далеко не всегда.

> в обшем не понятно как это все реализовать при возможности использовать существующий код...

Пост был не столько про то, как делать "правильно" в C++. Скорее про то, что хотелось бы видеть в гипотетическом "языке мечты".

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

@eao197
"Ну вот мне сложно придумать сценарии в которых нужно знать про noexcept когда пишешь обычный код."
скажем исключения запрещены по тем или иным причинам (в атомобильном секторе это распростроненное ограничение)
"Компилятор знает, что может бросать исключения." это да, причем просто по сигнатуре без noexcept, но и false positive тоже будут. новерное, ворнинг лучше чем ничего.

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

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

> Есть паники. Которые не исключения. И которые, в принципе, могут сразу приводить к краху всего приложения, без каких-либо шансов на перехват и последующее восстановление.

Панику в Rust таки можно перехватить:
https://doc.rust-lang.org/std/panic/fn.set_hook.html
https://doc.rust-lang.org/beta/std/panic/fn.catch_unwind.html

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

@XX

Насколько я помню, код на Rust можно скомпилировать в режиме, когда выброс паники сразу приводит к аборту приложения. Так что если кто-то пишет на Rust-е либу в которой паники бросаются и перехватываются, а затем эта либа попадает в приложение, которое собирается в режиме "treat panic as abort", то приложение накрывается медным тазом при первой же брошенной панике.

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

Sum types с паттерн матчингом это хорошо, но протаскивание ошибки через много слоев/блоков может быть довольно муторным. В этом смысле занятно сделали в современном Окамле: там можно вернуть обычное значение из функции, а можно кинуть исключение из любого ее места, а в вызывающем коде можно проанализировать их вместе. Например, если функция myFunc обычно возвращает option, но может и кинуть исключение, то пишешь

match myFunc arg1 arg2 with
| Some x -> do_something_with_x
| None -> do_something_about_nothing
| exception End_of_file -> do_something_else1
| exception Err -> do_something_else2

А если тут не стал ловить исключение, оно дальше вверх полетит.