Hacker News new | ask | show | jobs
by drobilla 4137 days ago
I'm very happy to see Rust stabilize, about time we get a systems(ish) programming language with a half decent type system. With that said... I need to get some bikeshedding off my chest:

I hate to let such a triviality lower my enthusiasm for a language so much, but I just can not get over that awful inconsistent closure syntax :/

I don't get it. Most everything else has a nice unique keyword syntax, fn uses (args, in, parenthesis), proc syntax made consistent sense, then lambda is this crazy || linenoise thing that doesn't fit in at all. The "borrow the good ideas from other languages" approach has resulted in a great language, but "cram random syntax from other languages that doesn't fit" doesn't work out so well.

3 comments

The natural thing to want in a C-like syntax is the "arrow function" closure syntax (like ES6 or C#), but that required too much lookahead to parse. Having a keyword discourages functional style, which would be a shame in a language with a powerful iterator library. So Rust went with the Ruby/Smalltalk-style bars, which are nice, concise, and easy to parse.
It seems like the human parser should be given priority over the computer parser, when considering what is easy and what is hard. The machines work for us!
As far as I know, Rust now has an LL(1) grammar, which means that writing parsers for it can be done by hand (or with the more powerful LALR(1) and LR(1) parser generators). This is very important for humans too, because it means more people are likely to write tools to process Rust code. If you hope to have automatic indentation, auto-completion, refactoring, formatting tools, etc. keeping the syntax simple is really important.
Can't they just expose the parser as a library? Actually, it looks like they did, with the rustc crate.

Hand-writing a parser for some other language leads to madness - just ask the folks who've done SWIG, GDB, or most IDE syntax-checkers. You'll inevitably get some corner-cases wrong, or the language definition will change underneath you long after you've ceased to maintain the tool. Instead, the language should just expose its compiler front-end as a library, and then you can either serialize the AST to some common format for analysis outside the language or build your tools directly on top of that library.

You missed the whole point.

By making the language simple you can easily implement your own parser. This opens up the ability to write native parsers in other languages, say vimscript. By keeping it super simple there -are- no corner-cases.

There are many benefits to this (like the formatters etc that others have alluded to) from things like IDE integration (imagine lifetime elision visualisation, invalid move notifications, etc) static analysis tools and more. None of these tools then need to be written in Rust. It also means it's easier to implement support in pre-existing multi-language tools.

Don't underestimate the necessity of a simple parseable grammar. Besides, people have endured much worse slights in syntax (see here Erlang).

The vim formatters/syntax checkers I've used that actually try to parse the language - other than Lisp, which is the limiting case - are generally terrible. They all miss some corner case that makes them useless for daily work, since they generate too many false-positives on real code.

The ones I actually use all call out to the actual compiler - Python, Go, or Clang for C++.

Just because people write their own parsers doesn't make it a good idea. It may've been necessary when most compilers were proprietary and people didn't have an idea how to make a good API for a parser. But now - just don't do it. You'll save both you and your users a lot of pain.

We are not quite ready to support users of libsyntax and librustc as we are very serious about keeping our stable api stable, and freezing those apis would really impact the future development of the compiler, so it will not be exposed in rust 1.0. We want to eventually get something for this purpose though.
That's what D does, it has infinite look-ahead in cases and there are still multiple parsers available as libraries.
This human parser happens to prefer Rust's closure syntax to any other language's. :) Well, for usage, anyway... the written-out type of a closure for use in function signatures is not nearly as concise.
How much worse is it? Can parsing actually be so expensive that it dictates the syntax of the language?
Parsing a context free grammar in general is O(n^3), but parsing an LL(1) grammar is O(n). That's a pretty huge difference, if you're not careful. Imagine you've got a million lines of code to parse.
As a Rubyist, I found the closure syntax quite comfortable, as they're almost identical. Passing a closure or lambda to some function foo:

        x = foo {|x|   x + 1 }    # Ruby
    let x =  foo(|x| { x + 1 }); //Rust
    let x =  foo(|x|   x + 1 );  // single expressions don't need {}s
That said, I'm not sure what the exact reason was for choosing the syntax, as that was before my time.
> That said, I'm not sure what the exact reason was for choosing the syntax, as that was before my time.

I think the reason is just that it's very concise, and lightweight closure syntax makes things like `Option::map` feel like first-class parts of the language. The closure you pass just sort of seamlessly "blends in".

Note that having especially sugary here is not so uncommon, for example Haskell has `\x -> blah` for Rust's `|x| blah`.

I'm personally very happy that the closure syntax is as concise as it is.

I am not a Rust user and maybe I am reading the post wrong but it seems that syntax has been deprecated:

> Closures: Rust now supports full capture-clause inference and has deprecated the temporary |:| notation, making closures much more ergonomic to use.

That's talking about something different.

For a brief period, closures had to be annotated in certain cases like |&: args| or |&mut: args| or |: args| to determine whether they captured their environment by (mutable) reference or by value/move.

Now that this is inferred in all cases, closure arguments can just be written as |args| in all cases, just as they were before the current Fn* traits were introduced.

> For a brief period, closures had to be annotated in certain cases like |&: args| or |&mut: args| or |: args| to determine whether they captured their environment by (mutable) reference or by value/move.

That particular annotation controlled the access a closure has to its environment, not how it's captured. |&:|, |&mut: |, and |:| corresponded to the Fn, FnMut, and FnOnce traits, respectively. If you look at the signatures of those traits, you'll see that Fn's method takes self by reference, FnMut takes it by mutable reference, and FnOnce takes it by value. In particular, this means that the body of an FnOnce closure can move values out from the closure (that's why it can only be called once), whereas Fn and FnMut can only access values in the closure by reference and mutable reference, respectively.

The way variables are captured from the environment into the closure is controlled by the "move" keyword. If the "move" keyword precedes a closure expression, then variables from the environment are moved into the closure, which takes ownership of them. "move" is usually associated with FnOnce closures, but it's also needed when returning a boxed Fn or FnMut closure from a function, as you can see below:

    fn make_appender(x: String) -> Box<Fn(&str) -> String + 'static> {
        Box::new(move |y|
            // The closure has & access to its captured variable, but
            // it has been moved into the closure so it outlives the
            // body of make_appender, thanks to the move
            // keyword.
            x.clone() + y
        )
    }

    fn main() {
        let x = "foo".to_string();
        let appender = make_appender(x);
        println!("{} {}", appender("bar"), appender("baz"));
    }
> The way variables are captured from the environment into the closure is controlled by the "move" keyword. If the "move" keyword precedes a closure expression, then variables from the environment are moved into the closure, which takes ownership of them.

Note: if you don't specify `move`, then the captures are determined in the usual way:

`|| v.len()` captures `v` via an immutable borrow

`|| v.push(0)` captures `v` via a mutable borrow

`|| v.into_iter()` captures `v` by moving it

Thanks for the correction.
Fortunately with the annotations gone, I think there will be a lot less confusion! The "move" keyword on its own is pretty straightforward, at least once you've learned Rust's ownership model.
I wish they also had the option of a pure lambda that isn't a closure. You can't pass a pure lambda to an argument expecting an ordinary function.
What was deprecated is the explicit closure type specification, which is only the ":" part.
Right. IIRC, now your choices are, roughly:

    |args| expr // upvars captured by reference, can't be called after function has gone out of scope

    move |args| expr // upvars moved from function to the closure context (or copied if trivially copyable)
This is simple and good enough for most use cases. If you want more complex schemes, you have to implement them manually, e.g. to reference count the upvars, like Apple blocks do by default, wrap them in Rc and capture that.

Accepting closures is a bit more complicated though.

That's not quite right. In the non-move case, the capture is inferred per upvar. See my other comment for details (https://news.ycombinator.com/item?id=9047766)