Another crazy idea I share with this website.

I was developing a game and an engine in Rust, so I was reading many articles, most of which criticize the ‘borrow checker’.

I know that Rust is a big agenda language, and the extreme ‘borrow checker’ shows that, but if it weren’t for the checker, Rust would be a straight-up better C++ for Game development, so I thought: “Why not just use unsafe?”, but the truth is: unsafe is not ergonomic, and so is Refcell<T> so after thinking for a bit, I came up with this pattern:

let mut enemies = if cfg!(debug_assertions) {
    // We use `expect()` in debug mode as a layer of safety in order
    // to detect any possibility of undefined bahavior.
    enemies.expect("*message*");
    } else {
    // SAFETY: The `if` statement (if self.body.overlaps...) must
    // run only once, and it is the only thing that can make
    // `self.enemies == None`.
    unsafe { enemies.unwrap_unchecked() }
};

You can also use the same pattern to create a RefCell<T> clone that only does its checks in ‘debug’ mode, but I didn’t test that; it’s too much of an investment until I get feedback for the idea.

This has several benefits:

1 - No performance drawbacks, the compiler optimizes away the if statement if opt-level is 1 or more. (source: Compiler Explorer)

2 - It’s as safe as expect() for all practical use cases, since you’ll run the game in debug mode 1000s of times, and you’ll know it doesn’t produce Undefined Behavior If it doesn’t crash.

You can also wrap it in a “safe” API for convenience:

// The 'U' stands for 'unsafe'.
pub trait UnwrapUExt {
    type Target;

    fn unwrap_u(self) -> Self::Target;
}

impl<T> UnwrapUExt for Option<T> {
    type Target = T;

    fn unwrap_u(self) -> Self::Target {
        if cfg!(debug_assertions) {
            self.unwrap()
        } else {
            unsafe { self.unwrap_unchecked() }
        }
    }
}

I imagine you can do many cool things with these probably-safe APIs, an example of which is macroquad’s possibly unsound usage of get_context() to acquire a static mut variable.

Game development is a risky business, and while borrow-checking by default is nice, just like immutability-by-default, we shouldn’t feel bad about disabling it, as forcing it upon ourselves is like forcing immutability, just like Haskell does, and while it has 100% side-effect safety, you don’t use much software that’s written in Haskell, do you?

Conclusion: we shouldn’t fear unsafe even when it’s probably unsafe, and we must remember that we’re programming a computer, a machine built upon chaotic mutable state, and that our languages are but an abstraction around assembly.

  • SavvyWolf
    link
    fedilink
    English
    151 month ago

    One thing I’ve noticed with Rust is that if you find yourself fighting with the borrow checker, that’s a sign that your codebase isn’t well structured.

    So I’m curious; what problem have you been trying to solve where the borrow checker has been this much of an obstacle? There might be a cleaner design for it.

    • @[email protected]
      link
      fedilink
      101 month ago

      I disagree. It’s a sign your code isn’t structured in a way that the borrow checker understands, but that is a subset of well-structured code.

      In other words, if your code nicely fits with the borrow checker then it’s likely well structured, but the inverse is not necessarily true.

      One thing I always run into is using lambdas to reduce code duplication within a function. For example writing a RLE encoder:

      fn encode(data: &[u8]) -> Vec<u8> {
        let mut out = Vec::new();
      
        let mut repeat_count = 0;
      
        let mut output_repeat = || {
           out.push(... repeat_count ...);
        };
      
        for d in data {
            output_repeat();
            ...
        }
        output_repeat();
        out
      }
      

      This is a pretty common pattern where you have a “pending” thing and need to resolve it in the loop and after the loop. In C++ you can easily use lambdas like this to avoid duplication.

      Doesn’t work in Rust though even though it’s totally fine, because the borrow checker isn’t smart enough. Instead I always end up defining inline functions and explicitly passing the parameters (&mut out) in. It’s much less ergonomic.

      (If anyone has any better ideas how to solve this problem btw I’m all ears - I’ve never heard anyone even mention this issue in Rust.)

  • @[email protected]
    link
    fedilink
    13
    edit-2
    1 month ago

    First of all, unsafe famously doesn’t disable the borrow checker, which is something any Rustacean would know, so your intro is a bit weird in that regard.

    And if you neither like the borrow checker, nor like unsafe rust as is, then why are you forcing yourself to use Rust at all. If you’re bored with C++, there are other of languages out there, a couple of which are even primarily developed by game developers, for game developers.

    The fact that you found a pattern that can be alternatively titled “A Generic Method For Introducing Heisenbugs In Rust”, and you are somehow excited about it, indicates that you probably should stop this endeavor.

    Generally speaking, I think the Rust community would benefit from making an announcement a long the lines of “If you’re a game developer, then we strongly advise you to become a Rustacean outside the field of game development first, before considering doing game development in Rust”.

  • @Giooschi
    link
    English
    121 month ago

    Have you actually measured a performance impact from RefCell checks?

  • lad
    link
    fedilink
    English
    930 days ago

    I just wanted to advice you against thinking that if there’s something in all cases you’ve tried, there’s something every time. When you put something in an optional and then unwrap, it’s okay because you can see that the value is there, but even then there are usually better ways to express that. When you expect that since you’ve run the code thousands of times and it didn’t break [in a way that you would notice, e.g. panic in another thread will only affect that thread] means that everything is fine, you may get weird bugs seemingly out of nowhere and will also need to test much more than strictly necessary.

    Regarding the borrow checker, it has limitations and there are improvements that I hope will some day find way into upstream, but most of the time it may be better to change the code flow to allow borrow checker to help with bugs, instead of throwing it away completely. The same goes for unsafe, as in most cases it’s better to not uphold invariants manually.

  • @calcopiritus
    link
    5
    edit-2
    1 month ago

    Instead of checking if debug assertions are disabled, you should use debug assertions, it would make the code much neater.

    If you wanna eliminate the borrow checker this way, I guess you could use raw pointers instead of references, and have debug assertions to check if those pointers are null. At that point you’d have a mix of C and Rust. The memory would be completely unsafe (you’d have to allocate mostly on the heap, and drop it manually), but you’d have rusts’s type system. You’d also lose a lot of ergonomics though.

    EDIT: just to be clear. I think completely disabling the borrow checker this way is absolutely nuts. Maybe you could have some raw pointers in troublesome locations (where RC/RefCell would have too big of a performance impact for a 144fps game). But most of the code should be borrow checked, since the borrow checker only gets in your way sometimes. It’s not like lifetimes go away with the borrow checker, you still have to think about lifetimes if you manually manage your memory.