◀ prevnext ▶

My most hated feature in Rust

Programming · Dec 3rd, 2023

There are only two kinds of languages: the ones people complain about and the ones nobody uses.

Bjarne Stoustroup

I use Rust and I plan to continue, so I've earned the right to complain about Rust. But this won't be your average Rust vs Go blogpost. (Why are there so many of those? What the hell?) I won't be complaining about the borrow checker. Neither about traits, lifetimes, macros or compile times. These are fine. No, I will be complaining about Result. That is right. I will be complaining about one of Rusts most beloved features.

Rust prides itself on its robust and strong type system. If a function returns an object, you can be 99.99998...% sure that this object actually exists. (I am not saying 100%, because unsafe Rust exists. And who knows if some dependency you are using is doing some funky C++ shit under the hood.) If this object may not exist, it will be wrapped in an Option. Before you can access it however, you must first check, whether this Option actually holds your object. And if it doesn't, your object really doesn't exist and you cannot access it.

Result is quite similar, but a notch above Option. Like in Option, your object may or may not exist, but if it doesn't, it holds an error. This error (hopefully™) describes why the function couldn't return your object. And just like Option, before you can access your object, you must first check whether it actually exists or not.

Everything is safe and sound.

But then you start to use Result, and it turns out it's pretty clunky to use and just plain annoying.

The problems emerge

First of all, a lot of things can fail. Let me repeat: A LOT OF THINGS CAN FAIL. And you need to handle each and every Result you encounter. This means, everyone gets a match-statement, and bloated boilerplate ensues.

"Aha!" says the Rust evangelist, "Simply use the ? operator!" Ah yes. That removes the match. But it introduces more complexity somewhere else: Now the client function must also return a Result, and its error type must be the same as the calling function. This introduces problems the moment you call functions that return different errors. Now you'll have to convert errors to other errors, otherwise the compiler won't be happy.

"Well, just use From!" says the evangelist. Sure, let's hide the boiler plate code somewhere else, because this obviously fixes the issue. Also, do you really want to implement From between all the hundreds of error states, that your program can find itself in? I don't think so.

"I hear you, I hear you. What about map_err() then?" I mean yes, that works. It's obvious, straightforward and understandable. But it becomes quite annoying when you have to type it hundreds of times.

"Ehh, unwrap?" Cool. Why even use Rust in the first place?

Ultimately there is no silver bullet, and you will most likely use a combination of these solutions.

It keeps on giving...

Okay, usability aside, what else is there to hate about Result? Here's something: They give little information about what actually went wrong. Ideally, you want the error to implement std::error::Error, such that you can log it or something. You probably also want it to be an enum, such that it gives you a human readable and computer friendly way to tell what went wrong. Boilerplate over boilerplate.

Look, for the API of your library, it makes sense to cover all possible failure states. But from my perspective, building a game engine, an executable, I only care whether the given operation succeeded or not. And when it fails, it should tell me where and why. Result simply doesn't provide that information. The uncool thing about Result is, that anything can be an error. The error may not implement std::error::Error, and it may not be an enum. The SDL2 crate for example made the amazingly annoying decision, to only return Strings as errors.

Compare that to exceptions in C#, which I am very familiar with. When an exception is thrown, you get an entire stack trace. You know which function called which, and it's usually fairly easy to pinpoint where and why an exception was thrown. Not so with Rust errors. When a "stream read failed" error bubbles up to my entry function, my first reaction is annoyance, because I have no idea where it occurred. It's not like I don't read streams in like 100 different places, no?

In Rusts defense however, when building C# as Release, most debug symbols are stripped away, and you get error messages that are undecipherable and less useful than C++ linker errors. Because you know, the linker at least tries to tell you what went wrong. Exceptions in C# Release builds just spit digital vomit at you.

The antidote?

Nonetheless, all problems I've described thus far, drove me to the point where I attempted to solve them by my own custom error type: RisError. It stores a message, it's file and line where it was created, and the std::error::Error that caused it. The file and line are not as useful as an entire stack trace, but it's better than nothing. I also created a few macros, that make creating and converting errors into it easier. Now, everything that fails returns an RisResult<T>, which essentially is just a Result<T, RisError>. Thus I can use ? everywhere, and use macros whenever something is not an RisError.

fn foo(i: usize) -> RisResult<Bar> {
    ris_util::unroll!( // this right here is the golden goose
        im_a_dangerous_function(i),
        "failed to call function with {}",
        i
    )?;

    ...
}

fn im_a_dangerous_function(i: usize) -> Result<(), SomeError> {
    ...
}

At this point, you may notice that this looks awfully like exceptions. Abort execution at anytime... Possibly (hopefully™) catch it somewhere up the call stack... Pretty much a goto, but god knows where it will end up... And you may ask, why even go through the trouble and not just use exceptions in the first place? To that I say: RAII still holds. And RAII is very useful.

With this system, if an error occurs, I can still execute code. This allows me to gather system information, properly log failure states and ensure that the current state of the game is saved, such that no progress is lost. A try-catch (or std::panic::catch_unwind as the Rust people call it) doesn't work: Building as Release, exceptions are disabled and they are turned into aborts.

Even if I enable exceptions in Release, or are somehow able to catch aborts, working with exceptions in memory managed languages is infamously difficult. It's so difficult in fact, that people coined the term exception safety. If I keep avoiding exceptions, everything that implements Drop will be cleaned up properly.

And it is now, where I have to mention, that I am absolutely annoyed by the fact that the Rust people decided to rename exceptions to "panics". When talking to non Rust people, I always have to preface: "Ackchyually, Rust has exceptions, they just call it something different".

I understand why the Rust people decided to rename them. Because exceptions are rarely exceptional. If you are anticipating an error with try-catch, is it really an "exception"? But the irony is, in my case, exceptions now really represent exceptional state! It's state that I did not account for. State that allows the engine to crash without properly shutting down. It's so backwards, and I hate it. I hate it so much.

Conclusion

If there will be a successor of Rust, I'd wish for an error system in between of Result and exceptions, which goes alongside the other two. Something that generates a stack trace and unrolls like an exception, but allows RAII to properly free all held resources.

The compiler is smart. Couldn't the compiler generate a stack trace at compile time? I mean like generics, it could just figure out all the functions that call an error, and just place that function path as a compile constant string into the error, no? But I guess recursion must then be forbidden, because that damn halting problem exist. God fucking dammit. I curse you, halting problem!

Nevertheless. As much as I hate my current solution, it is usable. And that is all I need for now....

Because people will be asking, below is the implementation of RisError. I called the conversion macro unroll, to differentiate it from unwrap, and because RisErrors can be chained, like exceptions unrolling a stack.

GitHub permalink

use std::error::Error;

pub type RisResult<T> = Result<T, RisError>;

pub type SourceError = Option<std::sync::Arc<dyn Error + 'static>>;

#[derive(Clone)]
pub struct RisError {
    source: SourceError,
    message: String,
    file: String,
    line: u32,
}

impl RisError {
    pub fn new(source: SourceError, message: String, file: String, line: u32) -> Self {
        Self {
            source,
            message,
            file,
            line,
        }
    }

    pub fn message(&self) -> &String {
        &self.message
    }

    pub fn file(&self) -> &String {
        &self.file
    }

    pub fn line(&self) -> &u32 {
        &self.line
    }
}

impl Error for RisError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        self.source.as_ref().map(|e| e.as_ref())
    }
}

impl std::fmt::Display for RisError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if let Some(source) = self.source() {
            write!(f, "{}\n    ", source)?;
        }

        write!(f, "\"{}\", {}:{}", self.message, self.file, self.line)
    }
}

impl std::fmt::Debug for RisError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let source_string = match &self.source {
            Some(source) => format!("Some ({:?})", source),
            None => String::from("None"),
        };

        write!(
            f,
            "RisError {{inner: {}, message: {}, file: {}, line: {}}}",
            source_string, self.message, self.file, self.line
        )
    }
}

#[derive(Debug)]
pub struct OptionError;

impl Error for OptionError {}

impl std::fmt::Display for OptionError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Option was None")
    }
}

#[macro_export]
macro_rules! unroll {
    ($result:expr, $($arg:tt)*) => {
        match $result {
            Ok(value) => Ok(value),
            Err(error) => {
                let source: ris_util::error::SourceError = Some(std::sync::Arc::new(error));
                let message = format!($($arg)*);
                let file = String::from(file!());
                let line = line!();
                let result = ris_util::error::RisError::new(source, message, file, line);
                Err(result)
            }
        }
    };
}

#[macro_export]
macro_rules! unroll_option {
    ($result:expr, $($arg:tt)*) => {
        match $result {
            Some(value) => Ok(value),
            None => {
                let source: ris_util::error::SourceError = Some(std::sync::Arc::new(ris_util::error::OptionError));
                let message = format!($($arg)*);
                let file = String::from(file!());
                let line = line!();
                let result = ris_util::error::RisError::new(source, message, file, line);
                Err(result)
            },
        }
    };
}

#[macro_export]
macro_rules! new_err {
    ($($arg:tt)*) => {
        {
            let source: ris_util::error::SourceError = None;
            let message = format!($($arg)*);
            let file = String::from(file!());
            let line = line!();
            ris_util::error::RisError::new(source, message, file, line)
        }
    };
}

#[macro_export]
macro_rules! result_err {
    ($($arg:tt)*) => {
        {
            let result = ris_util::new_err!($($arg)*);
            Err(result)
        }
    };
}

Next Post: An even better Error Type

Programming · Feb 11th, 2024

Next Post: An even better Error Type


Programming · Feb 11th, 2024

Previous Post: Making videos is hard
More Programming related Posts