Opinion: Rust code typically works once compiled, why?

Something's been nagging me for a while; I've developed in Scala for over 10 years. I'm okay at coding, typically following best practices in functional programming to a T, but sure I've written my fair share of code stink.

I remember when Scala first started making headlines people saying: it's so type-safe, once your code compiles you know it will work! The thinking is that, assuming you're literate enough to write code that does mostly what you intend, the compiler will ensure no bugs in your code - presuming most bugs can be caught with proper type enforcement at compile-time due to lazy human errors.

...but I wouldn't say that has always been my experience in Scala. Yet, having been using Rust for 3 years, it feels much more like a true statement but I can't explain why.

If I had to answer honestly, it's that I get this feeling like Scala is still inextricably tied to Java, and at the end of the day there's still weird runtime exceptions that can creep up. Plus I find myself wrapping things in Try constructs (or forget to when it should be...) whereas in Rust I've never even read about them! Instead in Rust I feel like the ? Result types are hermetically sealing my code from unforeseen runtime exceptions, but I can't really be certain of this though so far it proves to be true.

Am I close to a theoretical truth about Rust Result types being more resilient to extraneous runtime exceptions? Or am I just blinded by the light of Rust's hype and the placebo effect is making me think a Rust program is more resilient to catastrophic failures (due to not relying on complex garbage collectors, as much heap memory, etc)?

I love both languages still - this is not arguing against Scala. I just struggle sometimes to articulate my preferences for one language over the other; a lot of probably superstition is biasing me and I'd like to hear from others who like read about or play with both languages.

6 Likes

Are you maybe writing different kinds of programs for the two languages?

On one hand, I spent years writing an intentionally single-threaded server where correctness was critical. I felt what you were feeling -- that the type system had helped me to be confident about correctness.

OTOH, a colleague was working on Rust code which didn't work well because it is a server program with some poor locking and architecture choices.. There's still a lot that can go wrong in Rust especially when concurrency is involved. Rust definitely helps us reason about concurrency at a higher level (instead of worrying if there's hidden UB somewhere), but there's still a lot of design choices that the type system can't help with.

4 Likes

From my experience so far, while the type system helps, I think the overall "deal with it now" approach Rust has is what really makes that statement true. With results your API's are explicitly saying, this could go wrong, deal with it NOW.

6 Likes

The "if it compiles it probably works" does come in part from things like Result forcing you to acknowledge error cases. But it moreso comes from a culture of misuse-resistant API design and of "parsing instead of validating."

18 Likes

I forgot to include how in Scala you at times must handle NonFatal exceptions separately from Fatal exceptions (or suffer catastrophic consequences if you forget). But otherwise I just feel like Rust libraries generally do a good job providing interfaces that support Result types and it gives you confidence it will correctly handle errors at Runtime (this I trust is a cultural phenomenon as commented by @CAD97)

I've done a bit of locking and was quite pleased with it. In general tokio seems to offer pretty solid approaches to concurrency from what I've been exposed to. If I had to fault Rust it's the ginormous build sizes especially with certain libraries. The resulting executables are generally quite small of course.

I second this, the culture is a big part of this. Not only in API design, but also factors like URLO users promoting good practice instead of the more laissez-faire approach one can find in other communities.

Oh, and (mostly) forbidding shared mutability :sweat_smile:

13 Likes

I think that's the #1 reason.

As everyone knows there are only two hard things in computer science: cache invalidation, naming things and off-by-one errors.

And removal of shared mutability is the key which makes the cache invalidation problem more tractable.

Rust is not the first language which actually feels like “if it compiles it works”, but other languages of that group, like Haskell, solve the very same issue with huge hammer: complete removal of mutability.

This works, but requires quite a different mindset to create such programs. Rust does the same thing with a much smaller hammer.

Of course there are Rc and Arc and you can still tie your data structures into pretzel… but you have to work on that! In a languages with shared mutability by default it just happens naturally.

14 Likes

Agree, but I'd guesstimate Rust and Scala are just about neck-and-neck when it comes to successfully discouraging shared mutability.

How does it do that? Take something like that:

fn foo(x: &mut i32, y: &mut i32, …) {
   …
   x += 1;
   …
}

Forgetting that if you change x in there then y may be changed, too, is most common source of errors in practice. Rust forces developer to create an “ownership chain” which ensures that x and y are distinct in such function.

Even if you use Rc and RefCell to receive references to x and y to call such function… you would still be stopped, if not in compile-time, then in runtime.

What's the analogue in Scala, how does it work?

1 Like

Scala has no compile-time checks for mutability. The Scala community has adopted a more functional approach to the problem rather than dealing with compile-time checks.

1 Like

Well, isn't that the answer? Modern C++ also “adopted a more functional approach” with std::unique_ptr for ownership, std::move for move semantic, std::string_view for borrows, std::span for slices, and so on. And it even works! As long as everyone follows the rules… only that never happens. Everyone thinks s/he is just a tiny bit extra special.

There's big difference between “compiler makes you to X” and “community adopted X”.

P.S. Rust also relies on community to a very large extent since unsafe can bring the whole hours of cards tumbling down very easily. And this is constant source of worry for rustaceans and community does it's job when needed, but there's still big difference between “we have to be extra diligent when we look on small subset of the codebase” vs “we have to be extra diligent always”.

8 Likes

Yeah, I'd say the same.

A lot of the code I normally write used to be very bug-resistant by design (strongly typed, invalid states are unrepresentable, etc.) but the project I'm currently working on at work has lots of async and types that aren't always in a valid state and Arc<Dyn Trait> with interior mutability, and we're always debugging runtime errors.

The language gives you a lot of tools for building type-safe programs, but if you want to write Java it won't stop you.

7 Likes

Scala enforces that mutable variables be declared with var instead of val (immutable). I might be missing a subtlety here, but you would get a compile-time error if you mutated a val variable. All method variables are immutable. class constructors allow you to define mutable arguments, but that's something you need to explicitly define. Collections are immutable by default and require you to import a separate collection type for a mutable variant, e.g. ListBuffer (mutable) instead of List (immutable). So if you really wanted to pass a mutable variable it would need to be explicitly typed as such. Does that form a decent analog or does Rust go a step further?

Many modern languages work in exactly this way, having variables explicitly marked as either allowing or rejecting assignment, though fewer have separate mutable and immutable collection types.

The step further that Rust takes can be understood as: it takes the variable mutability idea and makes it transitive instead of local. If a variable is not mut then you can't assign it and also you can't modify the contents of the value it contains.

Ownership, reference mutability, and even references themselves can then be understood as just the things that were found to be necessary to enforce that rule while still writing practical programs.

And, as a side benefit of all this, we don't have to have separate immutable collection types, because each collection type can be either one.

(This is very much not the whole story about mutability and references in Rust; it doesn't explain interior mutability and the way it both augments and caveats the above story, it doesn't mention the thread-safety considerations, and it isn't the starting point from which Rust was actually designed; but I think it's a good perspective for comparison with your thoughts on Scala.)

7 Likes

That's not even remotely close to what Rust does. I don't know Scala but from your description what it does sounds roughly similar to what ANSI C did back in 1989: it marks variables as const which means your code couldn't change them… but some else still can. Like this:

int foo(int* x, const int* y) {
    int a = *y;
    *x = 42;
    return a == *y;
}

Naive reading of that code would say that a == *y check should always we true… and that's what you want, most of the time, too. But in C/C++/Java/JavaScript that's not guaranteed. The fact that something is unchangeable by you doesn't mean that someone else wouldn't change it.

The “mutable” vs “immutable” distinction is not what you really want, most of the time. Usually you want to change something in your program — but need to ensure that other components would react adequately to these changes.

That's where one of the “only two hard questions of computer science”, “cache invalidation” comes from.

It's not just about simple caches, it's, essentially “all the calculations made on basis of old information”… they have to be redone once the information is not current.

Essentially the software developer's work is an attempt to handle shared mutability, somehow. Think about it: this forum would be very sad, indeed, if there would be no shared mutability and thus there would be no for me to see your questions and for you to see my answers… but the mere fact that it's desired doesn't mean it's not dangerous, too: I can start writing my answer while you would, simultaneously, change the question (that's why forum keeps track of old versions) and so on.

Haskell handles shared mutability by moving all the mutability into IO monad and Rust handles shared mutability by asking you to use Mutexes, RwLocks and RefCells (which is, essentially, a single-thread RwLock).

Most other languages do what C have done: they allow you to mark certain variables as const or final and thus ensure that you can not change them by accident and that's good thing to do, but that's not even remotely close to what's needed. To be able to have that “if it compiles it works” property you need to ensure that other code couldn't do that, too! And yet you want to have shared mutability when your task needs it (again: think about how dull that forum would be without shared mutability).

The property you usually desire is, actually, the opposite: if you have reference to immutable variable you want to make sure it can not be changed under you. And the C-style approach to mutability doesn't give you such guarantees while Rust, very explicitly, makes them. Even interior mutability doesn't give you the ability to pass normal, &i32 reference somewhere which may change like y in the example above. It have to be special type like &RefCell<i32> if you want or need shared mutability.

What fascinates me about Rust is the fact that so many it's properties are extremely simple (at least conceptually) and logical and make perfect sense… and yet when you ask people who invented them… they did them for entirely different reasons.

It's really fascinating how many times Rust developers did the right thing for the wrong reason… of course nobody knew the “right reason” before the decision was made…

5 Likes

To give some actual examples:

You can't mutate it directly, but you can easily do this indirectly:

class Foo(var i: Int)
val a = Foo(0)
a.i += 1
println(a.i) // prints 1, so `a` changed despite being a val!

And yes, I could make i a val, but what if I want to mutate it somewhere else? There's no way to say "allow mutating i only if the binding is a var".

Moreover Rust goes even further, consider this snippet of Scala code:

class Foo(var i: Int)
  
val a = Foo(0)
val b = a
a.i += 1
println(b.i) // prints 1 even though we never mutated `b` directly!

Now b is not even being touched, but it is being mutated! Moreover even if we learned from the previous example and required a to be var in order to be mutated, b is never explicitly mutated and so can continue to remain just a val. In Rust this is illegal unless you explicitly opted into shared mutation by using a Cell/RefCell/Mutex (in which case it would be clear the value could be changed under your feet).

3 Likes

I strongly agree!

Three things come to mind...

  1. The expressive type system and its ease of use. If I need to have read-write configuration at startup which becomes read-only when the application switches to doing actual work, not only can I do that but Rust makes it very easy to do. I stopped worrying about unwanted side effects when coding in Rust because there is so much control over what side effects are allowed and when they're allowed.

  2. Decomposition / re-composition. Rust makes it easy to decompose a complex thing into smaller simpler things then piece those smaller things back into complex large things. Smaller things are so much easier to test. I've lost so much hair trying to do that with object oriented languages.

  3. Test driven development. Rust (Cargo) makes is so easy to build tests in parallel that it's just silly not to do that.

5 Likes

I have been writing scala for more than 6+ years never seen anyone/code base using var and this thread is about how you can do this and to that and break the immutability using var…very interesting.

IIRC, Rust pre 1.0 was debating calling mut references "uniq", as in they are the only reference to this value. That probably would have slightly increased early confusion, for slightly less mid-term confusion. After all, you call always use interior mutability with e.g. RefCell.

2 Likes

Ironically, one of my most unpleasant impressions with Rust is that, since the compiler is so good at catching bugs, bugs that the compiler doesn't catch always end up to be very hard to fix, this giving me the constant impression that Rust is harder to write. Because indeed, all debugging time is spent on the hard stuff instead of the easy trivial mistakes.

1 Like