this post was submitted on 17 Aug 2024
7 points (70.6% liked)

Rust

6041 readers
3 users here now

Welcome to the Rust community! This is a place to discuss about the Rust programming language.

Wormhole

!performance@programming.dev

Credits

  • The icon is a modified version of the official rust logo (changing the colors to a gradient and black background)

founded 1 year ago
MODERATORS
 

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.

you are viewing a single comment's thread
view the rest of the comments
[–] savvywolf@pawb.social 15 points 3 months ago (15 children)

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.

[–] FizzyOrange@programming.dev 10 points 3 months ago (14 children)

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.)

[–] fil@hachyderm.io 2 points 3 months ago* (last edited 3 months ago) (1 children)
[–] FizzyOrange@programming.dev 5 points 3 months ago (3 children)

Sorry that example was a bit too limited to demonstrate the problem actually. Add a second lambda and you hit the issue:

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=eb99d3d670bdd9d92006f4672444d611

Still totally fine from a safety point of view, but the borrow checker can't figure that out.

[–] Jenztsch@discuss.tchncs.de 6 points 3 months ago* (last edited 3 months ago) (1 children)

This is entering subjective taste, but in my opinion this also is a feature of Rust. Especially when the closures are more complicated, it may be not as obvious if and where they are changing state (the fact that Rust implicitely mutably borrows the variables to the closures doesn't help either).

So a solution of this issue for me would be to add the changed variables as parameters to the closures and explicitely mutably borrow them at the calls in the loop: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=78cc7947e2e0b07b54baf6e1a75a2632

I would agree with you that this increases code verbosity. However, this would be a price I'm willing to pay to help understand at quicker glances what happens inside this loop.

[–] zshift@hachyderm.io 2 points 3 months ago (2 children)

@Jenztsch @FizzyOrange it would be nice if rust had a feature like inline macros for this kind of behavior just for reducing duplications when you don’t need to capture values as part of a closure.

[–] zshift@hachyderm.io 2 points 3 months ago* (last edited 3 months ago) (1 children)

@Jenztsch @FizzyOrange

Eg

fn foo() -\> Vec\<i32\> {  
 let mut out = Vec::new();  
 macro! bar(i: i32) {  
 out.push(1);  
 }

 for i in 1..10 {  
 bar!(i);  
 }

 out  
}  
[–] Jenztsch@discuss.tchncs.de 3 points 3 months ago (1 children)

I'm not sure how you intend to use this. When no variables are captured, the borrow checker will not have any issues with the closure method.

When you are still capturing, you could implement a macro like one answer suggests. However, IMO this highly depends on the complexity of the duplicated code and even then I don't immediately see what the benefits compared to extracting it as a closure/function are.

[–] FizzyOrange@programming.dev 1 points 3 months ago (1 children)

The benefits are that you don't have to pass out and similar captured variables into the closures/functions.

[–] Jenztsch@discuss.tchncs.de 1 points 3 months ago (1 children)

Then I think RefCell is exactly what you want to defer the mutable borrow to runtime instead of compile time: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=3170c01849dc577a501ecb11dd44c5ba (source for this method on StackOverflow).

Maybe there could be syntactic sugar to use captures implicitely as RefCells inside a closure. But I would not be a fan of implicitely allowing multiple mutable borrows without any clue for that in the code.

[–] FizzyOrange@programming.dev 1 points 3 months ago

Not really because RefCell has performance implications and also adds noise to the code.

But I would not be a fan of implicitely allowing multiple mutable borrows without any clue for that in the code.

Nobody is suggesting breaking Rust's multiple mutable borrow restriction. The macro solution simply doesn't do that, and the "make the borrow checker smarter" solution just releases the mutable borrows when they aren't being used so they don't overlap.

[–] FizzyOrange@programming.dev 1 points 3 months ago

I dunno, does it even need a new feature? Kind of feels like Rust should be able to figure it out as long as the lambdas aren't moved/stored/etc?

[–] BB_C@programming.dev 2 points 3 months ago* (last edited 3 months ago) (1 children)

Is this crazy?

A general repeat macro that works on stable Rust would work too of course.

[–] FizzyOrange@programming.dev 2 points 3 months ago (1 children)

Yeah that's pretty unreadable IMO. I think your second link isn't what you intended?

[–] BB_C@programming.dev 2 points 3 months ago

I think your second link isn’t what you intended?

You scared me for a moment there. I don't know why you thought that.

Needless to say, even with the first example, metavar expressions are not strictly needed here, as using a second pattern and recursing expansions would work.

But I wanted to showcase the power of ${ignore}, as it can be cleaner and/or more powerful in some cases where extra patterns and recursing expansions can get messy and hard to track.

[–] fil@hachyderm.io 1 points 3 months ago
load more comments (12 replies)
load more comments (12 replies)