Hacker News new | past | comments | ask | show | jobs | submit login
Notes on a Smaller Rust (boats.gitlab.io)
103 points by telotortium 89 days ago | hide | past | web | favorite | 116 comments

As someone who recognizes himself as beginner programmer, i do not consider the borrow checker as my major issue with the language. Heck, i haven't even reached that issue.

Everytime that I have turned away from Rust, despite trying half a dozen times is due to its super busy and alien looking syntax. I just can't parse the code.

Honestly, I don't see any intrinsic problems with Rust's syntax that makes it unusually ugly.

Rather, I think the problem is that the syntax is 100% fine in isolation, but has some issues when taken together with experience in other languages — when you skim the code, some parts of it beg to be looked at like C++, while other parts push you to read it like a functional language. Unsurprisingly, neither fully works, which makes the experience jarring. Once you accept that Rust is its own language with its own rules and stop trying to borrow (hah!) from your experience with other languages, it feels much better.

> Honestly, I don't see any intrinsic problems with Rust's syntax that makes it unusually ugly.

You literally just got a user experience report claiming that it is.

It's coming from someone who isn't comfortable with C or Java either.

There's no winning over everyone. Of course Rust's syntax will be completely jarring if you're coming from Crystal or Ruby.

In my personal experience it's a little jarring at first and then it completely goes away.

I’m comfortable with C/++ and Java, to a greater extent than a lot of people I’ve worked with. There are still things in Rust that are papercuts:

* The need to put :: before <> in expressions for generic parameters, but not in type signatures.

* Using :: instead of just . even though most of the time the naming convention will delineate the difference anyway

* Error handling with futures

* Self-referential structs

* Passing a shared pointer through multiple move closures

IMHO the frustration involved in these things is not typically worth being more explicit. The :: thing may seem a bit pedantic, but it is simply more physical effort (Shift-colon-colon vs dot) and creates more visual noise.

If/when all of the things above are addressed, I think Rust could give Python a run for its money.

Oh yeah, I totally get that with Nim's * (export symbol), just can't get past my 20 years ago c++ pointer syntax brain wiring, even though I've barely touched it since. Always needs a double-take, which spoils reading flow.

Agree and also I think the main issue is that parts of the syntax are pointing to ideas that are not present in common languages, but which are essential in Rust. They aren't merely syntactical choices and can't be papered over or omitted. If you don't want to consider these concerns (like lifetimes) then it's probably not the language for you and changes in syntax won't help (much) there.

I had many many false starts back in 2014 when I tried learning Rust. It took me to stop comparing it or thinking about it like C++ (syntactically and semantically) in order for me to get it. Now, I’d consider myself reasonably fluent in Rust.

I don’t mind the syntax because I find it explicit enough, but terse enough in certain cases. It’s similar but different and so it rhymes with my past in C++, but it’s not the same.

What other languages do you have experience with? IMO the syntax is not wildly different from other statically typed curly bracket languages. IMO the most alien thing it has in terms of syntax is the use of a single quote for lifetime parameters ('a), but even that is based on the type parameter syntax for already existing languages (OCaml, F#, Standard ML).

Crystal and vanilla es6 are my favoured languages. I also adore elixir. I have recently done some toy projects in f# using xelmish. I have written small projects in python, nodejs and lua. I have also tried Nim and wren. So while i am not much of a programmer, I love trying new languages. Out of all the languages that I tried, Rust and lisp are the two which scared me off outright just based on the syntax

I don't see where you're coming from honestly. It has familiar C-style syntax, it has type parameters in the form java does with <>.

Seeing my own list, i don't think i am comfortable with either c or java. So that's probably why. C, java, c++ are other notable languages i never could get into.

javascript also has C style syntax, which you claimed to be familiar with. It's subjective, so I can't say that you're wrong. I just don't get it.

Honestly I have a hard time understanding why you're being downvoted for expressing an opinion.

Rust is unpleasant to look at. I know its silly, but that my biggest gripe with it as well, aside from sometimes overly complex type signatures due to generics. As a reference, I consider Python and Kotlin to be a e s t e t i c.

What was the first programming language you really understood and learned? I ask because most of the time people think something different is complex or "ugly" it's because they're using bad abstractions between what they fully understood first to other things they think is in the same domain.

Patricia Asa has an excellent presentation[0] on how she tried to learned C# and one of the biggest things she discovered was these bad cross-over abstractions held her back.

[0] https://www.youtube.com/watch?v=Id2jBMJLz2Y

My first programming language was Python. And now I write Python and Go frequently.

I'm trying to learn Rust, working through various programming exercises, but it looks and feels very foreign to me.

One example is the closure syntax. Compared to Go where closures look just like function definitions, closures in Rust look needlessly complex.

I also always find myself wanting to type mut &a instead of the correct &mut a. The former just seems more obvious to me, a reference to a is &a. Mutable a is mut a. So a mutable reference must be mut &a.

Then the lifetime syntax, I don't even know what to think of that, but that's probably more because I'm struggling asking myself "Should I use a lifetime here, or am I doing something else wrong that is preventing the compiler from figuring it out."

Maybe these things make more sense to people coming from C.

We took the closure syntax from Ruby/Smalltalk, and the lifetime syntax from OCaml.

On &mut a vs. mut &a, when read literally left to right the former is “a reference to a mutable a” and the latter is “a mutable reference to a”

I'm guessing the &mut a stuff makes more sense to C folks. In C you have to deal with const, and it can go anywhere. A const pointer to a non-const item, a pointer to const value, etc. That's my guess anyway.

For the record, I consider Clojure to be beautiful as well, in its own way.

I had experience somewhat counter to this. I learned C and C++ first, and when I first started working with Java things like CamelCase did seem a bit strange and off-putting to me, but I got used to the less keystrokes surprisingly quickly, and it's one of the things which bothers me most about working with Rust.

I think there is such a thing as liking what's familiar, but there are also design decisions which are simply easier to read or easier to parse. Another one would be semicolons: I have never missed semicolons one bit in a language where they're not required.

> I have never missed semicolons one bit in a language where they're not required.

I dutifully wrote semicolons for years, but just a short stint of Scala and I don't like using them anywhere now.

I have the same experience - I don't know what the hell is going on in the code with Rust. Compared to say F#, its far more verbose and feels too much like C++.

In the same vein I think Typescript error messages are absolutely horrific. Furthermore, I keep seeing people who start using Typescript to "make it like my preferred language xyz".

Coincidentally these devs 1) hate Javascript 2) end up writing a massively over engineered OO big ball of mud

These reasons are why I avoid Typescript where I can.

And crystal lang, for static typed language example.


I'm so not a fan of the ruby-style blocks. I never understood the appeal of typing "end" a million times instead of a single close brace.

{} blocks are used but for one line methods/closures. Since programming has nothing to do with typing speed, there isn't much difference between `end` and `}` other than `end` making more sense in ruby's english-like syntax.

> Since programming has nothing to do with typing speed

I have heard this a lot, and I am not sure I agree. I agree that the limiting factor for how long a project takes to finish is never going to be how fast you can type, but when I am really in flow I am actually typing pretty quickly, and I don't really like more friction when it's not necessary.

Of course it's a matter of taste, but the argument that it's english-like never made sense to me either. There is no english sentence construct with "do ... end" or "wile ... end".

The problem with 'end' IMO is that you have to actually read the word, with } you don't. Of course, this is all useless to argue about. I quite like that lots of languages look different.

Maybe it's my background (Java and JS with a little bit of C++), but I don't find it alien at all. I do find it really clunky in a handful of situations, mostly having to do with lots of nested containers (Option<Arc<Box<...), but luckily those are uncommon for most use-cases.

The bit which seems the most cumbersome to me is working with things like str/String and Path - it seems like there's always a lot of finagling and long chains of method calls to get things into the right type representation for a particular use-case.

I'm still not so experienced though so maybe it gets better.

I think what helps with that is building up a mental model of where things actually live in memory. If you create a new String, you're putting it on the heap. Which makes sense, because strings can be of any arbitrary length. It also makes sense, then, that you can mutate these heap-strings, because they have room to grow. In contrast, the only strings that can go on the stack (so you don't need String::new) are ones whose lengths are known at compile-time. So, a string literal (str).

There are many things like this in Rust where if you just follow the compiler errors until you can make it happy, things will end up really convoluted and baffling. They'll also probably not perform as well. To really use Rust you have to absorb what's actually going on underneath its protections, and not just the errors that surface from them. This isn't easy, and is probably the dividing line between people who stick with it and people who decide it's not for them.

You get about 50% of this picture for free (thinking in terms of stack, heap, references) if you've done C/C++ before. The other 50% is completely unique to Rust. But the key is to read the Book, follow guides, etc. Rust isn't really something you can learn just by hacking it out on your own, unfortunately.

I like how the strengths of Rust are summarized by 3 points:

* Algebraic data types

* Resource acquisition is initialization

* Aliasable XOR mutable

I think it can help not only to design a Smaller Rust as discussed here, but also to grasp the key design points of this language which is a bit daunting.

There's also the trait system, which although isn't unique in Rust, has many advantages over the inheritance-based class system that languages such as C++/Nim/etc uses. Rust's trait system can be both used for static and dynamic polymorphism seamlessly (basically, impl T vs. dyn T). But in C++, you need different systems for doing so: templates (via hacks such as CRTP/SFINAE, or concepts in C++20) vs. virtual/override. Note that Go has interfaces which is the closest thing to traits, but only allows for dynamic polymorphism (because Go doesn't have generics yet... it seems like it's going down a similar path to C++20's concepts)

I like how these three points aren't just "strengths of Rust", but...

> the necessary components ... to make imperative programming work as a paradigm

You know, before Rust imperative programming just didn't work. Didn't work as a paradigm, even!

> Rust works because it enables users to write in an imperative programming style, which is the mainstream style of programming that most users are familiar with, while avoiding to an impressive degree the kinds of bugs that imperative programming is notorious for.

That's from the previous paragraph. So yeah, out of context it's a bad quote, but in the context it's a little less hoity toity.

It had state mutation as a massive liability - which is probably why FP has seen such a surge in recent years. Rust is the only language that mostly solves that without totally changing the paradigm.

It doesn't really change the paradigm compared to FP even, it just enforces a clear separation at all times between sequential state mutation on the one hand and references to shared (and generally immutable, except as provided for by explicit mutability mechanisms) state on the other. Which is essentially what FP languages end up doing, though they go about it somewhat differently.

Right. The problem is that functional purity always comes with overhead; data structures like Clojure's help a whole lot, but there's still a cost.

But one of the primary motivators for functional purity is to avoid unintended side-effects.

In pure FP, You never even have to bother your mind with unexpected side-effects because there are no side-effects.

Rust instead gives you the vocabulary to carefully articulate intended side-effects, preventing all other, unexpected side-effects at the same time. So you can colonize the wilderness, instead of avoiding it altogether, and for that you get to skip the overhead of treating everything as immutable. Not that this is an indisputable improvement for every use-case, but it's a novel tradeoff and one that is definitely preferable in many domains.

There seems to be a certain train of thought within the Rust community that unsafe languages are fundamentally invalid. I saw a comment on another forum claiming that C++ was even unsuitable for personal, experimental projects because unsafe code == undefinted behavior, and therefore your program produces "random results" which would not be fit for, say, scientific inquiry.

Apparently the Linux kernel, and all code written before 2010 is just completely random!

> Apparently the Linux kernel, and all code written before 2010 is just completely random!

The Linux kernel is well known to be completely random when considered as a C program: building something useful out of it relies on a particular set of flags and ad-hoc implementation details of GCC (e.g. -fno-delete-null-pointer-checks).

> building something useful out of it relies on a particular set of flags and ad-hoc implementation details of GCC (e.g. -fno-delete-null-pointer-checks).

That makes it esoteric, not random. If the Linux kernel really did produce "random output" there is no way it would serve as the backbone of the global computing infrastructure, the financial system etc.

From my observations, there seem to have been considerable efforts to avoid it being just random. Compiler & library "optimisations" have broken Linux before ("randomly") [0] [3]. Linus has ranted about this [1], multiple times [2] [3].

[0] https://lwn.net/SubscriberLink/793253/6ff74ecfb804c410/

[1] https://lkml.org/lkml/2018/6/5/769

[2] https://bugzilla.redhat.com/show_bug.cgi?id=638477#c129

[3] http://lkml.iu.edu//hypermail/linux/kernel/1407.3/00650.html

> That makes it esoteric, not random. If the Linux kernel really did produce "random output" there is no way it would serve as the backbone of the global computing infrastructure, the financial system etc.

To the extent that Linux is useful, it's not written in C. The particular binaries produced by GCC with particular flags have particular behaviour, more or less, but considered solely as a C program in terms of the C standard (i.e. behaviour on the C abstract machine), Linux does produce random output.

> I would probably experiment with exceptions if I were making this language.

Hard disagree. Rust made conscious design decisions (i.e. limiting type inference) to enable local reasoning, and exceptions are non-local.

Checked exceptions (like Java) are isomorphic with Rust's Result type. You could convert a checked exceptions syntax into Result exception-carriers on each return with a mechanical transformation, of a Rust+exceptions program into Rust-today program.

Suppose you have

    fn foo(i: i32) -> string throws IOException
    let x: Vec<i32> = ...
    let y = x.iter().map(foo).collect()
What's the type of y ? There are no good answers here: if it's Vec<string> then where did the errors go? If it's Vec<Result<IOExecption, string>> then the user is pretty confused about where the Result came from. You can't propagate the IOException through the map() call in the general case (it might be in a third-party compiled library etc.). In Java this is a compilation error and the reason checked exceptions are useless in practice.

In Java, you'd expect map() to throw, and the type of y to be Vec<string>, in Rust parlance. If one of the conversions failed, you'd expect the stack to unwind as the exception propagates, and you'd expect that the use of map() means you're not particularly interested in which item failed, you just want to abort (roll back) on failure. This is the 99.9% use case for exception in Java, or C#, or Delphi; you almost never catch exceptions.

If you're interested in which item failed, ideally you'd be using a method that doesn't throw at all, and instead returns an error condition. I like how C# makes this clear, with Parse and TryParse on Int32. You use Parse when you want unwinding and abort behaviour including stack unwinding, and TryParse when you want to handle errors, and you don't use exceptions for expected errors, since expected errors are not exceptional.

(If the map is lazy, you'd expect the materialization in collect() to throw. But the return type wouldn't change.)

IMO, the kind of code you write on a daily basis is the primary determinant of your preference for error codes vs exceptions. If you need to handle error conditions frequently, you'll prefer error codes, because they're data, like all other data, and you can use the general compositional tools at your disposal to manipulate them. If you handle error conditions exceedingly rarely, and mostly just want to abort, unwind, roll back, go back to the main loop and log them, then exceptions are your friend.

I do not think there is a fundamental superiority either way. I think there are tools for purposes. I do think that Java's checked exceptions are half-baked; they're half-way into the compile time type system in a language whose applications are generally better suited to run time exceptions.

> In Java, you'd expect map() to throw

You'd have to use unchecked exceptions to achieve that though. There's no way to propagate the checked exception across that call, because there's no way to make map() generic over exception-or-not in a Java/Rust-like type system.

> If you're interested in which item failed, ideally you'd be using a method that doesn't throw at all, and instead returns an error condition. I like how C# makes this clear, with Parse and TryParse on Int32. You use Parse when you want unwinding and abort behaviour including stack unwinding, and TryParse when you want to handle errors, and you don't use exceptions for expected errors, since expected errors are not exceptional.

Result gets you that without needing to write two implementations of every method. You call unwrap-or-panic in the cases where error is not expected/handled, and handle it in the cases where you want to handle it.

This should be a type error, because there's no further information available to resolve which implementation of FromIterator<> should be used. (Also you'd need to use filterMap to ever arrive at Vec<string> since you can't simply discard the errors.)

Using magic language syntax like checked exceptions in lieu of type system expressiveness is not great, which is why Java's checked exceptions are a bit on the unpleasant side. You can't have one map() that handles functions with and without exceptions, because exceptions aren't part of the type system.

In Rust, exceptions are part of the type system, so you only need one map(). If you also add syntactic sugar for the types such as:

  1: fn foo(i: i32) -> string throws IOException
  2: throw SomeIOException
  3: try a catch IOException b
To mean, respectively and approximately:

  1: fn foo(i: i32) -> Result<string, IOException>
  2: return Err(SomeIOException)
  3: match a {
       None => b
       Some(_) => {}
... you can keep the simpler program code of exceptions and keep the ability to express exceptions first class in the type system.

The Rust implementation (syntax I use might vary from reality) of async/await is just this sort of syntactic sugar over a type: "async fn foo() -> X" is sugar for "fn foo() -> Future<X>", and "await!foo()" is (kind of) sugar for "foo().then(rest of the function)".

The only really kind of disappointing thing about this is that exceptions and futures don't get unified: we get ? for exceptions and await! for futures. Both of them ultimately are continuations: exceptions bypass the continuation and return an error immediately, futures call the continuation only when their value becomes available. The value of using a unified interface to continuations (or monads, if you prefer) is that you can use ones that don't have a magic blessed syntax: parsers with automatic backtracking written as simple imperative blocks, non-determinism via iterators where the continuation is a flatten operation, etc.

> This should be a type error, because there's no further information available to resolve which implementation of FromIterator<> should be used. (Also you'd need to use filterMap to ever arrive at Vec<string> since you can't simply discard the errors.)

I don't want to have to worry about whether the function "throws" or not at the point where I'm calling map - that's the whole problem of doing this in Java. What if the function that calls map is itself a generic higher-order function? Result's great benefit over exceptions is that it isn't a special case; generic functions work with Result just as they work with string.

> In Rust, exceptions are part of the type system, so you only need one map().

So what's the answer to the question? If there's only one map() I should be able to use it to map with foo; when I do, what do I get back?

> The Rust implementation (syntax I use might vary from reality) of async/await is just this sort of syntactic sugar over a type: "async fn foo() -> X" is sugar for "fn foo() -> Future<X>", and "await!foo()" is (kind of) sugar for "foo().then(rest of the function)".

Sure, and try!foo() does a pretty similar thing for Result. I think that's a better approach than exceptions, because you can see what's going on at the call site - having functions that "throw" and don't "throw" look exactly the same at the point of use is too magic/confusing IME.

> The value of using a unified interface to continuations (or monads, if you prefer) is that you can use ones that don't have a magic blessed syntax: parsers with automatic backtracking written as simple imperative blocks, non-determinism via iterators where the continuation is a flatten operation, etc.

Yeah, I find continuations too confusing to reason about but I really wish Rust would adopt some general-purpose syntax for monads ("do notation"). That would require HKT though, because you can't even form the type of a monad in the general case without that.

And nobody, nobody, likes checked exceptions. Perhaps with appropriate syntactic sugar they could be made ergonomic, but Java's checked exceptions at least were a failed experiment.

Java's checked exceptions are a failed experiment because of subclassing (i.e. eventually you get `throws Throwable` which doesn't give you any useful information). More precise exception tracking is essentially isomorphic to current Rust (Result etc.).

(Just adding a bit to that:)

It's not just subclassing.

It also breaks with higher-order functions, e.g. what is the throws clause of a map() method on lists? There's really no good answer in Java, so what people do in practice is to just re-wrap into a RuntimeException, but that leaves callers with a problem: Calling code cannot match on exception types using a catch clause on the checked exception -- it has to check for a RuntimeException with an embedded checked exception. This is disastrous for ergonomics and real-life use of checked exceptions.

The Result/Either method leads to its own ergonomic problems, but I think they could be solvable with support for row-types and (anonymous) union types. At least in Haskell, the Either method leads to a proliferation of FooError types whose only purpose is to wrap other error types. Well, either that or you accept that every little thing can return in a generic 'AppError'.

The "right" thing in Java would be to parameterize map() by the error type declared on the lambda passed in. In other words:

    interface Function<T,U,Err> {
       U apply(T value) throws Err;

    interface List<T> {
        <U,Err> List<U> map(Function<T,U,Err> f) throws Err;

That only works for exactly one exception type. Err cannot be no errors, nor can it be two or more unrelated exceptions. You end up having to have n different overloads (for 0, 1, 2, ... exceptions) for every functions, and since Java's type system is not up to "varidic generics" you have to write them all manually.

While you're correct about the two or more unrelated exceptions, Java will happily infer Err to RuntimeException and so the no errors case works just fine there. It's only work in a case where the type is only needed by inference, though.

Oh indeed, you need |, and the ability to compose the type parameters, etc. It's why I said "right" rather than right.

You could solve that with sum types (for 2+ exceptions) and never types (for 0),

It's not actually a sum, it's an inclusive union which are much harder to reason about and compromise your type system more (IMO). In any case, Java does not have any of those, and this is what makes checked exceptions impractical to work with; Rust has sum types but I don't think it has either inclusive unions or never types.

> It's not actually a sum, it's an inclusive union

Right, whoops. My bad!

> In any case, Java does not have any of those, and this is what makes checked exceptions impractical to work with

Absolutely. I was approaching it from the standpoint of "what would I change about Java's type system to make checked exceptions usable?".

> Rust has sum types but I don't think it has either inclusive unions or never types.

Rust is working on stabilizing a true never type[0], and until then you can emulate it with an empty enum.[1] There is also a RFC for unions, but that doesn't seem to have gone anywere.[2]

[0]: https://github.com/rust-lang/rust/issues/57012

[1]: Such as https://crates.io/crates/void which also provides helpers for safely coercing `Void` `Result`s

[2]: https://github.com/rust-lang/rfcs/pull/1154

My comment was actually made with many of these issues in mind -- I was trying to avoid needless nitpicking by being a bit vague. Perhaps that was a mistake.

The point wasn't that these things couldn't be solved -- they could, at least in theory[0] -- but that the current practical limitations of Java[1] are such that the interaction of higher-order functions and checked exceptions make checked exceptions a really bad idea.

[0] At least I think so... given anonymous or adhoc sum types, etc.

[1] Interestingly, this is not a JVM-wide issue. It's just the Java compiler that imposes these restrictions, for example: You cannot "catch" a checked exception which hasn't been declared at least one method in the "try" scope. This makes sense at first glance ("cannot happen"), but means that re-wrapping by higher-order functions must entail extremely error-prone inspection of any exception by calling code, etc. etc. It's a huge mess.

A bit late... but as others have pointed out:

This only works to the first order. If you have functions-that-call-functions-that-call-functions, you end up not being able to write(!) the correct throws clauses in Java.

Not all isomorphic representations are equally ergonomic. Exceptions require special additional syntax, Result is “just” another value.

Ensuring error values get all the way to the top-level error handling loop is generally more ergonomic with exceptions, not less. It's easier for values to fall by the wayside, and for errors not to propagate when they're paired (type-wise) with values.

If you do a lot of error handling close to the point of the error condition being found, the Rust / checked exceptions approach works well. For the kinds of applications that are written in Java, it's not the case. Most error conditions need to be propagated and the action in flight aborted.

> Most error conditions need to be propagated and the action in flight aborted.

That's exactly what the '?' operator does. It's just as ergonomic as Java exceptions, and it doesn't hide the places where errors can be returned and the execution flow can be diverted.

What benefits are there to checked exceptions over Rust's `Result` type?

Automatic propagation without relying on macros like try!, or match with return on Err case.

As you make it easier to propagate the error case to the caller, you asymptotically reach checked exceptions.

I guess, although I'm not convinced that's an advantage. The ? syntax is pretty convenient, and it still allows you to do manual matching if you have a good reason to.

I think the main thing you'd gain with checked exceptions is being able to list the possible failures in the function signature, instead of having to create an enum type. But I guess I'm not sure it's really worth the tradeoff.

Strack traces? I love Rust error handling, but the one thing I sometimes miss is that there is no stack traces by default on errors.

That's orthogonal to exceptions though. Errors could easily have stack traces automatically added to them (and IMO, they should).

Interesting. Coming from an FP background I'd say the opposite - the confusing part of Rust is all these special-case imperative control flow keywords, a more smalltalk-like "everything is just values and functions" syntax would be the best way to simplify it.

I'd agree with stepping away from guarantees about when allocations happen etc. But I think at the point where you remove those from Rust you really just have OCaml.

I think one thing that confuses people about Rust is it sends mixed signals with regard to functional vs imperative. It gives you lots of nice functional toys - closures, implicit returns, pattern matching - but you end up having to write a lot of imperative stuff too.

I've found that Rust flows much more nicely when you just embrace an imperative style "by default". That's its native tongue; the functional stuff is more of an exception.

Those of us used to multi-paradigm languages have taught ourselves that imperative == bad (actual for-loops! egads!), but the thing is, Rust is specifically designed to make it not-bad. It gives you all these tools to carefully control and limit mutability, so you can do imperative stuff with much less fear. It takes getting used to but in my (limited) experience this mindset significantly reduces friction when working with Rust.

I haven't spent a lot of time with FP languages over the years, but implicit returns are one of my least favorite features of Rust. Especially the use of semicolons to denote a terminated statement vs. a return value: a semicolon-related typo is such an easy mistake to make, and an annoying reason for a build to fail, especially when so many modern languages have shown it's quite possible and pleasant to work without semicolons.

To be clear, Rust does not have "implicit returns" nor does it use semicolons to indicate return values.

Expressions evaluate to a value, and semicolons take an expression, throw away its value, and evaluate to () instead. That's it.

The other behaviors you're talking about fall out of these semantics, but are also different than what you've said; functions evaluate to a value, so the final expression's value determines the value it evaluates to, but you cannot "implicitly return" from the middle of a function by dropping a ;, for example.

What I meant was the fact that you can omit the "return" keyword:

  fn foo(x: i32) -> i32 { x * 2 }

Right, but only as the last expression in a block, which is different than what "implicit returns" implies.

It's a bit of a semantic distinction isn't it? In practice what it means is that you add semicolons to the end of almost every line of imperative code in a block, and you leave the semicolon off the last line and this becomes your return value implicitly (without the use of a return keyword).

It is a semantic distinction, because "everything is an expression" and "implicit returns" have different semantics.

Thinking of them as implicit returns leads people to believe they can write code like

  fn foo(x: bool) -> i32 {
      if x {

      // more code
and then they're confused when this doesn't work.

I actually love the semicolon semantics. Making expressions and statements disjoint prevents bugs like this:

  if(foo = 5) {
They are easy to mess up when you're getting started, but they're also very easy to fix because they'll basically always result in a clear compiler error. If "builds failing" are annoying for you, you probably aren't using an IDE with inline errors, and with Rust you absolutely should be doing that or you'll have a bad time.

   if(foo = 5) {
You don't need the rust style expression/statement distinction to catch this. Many languages will catch this at compile time since "foo = 5" does not evaluate to a boolean.

I know that it's relatively easy to catch this error in Rust, and it is at most a minor annoyance, but it still feels like a clear step backwards after working with languages which don't require semicolons at all.

> since "foo = 5" does not evaluate to a boolean

In most C-like languages, this expression is valid: the whole thing evaluates to "5", which has a truthiness. Some "clever" programmers use this pattern intentionally in loops to save a line of code, especially in the C/C++ world. For example:

  while(currentNode = currentNode.next) {
When currentNode becomes null, the expression/statement becomes falsy, and the loop terminates. I absolutely love the way Rust systematically disallows shaky syntax tricks like this.

Yeah I know, but there's a wide range of languages which are neither C nor Rust.

My point is, there are plenty of examples of languages that show that you don't need to add semicolons to almost every line of your program just to avoid this one potential error.

Out of curiousity, which languages are you referring to? Assignment in if-statements is possible in C, C++, js, Go, and many other C-like languages.

I think the only language I know of that disallows assignment in them is python, and it's not because assignment evaluates to something other than boolean, python is happen to evaluate the 'truthiness' of certain types. And funnily enough, they are going to add this back in to python 3 I believe, using a different operator.

Swift, Kotlin, D, C# to name a few.

Are you sure about Go? I just tried this in an online playground, and I get this error:

    syntax error: assignment x = 2 used as value

Well, it'd be OCaml without a garbage collector. And to be honest, that would be a very useful and handy language for systems development.

OCaml is already a good language for systems development - people are far too scared of garbage collectors. And if you make the changes in the article then I think you give up most of the concrete benefits of non-GC - you won't have easy C interop without control of stack/heap/allocation, and since trait objects could end up nested arbitrarily far, I think you might still get GC-like pauses where a simple-looking code line ended up doing an arbitrary amount of unwrapping and freeing work.

I guess it depends on your definition of systems development. If you mean "writing systemsy applications" like IoT things or whatever, sure, fine, a GC isn't going to be an issue and probably will help.

If you're talking about a language to write drivers or an operating system, etc in then a GC is just a big No. In that domain we don't even have Malloc/Free, let alone GC services. Having the overhead and normative lifestyle assumptions of _any_ kind of runtime is a big Nope.

Counterpoint: The Oberon System successfully implemented a garbage-collected OS kernel: https://en.wikipedia.org/wiki/Oberon_(operating_system).

Mirage exists and works reasonably well.

OCaml has for and while loops. Its for loop is even more imperative than Rust's (which simply drives an iterator, whereas OCaml's has "for a to b" and "for b downto a").

OCaml doesn't have break or continue though.

> a more smalltalk-like "everything is just values and functions" syntax

Can you do that sort of thing without a GC? Some functional languages use tailcall as their control flow primitive, but that's just a glorified GOTO.

I don't see why not? Rust has first-class functions and its lifetime support is supposed to be a big selling point, so it ought to be possible to implement things like "if" and "for" as userspace functions - and if not, then that suggests important limitations to its lifetime/ownership model.

> special-case imperative control flow keywords,

Which ones do you mean?

Not OP but from what they're saying I'd guess loop, while, for, continue, break, return. Possibly do (which is reserved but not used), could be a do{}while or a monadic block, who knows.

Though it should be noted that Smalltalk provided a non-local return.

I'm really conflicted about

> Aliasable XOR mutable

because it prevents eventual synchronisation (e.g. single writer, multiple readers of a "monotonic" data structure like a counter)... I'm not convinced that it's entirely necessary, nor how easily it can be proved safe, but AFAIK many garbage collection runtimes work using eventual synchronisation... Probably other algorithms as well!

Another thing that's not (AFAIK) covered by Rust's ownership system is flexible memory pools... where you can have proofs that some memory is accessible and pass those proofs around (whether at runtime, or just during compilation on the type system level)... although that might be entirely isomorphic to just passing around owned pointers to memory (pools), so maybe Rust does support it.

People with lots of experience with Rust, what scenarios do you need to use unsafe Rust for?

Interior mutability via atomic values (https://doc.rust-lang.org/std/sync/atomic/index.html) works great for the first one, and, indeed, some sort of atomicity/synchronisation is required even in languages without the XOR rule (such as C++) to prevent undefined behaviour.

There's a variety of safe arena types that give allocation inside pools too: https://crates.io/search?q=Arena&sort=downloads

Atomics are very useful indeed, but an overkill for some situations. In particular, if there's just one writer and multiple readers, the write doesn't need to be synchronised (as there's no possibility of someone else mutating the value in the middle of you incrementing it). An example would be the GC thread incrementing the epoch id/count that all mutator (user) threads read.

Presumably you'd still need some sort of synchronisation otherwise the memory will stay mutated just in one CPU's cache, you need to "flush" the updated memory so that other CPUs can then see it, but that's likely cheaper than executing the whole operation atomically... Basically, increments are fundamentally not atomic (i.e. composed of multiple separate operations) whereas reads (of a single word) are, so if all mutations happen from a single thread (assuming a sensible CPU architecture and cooperative compiler) there's nothing else that needs to be done to ensure atomicity, you only need to care about the "happens before" relationship.

But then I'm not a low-level programmer so the above is probably all wrong :)

They have to go via the atomic types or it is undefined behaviour. One can use a very weak ordering (such as Relaxed) to require little or no "physical" synchronisation by the CPU. Like C++, Rust atomics offer a range of levels of synchronisation, weaker than sequential consistency.

A platform like the JVM disguises this because every read and write (even without 'volatile') use minimal synchronisation to avoid the worst aspects of the danger here. This ensures that programs that do it wrong (e.g. forget a 'volatile') are "only" incorrect, but not unsafe.

Ah, you're right, weaker ordering takes care of a lot of these issues! Good point, thanks!

> Resource acquisition is initialization

I recently built a small prototype Entity Component System, and one of the big learnings for me was to what extent RAII is an anti-pattern with respect to performance. It's actually amazing how fast a modern CPU can be when you're not using it to allocate and initialize small blocks of memory at a time.

I understand the benefits of RAII in terms of bookkeeping, but it seems like there is opportunity for a new resource management paradigm which is more optimized for using computing resources.

Can't you just initialize everything at once before you need it rather than initializing one object here and there?

It depends heavily on the problem domain. In some cases this is possible, but it's often the case that you will not know what resources you will need at the beginning of execution.

Also the implementation of "initialize everything at once" in an RIAA paradigm is not necessarily going to be the most efficient possible implementation. For instance, I might want to do something like this (in a made-up language):

    struct A { ... }

    func init() {
        let a1 = new A();
        let a2 = new A();
        let a3 = new A();
The most efficient way to implement this would be to allocate enough memory to store a1, a2, and a3 and then run the initialization method 3 times over that memory. But it's likely that instead the compiler will allocate a1, initialize a1 and so on with a2 and a3. Since the allocations are separate, maybe another thread takes precedence between the allocation of a2 and a3 and allocates some memory, so these values are not contiguous in memory, and I get worse caching performance when accessing them.

In this toy example with 3 values it doesn't make aa lot of difference, but if you add up all these marginal costs in a large application it absolutely does cost a lot of performance.

> The most efficient way to implement this would be to allocate enough memory to store a1, a2, and a3 and then run the initialization method 3 times over that memory.

Only if a1, a2, a3 are also freed at once. In which case, you can just put all three in a struct that is allocated as a single block. Rust doesn't yet support this very well in the general case, because its support for what C++ calls "placement new" and customized allocation in general is not complete or stabilized. But doing this in a more hackish special-cased way is already possible in many cases.

> Only if a1, a2, a3 are also freed at once.

Yes exactly. This is meant to be illustrative of the case suggested in the parent comment, where all required runtime objects are known and can be initialized together at the beginning of execution.

edit: it may be possible to program around some of the worst performance pitfalls of RAII in many cases, but you will end up with very non-idiomatic code, which I would take as evidence that RAII is not ideal for memory performance.

The lack of ADTs in mainstream languages is really quite baffling, given their obvious utility.

Thankfully it looks like they’re trickling in via Typescript/Swift/Kotlin.

Do TS and Kotlin support them now? Last I checked TS was relying on string comparisons and Kotlin wasn’t even bothering outside sealed classes

TS is a bit more powerful than it appears here, because string literal values are recognized at the type level, so can be checked by the compiler when distinguishing cases. See for example: "Discriminated Unions" at https://www.typescriptlang.org/docs/handbook/advanced-types.....

See, everyone has a different thing they find important about Rust. He suggest abandoning zero cost abstractions (letting stack/inline allocation be decided by the compiler instead) which I think would remove most of the power of the language. Default thread safe primitives sounds really wasteful too.

I think languages similar to Rust but with less complexity will be a very cool space to watch going forward. All sorts of interesting language designs possible.

I’m working on a language that does “Rust-like” things, but unlike the article, what I am going for is the efficient memory management, in this case the inline structs (zero cost abstraction) and compile time memory management (automatic lifetime analysis). The biggest difference with Rust is that it is fully automatic: when ownership can’t be determined at compile time, by defaults it falls back to a reference count increase at runtime (in Rust this would just be an error). The advantage is zero annotations, and a language that can be used mostly by people that don’t even understand ownership. There may be ways to explicitly (optionally) enforce ownership in the future, for those who want more control.

Language: http://strlen.com/lobster/ Details on memory management: http://aardappel.github.io/lobster/memory_management.html

> As I said once, pure functional programming is an ingenious trick to show you can code without mutation, but Rust is an even cleverer trick to show you can just have mutation.

Yea, I like this.

>In other words, the core, commonly identified “hard part” of Rust - ownership and borrowing - is essentially applicable for any attempt to make checking the correctness of an imperative program tractable. So trying to get rid of it would be missing the real insight of Rust, and not building on the foundations Rust has laid out.

Sure, but people wanting a smaller Rust might not care about the "real insight of Rust", but of the surface syntax, speed, apis, tooling (e.g. cargo), and so on...

I'd like a Rust like language that's GCed for example, and drops all the lifetime annotations and so on...

I guess it depends what you think is the real core of Rust - for me, Rust's primary innovation and primary differentiation point is giving you safe memory management without a garbage collector. Anything that steps away from it (so stepping away from the borrow checker, lifetimes and so on) becomes something that isn't Rust anymore.

After all, just about everything else in Rust is in Haskell.

The borrow checker is undoubtedly the core innovation of Rust, but it's not what people new to the language want. When I first came to rust, I was looking for a c++ alternative that had similar syntax and low level power, but also a sane standard library and modern language features like closures.

When I first learned about the borrowchecker I thought it was brilliant. Then I spent some time fighting it and I hated it. Then I learned to love it...until I got backed in to a corner and had to re-architect a project I'd been working on for months. Now I have mixed feelings about it.

So I agree Rust isn't Rust without the borrowchecker, but I empathize with all the noobs who want a rust without it because many people just want a language with some traction that fixes c++ past mistakes, and they end up getting a massive paradigm shift thrown at them instead.

It's surprising to me that a language hasn't emerged which is more purely a modernization of C. It seems like you could go a long way just keeping C's basic abstraction in place, reworking some of the clunky bits and adding some basic quality-of-life improvements from the past 30 years of language design.

Rust seems like a great language to write other languages in. Not as convenient than a lisp but, zero-cost abstraction, safe, fast and close to the metal.

So you may be see a rustscript at some point. Some people try to implement Python in it, which I think is very interesting: https://github.com/RustPython/RustPython

After all, replacing C/C++ is the raison d'être of rust.

That’s basically Swift.

Guidelines | FAQ | Support | API | Security | Lists | Bookmarklet | Legal | Apply to YC | Contact