Readit News logoReadit News
xdfgh1112 · 9 months ago
A pattern in these is code that compiles until you change a small thing. A closure that works until you capture a variable, or code that works in the main thread but not in a separate thread. Or works until you move the code into an if/else block.

My experience with Rust is like this: make a seemingly small change, it balloons into a compile error that requires large refactoring to appease the borrow checker and type system. I suppose if you repeat this enough you learn how to write code that Rust is happy with first-time. I think my brain just doesn't like Rust.

stouset · 9 months ago
Your supposition about Rust is correct.

I’ll add that—having paid that upfront cost—I am happily reaping the rewards even when I write code in other languages. It turns out the way that Rust “wants” to be written is overall a pretty good way for you to organize the relationships between parts of a program. And even though the borrow checker isn’t there looking out for you in other languages, you can code as if it is!

tonyarkles · 9 months ago
I had a similar experience with Erlang/Elixir. The primary codebase I work with in $DAYJOB is C++ but structured very OTP-like with message passing and threads that can crash (via exceptions, we’re not catching defaults for example) and restart themselves.

Because of the way we’ve set up the message passing and by ensuring that we don’t share other memory between threads we’ve virtually eliminated most classes of concurrency bugs.

saghm · 9 months ago
I'm fortunate enough not to have to often write code in other languages anymore, but my experience that writing code in ways that satisfies the compiler actually ends up being code I prefer anyhow. I was somewhat surprised at the first example because I haven't run into something like that, but it's also not really the style I would write that function personally (I'm not a big fan of repetitions like having `Some(x)` repeated both as a capture pattern and a return value), so on a whim I tried what would have been the way I'd write that function, and it doesn't trigger the same error:

    fn double_lookup_mut(map: &mut HashMap<String, String>, mut k: String) -> Option<&mut String> {
        map.get_mut(&k)?;
        k.push_str("-default");
        map.get_mut(&k)
    }
I wouldn't have guessed that this happened to be a way around a compiler error that people might run into with other ways of writing it; it just genuinely feels like a cleaner way for me to implement a function like that.

farresito · 9 months ago
As someone who is interested in getting more serious with Rust, could you explain the essence of how you should always approach organizing code in Rust as to minimize refactors as the code grows?
ajross · 9 months ago
> It turns out the way that Rust “wants” to be written is overall a pretty good way for you to organize the relationships between parts of a program

That's what it promised not to do, though! Zero cost abstractions aren't zero cost when they force you into a particular design. Several of the cases in the linked article involve actual runtime and code size overhead vs. the obvious legacy/unchecked idioms.

ajb · 9 months ago
It sounds like the ideal, then, would be to detect the problematic patterns earlier so people wouldn't need to bang their heads against it.
kazinator · 9 months ago
Why would you cling to some cockamamie memory management model, where it is not required or enforced?

That's like Stockholm Syndrome.

resonious · 9 months ago
Maybe I'm just brainwashed, but most of the time for me, these "forced refactors" are actually a good thing in the long run.

The thing is, you can almost always weasel your way around the borrow checker with some unsafe blocks and pointers. I tend to do so pretty regularly when prototyping. And then I'll often keep the weasel code around for longer than I should (as you do), and almost every time it causes a very subtle, hard-to-figure-out bug.

twic · 9 months ago
I think the problem isn't that the forced changes are bad, it's that they're lumpy. If you're doing incremental development, you want to be able to to quickly make a long sequence of small changes. If some of those changes randomly require you to turn your program inside-out, then incremental development becomes painful.

Some people say that after a while, they learn how to structure their program from the start so that these changes do not become necessary. But that is also partly giving up incremental development.

joshka · 9 months ago
Rust definitely forces you to make more deliberate changes in your design. It took me about 6 months to get past hitting that regularly. Once you do get past it, rust is awesome though.
brabel · 9 months ago
I suppose you haven't had to refactor a large code base yet just because a lifetime has to change?
amelius · 9 months ago
> I suppose if you repeat this enough you learn how to write code that Rust is happy with first-time.

But this assumes that your specifications do not change.

Which we know couldn't be further from the truth in the real world.

Perhaps it's just me, but a language where you can never change your mind about something is __not__ a fun language.

Also, my manager won't accept it if I tell him that he can't change the specs.

Maybe Rust is not for me ...

stouset · 9 months ago
I genuinely don’t know where you’ve gotten the idea that you can “never change your mind” about anything.

I have changed my mind plenty of times about my Rust programs, both in design and implementation. And the language does a damn good job of holding my hand through the process. I have chosen to go through both huge API redesigns and large refactors of internals and had everything “just work”. It’s really nice.

If Rust were like you think it is, you’re right, it wouldn’t be enjoyable to use. Thankfully it is nothing like that.

stephenbennyhat · 9 months ago
"Malum est consilium, quod mutari non potest" you might say.
oconnor663 · 9 months ago
My recommendation is that you do whatever you feel like with ownership when you first write the code, but then if something forces you to come back and change how ownership works, seriously consider switching to https://jacko.io/object_soup.html.
perrygeo · 9 months ago
> make a seemingly small change, it balloons into a compile error that requires large refactoring to appease the borrow checker and type system

Same experience, but this is actually why I like Rust. In other languages, the same seemingly small change could result in runtime bugs or undefined behavior. After a little thought, it's always obvious that the Rust compiler is 100% correct - it's not a small change after all! And Rust helpfully guides me through its logic and won't let my mistake slide. Thanks!

rendaw · 9 months ago
Yo, everyone's interpreting parent's comment in the worst way possible: assuming they're trying to do unsound refactorings. There are plenty of places where a refactoring is fine, but the rust analyzer simply can't verify the change (async `FnOnce` for instance) gives up and forces the user to work around it.

I love Rust (comparatively) but yes, this is a thing, and it's bad.

joshka · 9 months ago
Yeah, Rust-analyzer's palette of refactorings is woefully underpowered in comparison to other languages / tooling I've used (e.g. Resharper, IntelliJ). There's a pretty high complexity bar to implementing these too unfortunately. I say this as someone that has contributed to RA and who will contribute more in the future.
alfiedotwtf · 9 months ago
I don’t know anyone who has gotten Rust first time around. It’s a new paradigm of thinking, so take your time, experiment, and keep at it. Eventually it will just click and you’ll be back to having typos in syntax overtake borrow checker issues
summerlight · 9 months ago
This is because programming is not a work in a continuous solution space. Think in this way; you're almost guaranteed to introduce obvious bugs by randomly changing just a single bit/token. Assembler, compiler, stronger type system, etc etc all try to limit this by bringing a different view that is more coherent to human reasoning. But computation has an inherently emergent property which is hard to predict/prove at compile time (see Rice's theorem), so if you want safety guarantee by construction then this discreteness has to be much more visible.
ajross · 9 months ago
> A pattern in these is code that compiles until you change a small thing.

I think that's a downstream result of the bigger problem with the borrow checker: nothing is actually specified. In most of the issues here, the changed "small thing" is a change in control flow that is (1) obviously correct to a human reader (or author) but (2) undetectable by the checker because of some quirk of its implementation.

Rust set out too lofty a goal: the borrow checker is supposed to be able to prove correct code correct, despite that being a mathematically undecidable problem. So it fails, inevitably. And worse, the community (this article too) regards those little glitches as "just bugs". So we're treated to an endless parade of updates and enhancements and new syntax trying to push the walls of the language out further into the infinite undecidable wilderness.

I've mostly given up on Rust at this point. I was always a skeptic, but... it's gone too far at this point, and the culture of "Just One More Syntax Rule" is too entrenched.

redman25 · 9 months ago
If you are trying overly hard to abstract things or work against that language, then yes, things can be difficult to refactor. Here's a few things I've found:

- Generics

- Too much Send + Sync

- Trying too hard to avoid cloning

- Writing code in an object oriented way instead of a data oriented way

Most of these have to do with optimizing too early. It's better to leave the more complex stuff to library authors or wait until your data model has settled.

nostradumbasp · 9 months ago
"Trying too hard to avoid cloning"

This is the issue I see a certain type of new rustaceans struggle with. People get so used to being able to chuck references around without thinking about what might actually be happening at run-time. They don't realize that they can clone, and even clone more than what might "look good", and that it is super reasonable to intentionally make a clone, and still get incredibly acceptable performance.

"Writing code in an object oriented way instead of a data oriented way" The enterprise OOP style code habits also seem to be a struggle for some but usually ends up really liberating people to think about what their application is actually doing instead of focusing on "what is the language to describe what we want it to do".

IshKebab · 9 months ago
Yeah I think this becomes more true the closer your type system gets to "formal verification" type systems. It's essentially trying to prove some fact, and a single mistake anywhere means it will say no. The error messages also get worse the further along that scale you go (Prolog is infamous).

Not really unique to Rust though; I imagine you would have the same experience with e.g. Lean. I have a similar experience with a niche language I use that has dependent types. Kind of a puzzle almost.

It is more work, but you get lots of rewards in return (including less work overall in the long term). Ask me how much time I've spent debugging segfaults in C++ and Rust...

binary132 · 9 months ago
That’s not what OP is discussing. OP is discussing corner cases in Rust’s typesystem that would be sound if the typesystem were more sophisticated, but are rejected because Rust’s type analysis is insufficiently specific and rejects blanket classes of problems that have possible valid solutions, but would need deeper flow analysis, etc.
ykonstant · 9 months ago
Lean is far more punishing even for simple imperative code. The following is rejected:

  /- Return the array of forward differences between consecutive
     elements of the input. Return the empty array if the input
     is empty or a singleton.
  -/

  def diffs (numbers : Array Int) : Array Int := Id.run do
    if size_ok : numbers.size > 1 then
      let mut diffs := Array.mkEmpty (numbers.size - 1)
      for index_range : i in [0:numbers.size - 2] do
        diffs := diffs.push (numbers[i+1] - numbers[i])
      return diffs
    else
      return #[]

Deleted Comment

QuadDamaged · 9 months ago
When this happens to me, it’s mostly because my code is written with too coarse separation of concerns, or I am just mixing layers
dinosaurdynasty · 9 months ago
And in C++, those changes would likely shoot yourself in the foot without warning. The borrow checker isn't some new weird thing, it's a reification of the rules you need to follow to not end up with obnoxious hard to debug memory/threading issues.

But yeah, as awesome as Rust is in many ways it's not really specialized to be a "default application programming language" as it is a systems language, or a language for thorny things that need to work, as opposed to "work most of the time".

cogman10 · 9 months ago
C++ allows both more incorrect and correct programs. That's what can be a little frustrating about the BC. There are correct programs which the BC will block and that can feel somewhat limiting.
Animats · 9 months ago
My big complaint about Rust's borrow checking is that back references need to be handled at compile time, somehow.

A common workaround is to put items in a Vec and pass indices around. This doesn't fix the problem. It just escapes lifetime management. Lifetime errors then turn into index errors, referencing the wrong object. I've seen this three times in Rust graphics libraries. Using this approach means writing a reliable storage allocator to allocate array slots. Ad-hoc storage allocators are often not very good.

I'm currently fixing some indexed table code like that in a library crate. It crashes about once an hour, and has been doing that for four years now. I found the bug, and now I have to come up with a conceptually sound fix, which turns out to be a sizable job. This is Not Fun.

Another workaround is Arc<Mutex<Thing>> everywhere. This can result in deadlocks and memory leaks due to circularity. Using strong links forward and weak links back works better, but there's a lot of reference counting going on. For the non-threaded case, Rc<RefCell<Thing>>, with .borrow() and .borrow_mut(), it looks possible to do that analysis at compile time. But that would take extensions to the borrow checker. The general idea is that if the scope of .borrow() results of the same object don't nest, they're safe. This requires looking down the call chain, which is often possible to do statically. Especially if .borrow() result scopes are made as small as possible. The main objection to this is that checking may have to be done after expanding generics, which Rust does not currently do. Also, it's not clear how to extend this to the Arc multi-threaded case.

Then there are unsafe approaches. The "I'm so cool I don't have to write safe code" crowd. Their code tends to be mentioned in bug reports.

saurik · 9 months ago
> A common workaround is to put items in a Vec and pass indices around. This doesn't fix the problem. It just escapes lifetime management. Lifetime errors then turn into index errors, referencing the wrong object.

That people seriously are doing this is so depressing... if you build what amounts to a VM inside of a safe language so you can do unsafe things, you have at best undermined the point of the safe language and at worse disproved the safe language is sufficient.

Animats · 9 months ago
That's a good way to put it. I'll keep that in mind when trying to convince the Rust devs.
edflsafoiewq · 9 months ago
This is a common pattern everywhere, not just in Rust. Indices, unlike pointers to elements, survive a vector reallocation or serialization to disk. IDs are used to reference items in an SQL database, etc.
imtringued · 9 months ago
I hope you realize that index buffers are an industry standard in graphics APIs.
recursivecaveat · 9 months ago
Yes the "fake pointer" pattern is a key survival strategy. Another one I use often is the command pattern. You borrow a struct to grab some piece of data, based on it you want to modify some other piece of the struct, but you can't because you have that first immutable borrow still. So you return a command object that expresses the mutation you want, back up the call stack until you're free to acquire a mutable reference and execute the mutation as the command instructs. Very verbose to use frequently, but often good for overall structure for key elements.
Animats · 9 months ago
Yes. Workarounds in this area exist, but they are all major headaches.
lalaithion · 9 months ago
Animats · 9 months ago
Neat. It's still run-time checking. A good idea, though. The one-owner, N users case is common. The trick is checking that the users don't outlive the owner.
aw1621107 · 9 months ago
At least based on the comments on lobste.rs [0] and /r/rust, these seem to be actively worked on and/or will be solved Soon (TM):

1. Checking does not take match and return into account: I think this should be addressed by Polonius? https://rust.godbolt.org/z/8axYEov6E

2. Being async is suffering: I think this is addressed by async closures, due to be stabilized in Rust 2024/Rust 1.85: https://rust.godbolt.org/z/9MWr6Y1Kz

3. FnMut does not allow reborrowing of captures: I think this is also addressed by async closures: https://rust.godbolt.org/z/351Kv3hWM

4. Send checker is not control flow aware: There seems to be (somewhat) active work to address this? No idea if there are major roadblocks, though. https://github.com/rust-lang/rust/pull/128846

[0]: https://lobste.rs/s/4mjnvk/four_limitations_rust_s_borrow_ch...

[1]: https://old.reddit.com/r/rust/comments/1hjo0ds/four_limitati...

DylanSp · 9 months ago
Case 1 is definitely addressed by the Polonius-related work. There's a post [1] on the official Rust blog from 2023 about that, and this post [2] from Niko Matsakis' blog in June 2024 mentions that they were making progress on it, though the timeline has stretched out.

[1]: https://blog.rust-lang.org/inside-rust/2023/10/06/polonius-u...

[2]: https://smallcultfollowing.com/babysteps/blog/2024/06/02/the...

dccsillag · 9 months ago
(Side note) That's odd, lobste.rs seems to be down for me, and has been like that for a couple of months now -- I literally cannot reach the site.

Is that actually just me??

EDIT: just tried some things, very weird stuff: curl works fine. Firefox works fine. But my usual browser, Brave, does not, and complains that "This site can't be reached (ERR_INVALID_RESPONSE)". Very very very weird, anyone else going through this?

yurivish · 9 months ago
rascul · 9 months ago
Seems like lobste.rs might be blocking Brave.

https://news.ycombinator.com/item?id=42353473

porridgeraisin · 9 months ago
They are throwing a bit of a hissy fit over brave. Change the user agent or something and view the site.
Permik · 9 months ago
Hello from Finland, I can reach the site all fine. Hope you get your connection issues sorted :)
joshka · 9 months ago
One approach to solving item 1 is to think about the default as not being a separate key to the HashMap, but being a part of the value for that key, which allows you to model this a little more explicitly:

    struct WithDefault<T> {
        value: Option<T>,
        default: Option<T>,
    }

    struct DefaultMap<K, V> {
        map: HashMap<K, WithDefault<V>>,
    }

    impl<K: Eq + Hash, V> DefaultMap<K, V> {
        fn get_mut(&mut self, key: &K) -> Option<&mut V> {
            let item = self.map.get_mut(key)?;
            item.value.as_mut().or_else(|| item.default.as_mut())
        }
    }
Obviously this isn't a generic solution to splitting borrows though (which is covered in https://doc.rust-lang.org/nomicon/borrow-splitting.html)

twic · 9 months ago
The article makes the 'default' key with push_str("-default"), and given that, your approach should work. But i think that's a placeholder, and a bit of an odd one - i think it's more likely to see something like (pardon my rusty Rust) k = if let Some((head, _)) = k.split_once("_") { head.to_owned() } else { k } - so for example a lookup for "es_MX" will default to "es". I don't think your approach helps there.
joshka · 9 months ago
Yeah, true. But that (assuming you're saying give me es_MX if it exists otherwise es) has a similar possible solution. Model your Language and variants hierarchically rather than flat. So languages.get("es_MX") becomes

    let language = languages.get_language("es");
    let variant = language.get_variant("MX");
There's probably other more general ideas where this can't be fixed (but there's some internal changes to the rules mentioned in other parts of this thread somewhere on (here/reddit/lobsters).

Dead Comment

rolandrodriguez · 9 months ago
I didn’t get past the first limitation before my brain started itching.

Wouldn’t the approach there be to avoid mutating the same string (and thus reborrowing Map) in the first place? I’m likely missing something from the use case but why wouldn’t this work?

    // Construct the fallback key separately
    let fallback = format!("{k}-default");

    // Use or_else() to avoid a second explicit `if map.contains_key(...)`
    map.get_mut(k)
       .or_else(|| map.get_mut(&fallback))

css · 9 months ago
josephcsible · 9 months ago
I see how that helps with the usual case of inserting a value under the original key if it wasn't there, but I don't see how it helps in this case of checking a different key entirely if it wasn't there.
josephcsible · 9 months ago
It definitely needs get_mut(k) changed to get_mut(&k), but even after doing that, it still fails to compile, with an error similar to the one the original code gets.
aoeusnth1 · 9 months ago
This creates the fallback before knowing that you’ll need it.
mrcsd · 9 months ago
Not necessarily. Since the argument to `.or_else` is a function, the fallback value can be lazily evaluated.
Sytten · 9 months ago
I am pretty sure the example 2 doesn't work because of the move and should be fixed in the next release when async closure are stabilized (I am soooo looking forward to that one).
germandiago · 9 months ago
The limitation is the borrow checker itself. I think it restricts too much. clang implements lifetimebound, for example, which is not viral all the way down and solves some typical use cases.

I find that relying on values and restricted references and when not able to do it, in smart pointers, is a good trade-off.

Namely, I find the borrow-checker too restrictive given there are alternatives, even if not zero cost in theory. After all, the 80/20 rule helps here also.

pornel · 9 months ago
A borrow checker that isn't "viral all the way down" allows use-after-free bugs. Pointers don't stop being dangling just because they're stashed in a deeply nested data structure or passed down in a way that [[lifetimebound]] misses. If a pointer has a lifetime limited to a fixed scope, that limit has to follow it everywhere.

The borrow checker is fine. I usually see novice Rust users create a "viral" mess for themselves by confusing Rust references with general-purpose pointers or reference types in GC languages.

The worst case of that mistake is putting temporary references in structs, like `struct Person<'a>`. This feature is incredibly misunderstood. I've heard people insist it is necessary for performance, even when their code actually returned an address of a local variable (which is a bug in C and C++ too).

People want to avoid copying, so they try to store data "by reference", but Rust's references don't do that! They exist to forbid storing data. Rust has other reference types (smart pointers) like Box and Arc that exist to store by reference, and can be moved to avoid copying.

germandiago · 9 months ago
> Pointers don't stop being dangling just because they're stashed in a deeply nested data structure or passed down in a way that [[lifetimebound]] misses

This is the typical conversation where it is shown what Rust can do by shoehorning: if you want to borrow-borrow-borrow from this data structure and reference-reference-reference from this function, then you need me.

Yes, yes, I know. You can also litter programs with globals if you want. Just avoid those bad practices. FWIW, references break local reasoning in lots of scenarios. But if you really, really need that borrowing, limit it to the maximum and make good use of smart pointers when needed. And you will not have this problem.

It looks to me like Rust sometimes it is a language looking for problems to give you the solution. There are patterns that are just bad or not adviced in most of your code and hence, not a problem in practice. If you code by referencing everything, then Rust borrow-checker might be great. But your program will be a salad of references all around, which is bad in itself. And do not get me started in the refactorings you will need every time you change your mind about a reference deep somewhere. Bc Rust is great, yes, you can do that cool thing. But at what cost? Is it even worth?

I also see all the time people showing off the Send+Sync traits. Yes, very nice, very nice. Magic abilities. And what? I do my concurrent code by sharing as little as possible all the time. So the patterns of code where things can be messed up are quite localized.

Because of this, the borrow checker is basically something that gets a lot in the way but does not add a lot of value. It might have its value in hyper-restricted scenarios where you really need it, and I cannot think of a single scenario where that would be really mandatory and really useful for safety except probably async programming (for which you can do structured concurrency and async scopes still in C++ and I did it successfully myself).

So no, I would say the borrow checker is a solution looking for problems because it promotes programming styles that are not clean from the get go. And only in this style it is where the borrow checker shines actually.

Usually the places where the borrow checker is useful has alternative coding patterns or lifetime techniques and for the few ones where you really want something like that, probably the code spots are small and reviewable anyway.

Also, remember that Rust gives you safety from interfaces when you use libraries, except when not, bc it basically hides unsafe underneath and that makes it as dangerous as any C or C++ code (in theory). However, it should be easier to spot the problems which leads more safety in practice. But still, this is not guaranteed safety.

The borrow checker is a big toll in my opinion and it promotes ways of coding that are very unergonomic by default. I'd rather take something like Swift or even Hylo any day, if it ever reaches maturity.

nostradumbasp · 9 months ago
That is your personal decision to make. I would say this, despite this article pointing out 4 issues with certain design patterns there is a lot of good commercial software being written in the language every single day.

My personal experience has been one where after I spent the time learning Rust I am now able to write correct code much faster than GC based languages. Its pretty close to replacing python for me these days. I am also very grateful to not deal with null/nil ever again, error handling is so convenient, simple 1 offs run super fast and don't need me to go back to fix perf/re-write, and my code is way easier to read.

To each their own, but I wouldn't let a niche technical articles sway you from considering Rust as something to learn, use, and enjoy.

germandiago · 9 months ago
My opinion about Rust is not leaning on this article actually. I might need to give it a bigger try to see how it feels but I am pretty sure I am not going to ever like so heavy lifetime annotations for a ton of reasons.

I think a subset of the borrow checking with almost no annotations and promoting other coding patterns would make me happier.

pjmlp · 9 months ago
And full of gotchas, which remain to be seen if they will ever be fixed, it has hardly been updated after the initial POC implementation.
lumost · 9 months ago
Using value types for complex objects will reck performance. Why not just use a GCd language at that point?
germandiago · 9 months ago
You usually pass around bigger types through the heap internally encapsulated in an object with RAII in C++, for example. I do not think this is low-perf per se.
CyberDildonics · 9 months ago
This is not true, the heavy data will be on the heap and you can move the values around. It actually works out very well.
mjburgess · 9 months ago
Given the amount of cloning and Arc's in typical Rust code, it just seems to be an exercise in writing illegible Go.
the__alchemist · 9 months ago
The one I run into most frequently: Passing field A mutably, and field B immutably, from the same struct to a function. The naive fix is, unfortunately, a clone. There are usually other ways as well that result in verbosity.
pornel · 9 months ago
The problem is typically caused by &mut self methods exclusively borrowing all of self.

I wish this not-a-proposal turned into a real proposal:

https://smallcultfollowing.com/babysteps//blog/2021/11/05/vi...

bionhoward · 9 months ago
Could you change the function to accept the whole struct and make it mutate itself internally without external mutable references?
the__alchemist · 9 months ago
Yes. Note that this requires a broader restructure that may make the function unusable in other contexts.
pitaj · 9 months ago
estebank · 9 months ago
The borrow checker is smart enough to track disjointed field borrows individually and detect that's fine, but if you have two methods that return borrows to a single field, there's no way of communicating to the compiler that it's not borrowing the entire struct. This is called "partial borrows", the syntax is not decided, and would likely only work on methods of the type itself and not traits (because trait analysis doesn't need to account for which impl you're looking at, and partial borrows would break that).

The solution today is to either change the logic to keep the disjointed access in one method, provide a method that returns a tuple of sub-borrows, have a method that takes the fields as arguments, or use internal mutability.

duped · 9 months ago
The fix is destructuring