SFML: Error handling in SFML 3
Back in 2015, I created several forum threads around the topic of error handling/reporting:
Some discussions around the topic date back to 2011 and 2013:
These are quite interesting, already to understand historic context and how SFML 1 and 2 evolved to be without exceptions.
Error handling in SFML 3
Now the year is 2022, a lot has changed, and at last we have the option to introduce breaking changes and redesign the API for better userfriendlyness. Handling errors is an important topic, and there are different options to tackle them in SFML 3. I’ll jump right into them:
-
Boolean return types E.g. SFML 2
sf::Image::loadFromFile()- ✔️ Simple.
- ✔️ Easy to port to bindings.
- ❌ Can be forgotten (
[[nodiscard]]is needed on every single method). - ❌ Needs separate output parameters.
- ❌ No way to transport error messages, needs
sf::err()side channel.
-
Optionals Basically all the disadvantages of
boolexcept no need for output parameters.- ✔️ Simple to understand and document.
- ✔️ Easy to port to bindings.
- ❌ Can be forgotten (
[[nodiscard]]is needed on every single method). - ❌ No way to transport error messages.
-
Assertions Listed for completeness, but they are not really a solution for runtime errors. But we might want to use them more often to complement other error mechanisms in the case of logic errors.
- ✔️ Immediate breakpoint, must be fixed.
- ✔️ No runtime overhead in release.
- ❌ Only useful for errors that are bugs (not loading files etc).
- ❌ Do not allow the user to be lenient about certain errors.
-
Exceptions
- ✔️ User cannot ignore them.
- ✔️ Automatically propagate upward.
- ✔️ Can be polymorphic, allowing automatic fallbacks and higher-level handling.
- ✔️ Can theoretically transport extra data beyond message, although rarely done in practice.
- ✔️ Can be used in constructors.
- ❌ Hard to document and follow, especially once multiple layers/callbacks/virtual functions are involved.
- ❌ Exception safety can be hard to achieve, and we need to enter the whole
noexceptrabbit hole. - ❌ Need verbose
try/catchblock even in situations where the user explicitly wants to ignore them. - ❌ Not easy to map to bindings, especially due to polymorphism.
- ❌ Small runtime overhead (more or less depending on exception mechanism).
-
Result type Inspired by
std::expectedproposal or Rust’sResulttype. Basicallystd::variant<T, E>but with a highly specialized API.- ✔️ User cannot forget them (
[[nodiscard]]can be declared directly on the type). - ✔️ User can however explicitly and concisely ignore them (e.g. with
(void)or anignore()method). - ✔️ Allow to transport arbitrary data as part of the error (e.g. an enum, message, context info).
- ✔️ Function signature makes immediately obvious which methods can fail, and how they can do so – users are not surprised by unexpected errors and cannot miss any documentation.
- ❌ No automatic propagation (could probably be made somewhat ergonomic as a library solution).
- ❌ Require static factory functions such as
Image::fromFile(), no constructor support (if this is truly a disadvantage). - ❌ Non-polymorphic unless explicitly designed.
- ❌ Requires either custom-built type for SFML or use of an established library.
- ✔️ User cannot forget them (
About this issue
- Original URL
- State: open
- Created 2 years ago
- Reactions: 10
- Comments: 29 (27 by maintainers)
Firstly, see my comments and @Bromeon’s comments on the issue.
I strongly dislike and discourage the use of exceptions for situations where the user wants to react to an error relatively quickly rather than having it bubble up a lot, and my claim is that the vast majority of functions fit in this description.
If a font (or any other resource, really) fails to be loaded, you want to immediately either:
Log the issue and continue with a fallback resource.
Exit the program.
Interactively ask the user for the location (e.g. the resource is being loaded from a user-provided script rather than from the game engine itself).
All of these actions are to be taken immediately. Attempting to load a font in the middle of the game loop, as part of the game logic, and handling a possible failure in – say –
mainis just not good design and not something I’d like to promote.Here’s an analysis of some approaches when loading a single resources and trying to provide a fallback (real use case – I used this “fallback” approach in real projects):
Note that the example above is realistic for smaller games, but in larger games you usually have some sort of resource manager, or load resources on demand. Let’s see a basic example of a resource manager:
Now, let’s imagine we load all fonts at the beginning of the application, through a
loadFontsfunction. How does the choice of error handling mechanism affect the correctness and verbosity of the code?My personal preference and what I’d like everyone to use is either
std::optionalorsf::Expected. They force the author of the code to at least think about the error locally, and always give the option to propagate it upwards as an exception if they want to.Some thoughts on the path to whatever ends up the desired goal.
Atm SFML is at “run
t.loadon uninitialisedT, and returnbool” which we all seem to agree is not ideal. Changing this tostd::optional<T> loadT();is a modernisation of the former, but has no extra error information. I think it’s also easy to make the argument that we should have error information but that we don’t know which style of this that we want and the question seems to mostly be aroundexpectedvsexceptions.IMO we should focus on deciding which of these we want, thinking long-term future. I don’t think we should take
exceptionsoverexpectedbecuasestd::expectedisn’t available until C++23, as that’s only a couple of years away but SFML is supposedly going to try and stay relevant for the foreseeable future, so that seems rather short-sighted IMO. Even if a polyfill is not ideal, a few years with a non-ideal polyfill (or even postponing this error discussion for post-C++23) is much better than ending up picking the suboptimal error handling style and living with that for the rest of the library’s lifetime (assuming we would end up thinking thatexpectedis preferred for SFML).Also, going “return bool” -> “use exceptions” -> “use expected” is a quite jerky road, which fundamentally changes the error handling twice. To me anyway, “return bool” -> “return optional” -> “return expected” is a cleaner road, since the pattern is the same after the initial switch, we’re just adding more information. Ideally personally for me, I’d want to see “return bool” -> (“return expected polyfill”) -> “return expected” since that’d mean we get useful error info from the first change, and the second change becomes trivial. I think we should not go down the route of exceptions at all, unless we decide that expections is where SFML wants to be long term, and in that case we should go “return bool” -> “use exceptions” since that is possible to implement today, and has fewest steps.
And if it wasn’t clear, my personal vote is
expectedand notexceptionswith no extra arguments, it’s all outlined above - and I do consider this more or less purely a choice of style/preference with no real critical dealmakers/dealbreakers on either side.Id say option 5 is best - although we can’t use the STL one the arguments for it still stand and for SFML’s purposes it wouldn’t be too hard to implement
TL;DR I vote for Option 2.
Option 1 is the same as Option 2 given the limitations of C++03. Option 2 is the natural modernization of what we’re already doing. We can transition to this pattern easily and the library won’t feel much different from how errors are treated in SFML 2. We’re still returning truth-y values to indicate success or failure but we do so in a more expressive and idiomatic way that allows for shorter user code and better const-ness.
Assertions seem somewhat orthogonal to me. They’re mostly a developer tool to catch invariant violations. Sure, users with debug builds of SFML can benefit from them but we cannot rely on users having access to debug builds so we cannot craft APIs around this assumption.
Exceptions are my preference in the general case. They’re well understood and impossible to accidentally ignore. It’s worth mentioning that I seldom see anyone handle
sf::Font::loadFromFile’s failure other than returning frommainor throwing. If the API already threw upon failure, users would still get the behavior they seemingly want. However I understand its shortcomings and how it may do a poor job serving the last ~10% of users who do want to take action if a resource fails to initialize.std::expectedis maybe the ideal solution. I’ve usedtl::expected(a clone of C++23’sstd::expected) and really like it. It gives me a lot of what I like about exceptions. The implementation is ~3,000 lines long so it’s not reasonable to implement this ourselves. If we are to depend ontl::expectedthen that’s a discussion unto itself. In the end I think that this is just out of our reach and not worth the marginal improvement overstd::optional.While I’d have loved to solve the “missing information” problem with SFML 3 and personally would’ve gone with an exception API, it seems that
std::expectedis gaining a lot of support overall and as such, it may make sense to not introduce an API that has no specific future.During the last maintainer meeting, the general consensus, which I support, is to go with
std::optionalin SFML 3 and hopefully we’ll be ready forstd::expectedin SFML 4. The transition fromstd::optionaltostd::expectedwould be a lot more straight forward then.As said, the downside is, that SFML 3 still doesn’t provide an option to retrieve the actual errors beyond redirecting the
sf::err()buffer.Restating again for completeness, we won’t be implementing our own
sf::Expectedtype “polyfill”. It’s better to first have people get used to standard types, instead of teaching them something SFML specific. An eventually transition would also require more work, plus we’d have to maintain our own custom implementation.From what I read it seems that there’s a concenseus towards
std::expected. In llvm 16 there’s already an implementation, and there’stl:: expectedas well. So in the mean time we can have thetlheader just pasted in the code, and when the other major vendors implement the librarytlcan be replaced with minimal breaking changes.I think that the next question is what the
error_typeshould be. Personally -std::stringdoesn’t sound like an ergonomic type for recoverable errors. What I propose is usingstd::error_condition. It covers a string message, an error category, and the specific error code in that category. It does come with a caveat: the error codes are not portable easily, unless we make our own.Quite a few APIs in SFML have different “failure modes”, just two examples:
I can only re-iterate a bit from the previous summary. The error handling style of a library is a pretty fundamental choice and should IMO be compatible with the long-term vision of the library.
returning bool with out-refandoption<bool>are unanimously agreed as bad/not ideal, since they do not carry any error information. This means we need to change the API, and it’s not going to be towardsoption<bool>.std::expected, but the problem with this approach is that it’sc++23.In that case the options kinda boil down to:
std::expectedis more popular, becausestd::expectedis C++23std::expectedfor a few years and later switch over to C++23IMO it’d be very short sighted to do #1. IMO #2 makes perfect sense, it’s how SFML kinda did already with
sf::Threadand some of thechronostuff and I don’t see that being particularly harmful. If anyone truly is against this path, it would be good if they spoke up with concrete arguments on why. IMO #3 could also make sense, C++23 isn’t that far away, and if SFML adopted a more rapid major-release cycle that doesn’t contain very very many things, taking a decade+ to finish up, it would possibly mean waiting just some ~3-5 or so years which could be okay?A further note on
std::expectedVS exceptions. A function that returnsstd::expectedcan easily be used as if it had exceptions as error handling by calling.value()on it which throws with the contained error. So people preferring exceptions could get very similar semantics/behaviour by doing that. The opposite is not true however, as there’s no good way of turning an exception-based function call into something that returns anstd::expected. With that + what @Bromeon said with “errors are part of the type system and not some meta-information hidden inside documentation”, I feel that for a library that deals with building-block style APIs, making it return-value oriented is the more building-block style API that’s ultimately more flexible as it can be used to achieve either approaches.As for reaching a consensus - this is C++ error handling, so I doubt there will ever be one 😁
I don’t agree that
expectedtype VSexceptionsis a matter of complexity VS simplicity whereexceptionsis the simple one. Both can have simple APIs and both are equally powerful IMO.If you just wanna exit the program and you don’t care about handling the error recovery, with and
expectedtype you’d just doresult.unwrap()or whatever the method would be.So in my mind, the “
expectedis for the minority of people who want power, flexibility and complexity, whereasexceptionsis for people who want simple newbie-friendly APIs” is a false dichotomy.The difference IMO is more just a difference in style, with concrete differences in how you use things. The initial post of this thread outlines it very well 👍. Both ways can also be idiomatic, depending on what flavour of C++ you subscribe to.