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:

  1. 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.
  2. Optionals Basically all the disadvantages of bool except 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.
  3. 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.
  4. 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 noexcept rabbit hole.
    • ❌ Need verbose try/catch block 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).
  5. Result type Inspired by std::expected proposal or Rust’s Result type. Basically std::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 an ignore() 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.

About this issue

  • Original URL
  • State: open
  • Created 2 years ago
  • Reactions: 10
  • Comments: 29 (27 by maintainers)

Most upvoted comments

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:

  1. Log the issue and continue with a fallback resource.

  2. Exit the program.

  3. 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 – main is 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):

//
//
// WITHOUT ANY ERROR HANDLING MECHANISM
sf::Font font = sf::Font::loadFromFile("abcd.ttf");
sf::Text text(font, "hello"); // Possible run-time issue (!)

//
//
// EXCEPTIONS, NO ERROR HANDLING
sf::Font font = sf::Font::loadFromFile("abcd.ttf");
sf::Text text(font, "hello"); // No run-time issue -- good.
                              // But the code looks exactly the same as before!
                              // The user forgot to handle the error, because
                              // it's not obvious that there is one.
                              
//
//
// EXCEPTIONS + FALLBACK
sf::Font font = [&]
{
    try 
    {
        return sf::Font::loadFromFile("abcd.ttf");
    }
    catch (const sf::LoadException& e)
    {   
        return globalDefaultFont;
    }
}(); // Verbose, poor code, requires IIFE or default-constructible font (bad).
     // Discourages people from handling errors where they should be.
     // Encourages unnecessary bubbling-up of errors.

sf::Text text(font, "hello"); 

//
//
// OPTIONAL, NO ERROR HANDLING
sf::Font font = sf::Font::loadFromFile("abcd.ttf").value();
sf::Text text(font, "hello"); // No run-time issue -- good.
                              // The code has a clear indication that something
                              // can go wrong -- the `.value()` call above.

//
//
// OPTIONAL + FALLBACK
sf::Font font = sf::Font::loadFromFile("abcd.ttf").value_or(globalDefaultFont);
sf::Text text(font, "hello"); // No run-time issue -- good.
                              // The code has a clear indication that something
                              // can go wrong, and visibly provides a fallback.

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:

class ResourceManager
{
private:
    std::unordered_map<std::string, sf::Font> m_fonts;
    // ...other resources...

public:
    void addFont(std::string_view key, sf::Font&& font);
    sf::Font const* getFont(std::string_view key) const; 
    // ...other resources...
};

Now, let’s imagine we load all fonts at the beginning of the application, through a loadFonts function. How does the choice of error handling mechanism affect the correctness and verbosity of the code?

//
//
// EXCEPTIONS, NO ERROR HANDLING
void loadAllFonts()
{
    for (const auto& f : std::filesystem::recursive_directory_iterator(fontFolder))
    {
        if (!f.is_file() || !f.path().has_extension("ttf")) { continue; }
        
        resourceManager.addFont(f.path().filename(), 
                                sf::Font::loadFromFile(f.path()));
            // Whoops, the author completely forgot to address the error case,
            // yet the code compiles!
            //
            // In this case, the first failure will result in all the remaining
            // fonts *NOT* being loaded. 
            //
            // Is this what the author really intended...?
    }
}

//
//
// EXCEPTIONS, LOG AND CONTINUE, EVERYTHING IN THE TRY BLOCK
void loadAllFonts()
{
    for (const auto& f : std::filesystem::recursive_directory_iterator(fontFolder))
    {
        if (!f.is_file() || !f.path().has_extension("ttf")) { continue; }

        try // First issue: what error are we trying to catch? Could be anything
            // in the block below, including something thrown by `resourceManager`
            // or by `std::filesystem`. 
        {
            resourceManager.addFont(f.path().filename(), 
                                    sf::Font::loadFromFile(f.path()));
        }
        catch(const sf::LoadException& e) // Now we clarify what error we care 
                                          // about, but we still MANUALLY need
                                          // to figure out what part of the code
                                          // above throws this error.
        {
            errorLog() << "Couldn't load font from path " << f.path() 
                       << ", skipping it...\n";
        }
    }
}

//
//
// EXCEPTIONS, LOG AND CONTINUE, ISOLATE ERROR IN THE TRY BLOCK
void loadAllFonts()
{
    for (const auto& f : std::filesystem::recursive_directory_iterator(fontFolder))
    {
        if (!f.is_file() || !f.path().has_extension("ttf")) { continue; }

        sf::Font font = [&]
        {
            try // Now it is clear what can throw. But we had to use IIFE.
            {
                return sf::Font::loadFromFile(f.path())
            }
            catch(const sf::LoadException& e) 
            {
                errorLog() << "Couldn't load font from path " << f.path() 
                           << ", skipping it...\n";
            }
        }();

        resourceManager.addFont(f.path().filename(), std::move(font));
    }
}

//
//
// OPTIONAL, LOG AND CONTINUE
void loadAllFonts()
{
    for (const auto& f : std::filesystem::recursive_directory_iterator(fontFolder))
    {
        if (!f.is_file() || !f.path().has_extension("ttf")) { continue; }

        std::optional<sf::Font> maybeFont = sf::Font::loadFromFile(f.path());
            // Clearly forces the author of the code to THINK what should be
            // done in case of error, at THIS point in time.
        
        if (!maybeFont.has_value()) // Usual control flow.
        {
            errorLog() << "Couldn't load font from path " << f.path() 
                       << ", skipping it...\n";

            continue;
        }

        resourceManager.addFont(f.path().filename(), std::move(*maybeFont));
    }
}

//
//
// THEORETICAL SFML EXPECTED, LOG AND CONTINUE, WITH SOME SUGAR
void loadAllFonts()
{
    for (const auto& f : std::filesystem::recursive_directory_iterator(fontFolder))
    {
        if (!f.is_file() || !f.path().has_extension("ttf")) { continue; }

        sf::Font::loadFromFile(f.path())
            .onSuccess([](sf::Font& font)
            {
                resourceManager.addFont(f.path().filename(), std::move(font));
            })
            .onError([](const std::string& context)
            {
                errorLog() << "Couldn't load font from path " << f.path() 
                           << ", skipping it... context: " << context << '\n';
            });
    }
}

My personal preference and what I’d like everyone to use is either std::optional or sf::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.load on uninitialised T, and return bool” which we all seem to agree is not ideal. Changing this to std::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 around expected vs exceptions.

IMO we should focus on deciding which of these we want, thinking long-term future. I don’t think we should take exceptions over expected becuase std::expected isn’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 that expected is 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 expected and not exceptions with 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 from main or 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::expected is maybe the ideal solution. I’ve used tl::expected (a clone of C++23’s std::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 on tl::expected then 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 over std::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::expected is 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::optional in SFML 3 and hopefully we’ll be ready for std::expected in SFML 4. The transition from std::optional to std::expected would 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::Expected type “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’s tl:: expected as well. So in the mean time we can have the tl header just pasted in the code, and when the other major vendors implement the library tl can be replaced with minimal breaking changes.

I think that the next question is what the error_type should be. Personally - std::string doesn’t sound like an ergonomic type for recoverable errors. What I propose is using std::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.

What APIs are prone to fail for a variety of reasons? I thought we were only dealing with functions that have 1 possible failure mode.

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.

  • I assume that returning bool with out-ref and option<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 towards option<bool>.
  • Also assuming it is true like @ChrisThrasher said that there is a majority of preference towards std::expected, but the problem with this approach is that it’s c++23.

In that case the options kinda boil down to:

  1. Use exceptions even though std::expected is more popular, because std::expected is C++23
  2. Implement a polyfill for std::expected for a few years and later switch over to C++23
  3. Wait with updating error handling until SFML uses C++23

IMO it’d be very short sighted to do #1. IMO #2 makes perfect sense, it’s how SFML kinda did already with sf::Thread and some of the chrono stuff 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::expected VS exceptions. A function that returns std::expected can 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 an std::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 expected type VS exceptions is a matter of complexity VS simplicity where exceptions is 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 expected type you’d just do result.unwrap() or whatever the method would be.

So in my mind, the “expected is for the minority of people who want power, flexibility and complexity, whereas exceptions is 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.