C++ 枚举类和错误码,第三部分
C++ Enum Class and Error Codes, Part 3

原始链接: https://mropert.github.io/2025/12/03/enum_class_error_codes_part3/

## C++ 错误处理的重新思考:支持异常 本文探讨了 C++ 中传统错误处理方法(枚举、断言、契约和 `std::expected`)的替代方案,最终重新审视了经常被忽视的异常。虽然之前的方案存在缺点(污染逻辑、需要更改签名或功能有限),但异常提供了一种出乎意料的简洁解决方案。 核心论点是异常允许编写专注于“正常流程”的代码,并将错误处理委托给 `try/catch` 块或全局处理程序。对性能的担忧在很大程度上已经过时;最近的研究(由 Khalil Estell 进行)表明,由于基于表的异常处理,现代编译器(尤其是 x64/ARM64)的开销很小。 然而,C++ 缺乏强制的异常声明(删除了 `throw` 关键字)是一个重大弱点。这需要依赖注释进行文档记录,而 Java 的检查型异常 *要求* 处理或声明。作者建议采用类似 Java 的方法:使用异常处理可恢复的错误(如文件丢失),并依赖契约/中止来处理不可恢复的问题(如设备故障)。 最终,作者提倡使用异常,因为它们简洁易读,认为它们可以产生比替代方案更清晰的代码,甚至可以改进常见的无检查错误返回,从而导致后期崩溃且堆栈跟踪信息不足的情况。

黑客新闻 新 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 C++ 枚举类和错误码,第三部分 (mropert.github.io) 5 分,ibobev 发表于 1 小时前 | 隐藏 | 过去 | 收藏 | 讨论 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请YC | 联系 搜索:
相关文章

原文

Last time we explored some commonly found alternatives to using enums for error codes such as asserts, contracts and std::expected. Finally today we consider some unthinkable options.

The code examples given in part 1 and part 2 strongly focused on simple (and I’d argue, quite common) form of error handling: run a sequence of operations, and if any of them fails bail out and report an error. We tried a few alternatives but they all came with caveats. Error code returns polluted a lot of the logic with if ( error ) { return error; }, std::expected was a bit more concise but demanded we rewrite the signature of the functions we call to fit the monadic form and finally contracts allowed for cleaner user code but aren’t really made to do custom error handling (if they make it into C++26 at all) or recoverable errors (like a missing file).

As it turns out, there is a way in C++ to write a function or block expecting everything will succeed, and then through some compiler magic have the control flow bail on error and return, while still running any destructor that needs to be run. In fact it’s been there since the start. We could just have the functions we call throw an exception if they fail.

Exceptions? Really?!

Hear me out for a second. On paper this seems to fit the bill. Let’s look at what it would look like:

Model load_model( const std::filesystem::path& path )
{
    const auto blob read_from_path( path );
    const auto asset = parse_gltf( blob );
    return upload_to_gpu( asset );
}

For the implementer of load_model(), this is about as concise and clean at it could be. We write as if no error could happen, focusing on the happy path. And for the caller of load_model(), they can either add a try/catch block to handle failures, or let it pass through to our global exception handler (or call std::abort() if we don’t have one). In this case the caller should probably catch and handle it, as loading assets is the kind of thing that should be allowed to fail (although it should be loud about it so it gets fixed).

We could even one-line it like this:

Model load_model( const std::filesystem::path& path )
{
    return upload_to_gpu( parse_gltf( read_from_path( path ) ) );
}

Now for the drawbacks…

“Exceptions are slow and bloat the code”

This is the kind of thing that keeps being told and retold, but no one ever really checks or tries to reproduce. We just assume it’s true and move on. Luckily, over the past couple years someone took upon themselves to actually verify those assumptions.

That’s the work Khalil Estell has been doing for a few years now, with results shown in 2 talks I recommend looking at:

It focuses on GCC, ARM and embedded development, three things I don’t interact much with (after all PC games mostly care about x86_64 Windows Desktop), but the learnings and results are still applicable. As it turns out, there’s a lot of FUD out there about C++ exceptions, and a lot of conventional wisdom dates back from the pre 64 bits era, when exception handling was done through dynamic registration (setjmp/longjmp) and not using the more modern table driven approach.

As Khalil explains in his talks, the table approach used in x64 (and ARM64) has barely any cost on the golden path, it mostly manifests as a bit of extra code for catch blocks that get correctly branch predicted by the CPU. There is a cost to handling caught exceptions, but there again there’s a lot of room for improvement (as shown in the second talk, GCC’s exception handler had a 20 years old // TODO: optimize this comment in its source code).

In our case, having some overhead when handling exceptional failures such as an asset failing to load doesn’t seem like a big deal either way.

The real pros and cons

The main value to me has already been described earlier, it makes everything concise. The best code is no code, and in this case we don’t need to write anything outside of when we want to handle exceptions. The implicit pass-through nature is what allows us to focus on what the code is trying to do, and let the compiler handle error case propagation for us. This is the kind of quality of life thing I enjoy about C++ and other high-level languages.

The other advantage I’ve noticed is that unlike std::expected and enum error codes, we can finally have constructors that can fail. Gone are the days of two phase construction and .init(), or the ugly post construction .fail() check to make sure the object is actually useable. Not every constructor needs to be able to throw but for those few that can fail (like a class that encapsulates a hardware device) this feels much cleaner.

The biggest issue with exceptions, in my book, is the lack of documentation in code and enforcement by the compiler. One cannot tell if, and more importantly what, a function may throw just by looking at the signature. The use of the throw keyword in declarations was deprecated in C++11 and removed in C++17. The only thing that remains is noexcept, and quite frankly I find it useless for documentation. In my experience it’s only used for optimization purposes to satisfy if constexpr checks like std::is_nothrow_constructible and friends. I know that ship has sailed, but in my book noexcept should have been the default and we should have kept throw as a mandatory declaration for the functions that need to throw exceptions.

Since C++ didn’t go the Java route (which honestly I think they got right with the throws keyword), we’re left with comments to know when a try/catch block might be necessary. This is bad because it forces humans to rely on documentation (or read the implementation) rather than rely on something that the compiler can check and enforce (the Java compiler will force you to either catch all exceptions declared thrown by a called function, or declare the calling function as throwing them too).

Why did we decide to go this way in C++? Frankly I’m not sure. The references I could find didn’t have very convincing arguments. It was argued in P0003 that “exceptions specifications are a failed experiment”, but it’s unclear to me whether it’s just an observation that the poor original design lead us there, or because the author doesn’t like the idea at all. The original sin seems to date back from 1995 where N0741 favoured not adding any exception specification to the standard library because it would “constrain implementation” and leave std:: functions free to throw exception not mentioned in the signature.

C++ didn’t opt for checked exceptions, instead making the use of the throw specifications optional, so the standard library decided it wouldn’t bother either, and in turn most codebases and libraries did the same. Perhaps there was a lack of experience with exceptions at the time, Java 1.0 was only released in 1996. Almost 30 years later we have much more data and we can have a look at whether or not Java developers consider this a bad decision. I’m no expert in Java, but from what I could see the concerns seemed mostly about which exceptions belongs to which category, not about the whole system itself.

Too many exceptions?

In Java, exceptions are split between checked and unchecked. Only the former needs to be declared as thrown and handled, while the latter do not. In the second group you will find things like NullPointerException and ArrayIndexOutOfBoundsException. It’s been argued that libraries and framework have gotten their classification wrong on the occasion, but I could find a good summary on stack overflow:

Checked Exceptions should be used for predictable, but unpreventable errors that are reasonable to recover from.

Translated to C++, that rule could be to use exceptions for what Java would consider “checked exceptions”, and rely on contracts, asserts or straight calls to std::abort() for the rest. And going back to the error codes that got this whole discussion started, I think we could derive something similar.

Looking at our load_model() example, recoverable errors would be things like missing/inaccessible asset file or an unsupported format. Something we want to tell the user about (if we’re making an editor) or replace with a placeholder and a screaming log message (if we’re running a game test build).

On the other hand we have unrecoverable issues like device failures, programming errors, and generally most of the errors Vulkan can return. For these all we want to do is invoke some form of custom crash reporting before terminating. It might be theoretically possible to handle a VK_ERROR_DEVICE_LOST by shutting down the whole graphics context and recreating a new one, but I don’t see any game trying to do that rather than going back to desktop and let the player restart the game.

Final thoughts on error handling

Is it too late for exceptions in C++? Between the weak/non-existent compiler enforcement of throw vs catch and all the bad myth spread around about the performance of exceptions, it’s certainly not a great environment.

First, I wouldn’t use exceptions for what Java consider “unchecked exceptions”. In my opinion that’s one of the big things to learn from their usage. Contracts and calls to report_failure_and_abort() are better for that job.

Second, after going back and forth between alternatives on my current libraries, I found exceptions made my code more concise and readable than using std::expected or old timey error code return, enum class or not.

Finally, thinking back at how it’s been done in past projects, the most common was a bool return that might be checked, followed by crashes further down when it wasn’t. I’d argue that throwing exceptions with no catch block would achieve the same result, but at least the stack trace would point at the original issue in the crash dump.

联系我们 contact @ memedata.com