Hacker News new | past | comments | ask | show | jobs | submit login

Literally everything you're complaining about is down to differences in abstraction and detail, not aesthetics.

The stuff in square brackets is the capture list. Unlike python, which implicitly captures the full outer scope by reference, C++ allows/requires you to specify which outer variables you want to reference in the lambda, and whether they are captured by value ("=") or reference ("&"). This allows C++ to implement the lambda with a simple static function in most cases, where python needs a bunch of tracking data to preserve the outer stack from collection.

The rest of it is all just following the existing syntax of the language: C++ uses curly braces where python uses bare expression. C++ is typed and requires an "int" declaration on the argument. C++ puts a semicolon at the end of its expressions. You don't have to like this per se, and you're welcome to call it ugly if you want. You're just 30-ish years late to the argument, though.




Rust supports the same level of power and abstraction, yet it makes various aesthetic (ish) decisions that bring it closer to Python than C++ in this example.

Rust implements lambdas the same way as C++, yet it doesn't need capture lists. It has two modes: by default it tries to guess whether to take each variable by move/copy or by reference, but you can specify 'move' on a lambda to have it move/copy all mentioned variables. Not as flexible, right? Actually, it's equivalent in power, because if you want to "capture a variable by reference" in a 'move' lambda, you can manually assign a reference (pointer) to it to a new variable, and move that. With Rust's syntax, the new variable can even have the same name as the original, so it looks very natural:

    {
         let x = &x;
         foo(move || bar(x));
    }
This is a bit more verbose, but most of the time you don't need it.

Like C++, Rust uses semicolons, but it largely unifies expressions with statements. For example, the following are equivalent:

    foo(bar(42));
    foo({ let x = 42; bar(x) })
The syntax for lambdas is "|args| return_expression", so a simple lambda can be very succinct: "|x| x + 1". But just like above, return_expression can also be a braced block, allowing you to have large bodies with multiple statements. In most languages, supporting both blocks and expressions as lambda bodies would require two forms of lambda in the syntax, an added complexity. C++ conservatively chose to support only blocks, while JavaScript and Swift, among others, chose to have two forms. But in Rust, that's just a special case of a more general syntax rule.

Rust is statically typed, but it has type inference, so - among other things - you can usually omit the types of lambda arguments.

So what does the adder example actually look like in Rust? With soon-to-be-stable 'impl Trait' syntax, like this:

    fn adder(amount: u32) -> impl Fn(u32) -> u32 {
        move |x| x + amount
    }
The type declaration is somewhat verbose, but the implementation is quite succinct. The only ugly part IMO is 'move', which would be better if it weren't required here. (Without 'move' it tries to capture 'amount' by reference and complains because that wouldn't borrow check [because it would be a dangling pointer]. But it would be nice if the default lambda mode could decide to move in this case, either because of the lifetime issue or just because 'u32' is a primitive type that's always faster to copy than take an immutable reference to.)


FWIW: this exact scenario (capture a local and add it to the lambda's argument) is treated in the Rust book on closures, and the solution picked isn't yours. They want to use Box::new() to explicitly allocate a heap block to track the closure (in C++ the compiler does this for you and puts it into the function type):

https://doc.rust-lang.org/book/closures.html

I'm not expert enough to decide which is "official". But honestly... this is really not a situation where Rust shines aesthetically.


This is because "impl Trait" was accepted for stabilization about a week ago; the book only covers stable things.


C++ actually has the same problem; you can't specify the return value of an unboxed lambda. The differences there are that C++ lets you deduce function return types since C++14, something Rust doesn't want to support, and that boxing is done through a special std::function type, rather than a normal heap pointer.


> something Rust doesn't want to support

Well, in this way. "impl Trait", which was just accepted for stabilization, will let you return unboxed closures.


The reason Box is used there is that the type of a closure in Rust is anonymous, so can't be named as a return type. Once the `impl Trait` syntax for returns is available, the heap allocation won't be necessary any more, because you'll be able to write `fn factory() -> impl Fn(i32) -> i32 {`


So... a three-character capture list is ugly, but that extra impl clause is... fine? Obviously we shouldn't be getting into a Rust vs. C++ flame war in a clang thread, but would you at least agree that (relative to python) both language have significantly more verbose syntax owing to the interaction between closures and the languages data models?

Edit to clarify (to the extent of my rust understanding anyway): C++ didn't want to implicitly suck in external variables by reference and makes the programmer explicitly flag them as references. Rust has a borrow checker, so it can skip this part and simplify the syntax. But Rust's type system has no good way to implicitly figure out the full type of a lambda by its signature, so if you want to pass one around you need to specify its whole type signature twice, once in the expression itself and then again in the signature of the function that will receive or return it.

You pays your money and you makes your choice. Neither language is really a good fit for anonymous functions in the sense that Lisp was.


The extra syntax in this case is all about static typing (and in the case of Rust, exacerbated by having lifetimes as part of the type).

> But Rust's type system has no good way to implicitly figure out the full type of a lambda by its signature, so if you want to pass one around you need to specify its whole type signature twice, once in the expression itself and then again in the signature of the function that will receive or return it.

Rust has no problem figuring out the type of the lambda. But the type of the lambda is anonymous, and unique to that particular lambda. That type is guaranteed to implement the Fn trait, so that's what you specify when you need to return it. You don't need to specify it twice, however - I'm not sure how that follows from the snippet above?

And C++ has the same exact problem: C++ lambdas also each have their own unique type. However, C++ doesn't have traits at all. In C++, if you want to return a lambda, you'd use std::function, which is roughly analogous to Rust's Box<> for functions in this case - you can't just return the lambda directly (well, you can, but only in a context where the return type is implicit and can be inferred from usage - e.g. when returning from another lambda).


In C++14 and later you can return a lambda by using auto as a return type, which compared to impl Trait is shorter but less strongly typed (so more prone to confusing errors).


> So... a three-character capture list is ugly

IMO, it's 'ugly' not because "[=]" is inherently syntactically ugly, but because of all the variations of capture lists and the subtlety of the differences.

> but that extra impl clause is... fine?

It's not great - in fact, 'impl Trait' is a pretty subtle feature as well. But I think it's more principled.

> would you at least agree that (relative to python) both language have significantly more verbose syntax

Yes…

> owing to the interaction between closures and the languages data models?

…Not really. I'll elaborate later in the post.

> so if you want to pass one around you need to specify its whole type signature twice, once in the expression itself and then again in the signature of the function that will receive or return it.

No, you don't. Generally speaking, it needs to be specified at most once, sometimes zero times. In my example:

    fn adder(amount: u32) -> impl Fn(u32) -> u32 {
        move |x| x + amount
    }
the type is only written once. Admittedly, the expression has its own list of arguments ("|x|" versus "(u32)" in the type), but in the expression, only the names are specified, not types.

The caller of `adder` generally wouldn't need to specify the type:

    let a = adder(1);
    println!("{}", a(2)); // 4
…but if it's stored in a struct or passed across additional function boundaries, it may have to be repeated.

When would it have to be specified zero times? Well, for one case, if there aren't any function boundaries involved:

    let amount = 1;
    let adder = |x| x + amount;
    println!("{}", adder(amount));
But also, higher-order functions are often defined once and used many times. For example, the `map` method on Iterator is defined in the standard library with a full (generic) type signature, but I don't need to declare any types to use it:

    let v = Vec::from_iter(vec![2, 3, 4].iter().map(|x| x + 2)));
    println!("{:?}", v);
Admittedly there's a lot more noise there in general than Lisp or Python, but a lot of that is a desire to be explicit about allocations, not directly related to anonymous functions.

FWIW, not doing type inference across across function boundaries is a semi-artificial limitation. Haskell can do it just fine despite being statically typed, and although Haskell has very different implementation strategies, that's not related to type inference. C++ now sort of does it with `auto`, but only does 'forward reasoning' - i.e. it can deduce the type of an operation's result from the types of its operands, but not vice versa. Rust has true type inference, but it eschews global type inference (across functions) mainly because of a tendency to cause hairy/confusing errors in larger programs. (There are also compilation time concerns, but they're not the primary reason AFAIK.) I suppose you can say that dynamically typed languages are easier to understand when they go wrong, compared to hairy Haskell errors - but I'm not sure to what extent that's actually true, as opposed to functional programmers just tending to use more complex types.

By the way, C++(14) actually doesn't require the lambda argument type to be specified in this case. This works fine:

    auto adder(int amount) {
        return [=](auto x){ return x + amount; };
    }
(but not because of type inference; rather, the returned closure implements operator() for any argument. Also, 'amount' can't be 'auto', which is also an arbitrary limitation, but the rationale is pretty confusing to me considering that the return type can be 'auto'.)


> Literally everything you're complaining about is down to differences in abstraction and detail, not aesthetics.

I'm not complaining. Just pointing out how it's notably uglier, a critical part of being 'Pythonic', which affects both aesthetics (emotional experience) and functionality. That's not a value judgement but an observation.

Python is obviously more limited in power (functionality), which is a product of decisions made about base abstractions. That filters upwards and becomes apparent in both visuals and utility (and as a result learning curves, developer happiness, applicability to problem sets, etc).


"Ugly" is a subjective aesthetic judgment, not an observation.


If it's merely a consequence of choices made to support complexity then I disagree. The aesthetic and utility of the design is a product of many limitations and hard choices made early on. C++ will always be an ugly language because those trade offs were made from the beginning to support a breadth of functionality and configuration.

Simplicity and accessibility are extremely challenging things to support when you have a product that does everything for everyone.

The need for 5 different lambda expressions is a perfect example of that.

I personally think C++ still has a role in the world, as it clearly has lots of adoption. But if you want non-Ugly then those various use-cases have to be broken up into single-purpose (or niche) languages such as Rust for systems programming and Python for 'scripting' and Lua for glue/config code. All of those languages listed have made hard tradeoffs to be good at certain things. C++ and Java did not - largely because they are "yes" languages, as Steve Yegge calls it (the language designers said yes to everyone early on).

If Ugly languages were merely 'bad' or 'wrong' for being ugly then they both would be the most used languages. They clearly have long-lasting value in the industry - which people like Yegge believe is because they are okay with being ugly.


> This allows C++ to implement the lambda with a simple static function in most cases, where python needs a bunch of tracking data to preserve the outer stack from collection.

This isn't inherently about capture lists; Rust doesn't use capture lists, yet ends up doing the same thing as C++ here.




Join us for AI Startup School this June 16-17 in San Francisco!

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

Search: