Hacker News new | ask | show | jobs
by kris-s 1254 days ago
The author mentions the following:

    fn add(x: Option<i32>, y: Option<i32>) -> Option<i32> {
        if x.is_none() || y.is_none() {
            return None;
        }
        return Some(x.unwrap() + y.unwrap());
    }
    The above looks kind of clunky because of the none checks it needs to perform, and it also sucks that we have to extract values out of both options and construct a new option out of that. However, we can much better than this thanks to Option’s special properties! Here’s what we could do

    fn add(x: Option<i32>, y: Option<i32>) -> Option<i32> {
        x.zip(y).map(|(a, b)| a+b)
    }
Do folks really prefer the latter example? The first one is so clear to me and the second looks inscrutable.
15 comments

You do get better about using the functional operators as you use them, and they can be incredibly powerful and convenient in certain operations, but in this case he's missing the simplest implementation of this function using the `?` operator:

    fn add2(x: Option<i32>, y: Option<i32>) -> Option<i32> {
        Some(x? + y?)
    }
Wait! The ? operator works with the `Option<T>` type? I assumed it was only reserved for `Result<T,E>`.
To slightly elaborate on yccs27's good answer, at the moment, you can use ? for both Option<T> and Result<T, E>, but you can only use them in functions that return the same type, that is

  fn can_use_question_mark_on_option() -> Option<i32> {

  fn can_use_question_mark_on_result() -> Result<i32, ()> {
if you have them mixed in the body, they won't work directly. What you should/can do there depends on specifics. For example, if you have a function that returns Option and you have a Result inside, and you want any E to turn into None, you can add .ok() before the ? and then it still looks nice. (The compiler will even suggest this one!)
And an even smaller caveat: If you use older (distro provided) Rust versions note that it may look like there is some partial implementation of allowing mixing with NoneError, etc. Ignore this. It doesn't work, and was removed in later versions.
But also: don't use your distro provided version of Rust. It's intended for compiling distro packages that depend on Rust, not for developing with Rust. Get Rust from https://rustup.rs
And hence for writing rust code you would like to package for your distro of choice. I publish my software because I want others to use it, and including it in distro repos is the most friendly way to do that.
It currently works with `Result` and `Option` types, and with RFC#3058 will work with all types implementing the `Try` trait.
Monadic do notation equivalent at that point, I suppose.
Although that would be really fun, the proposed `Try` basically only allows you to call the `Result<_, E>` by a different name.
I also (written a few tools in rust, dabble now and then) was under the impression that it only worked with Result. Is this recent or just never encountered?
? is able to be used with Option as of Rust 1.22, released in November of 2017 https://blog.rust-lang.org/2017/11/22/Rust-1.22.html
Well, I guess this counts as a concept I wish I learned earlier!
TLDR; learning Rust through canonical code in tutorials often requires the student to learn bits about the language that are more advanced than the actual problem the resp. tutorial tries to teach how to solve in Rust. ;)

I prefer the latter now that I understand how all the Result/Option transformations work. As a beginner this would be hard to read but the former looks clunky.

Clippy also got pretty good lately at suggesting such transformations instead of if... blocks. I.e. I guess that means they are considered canonical.

In general I find canonical Rust often more concise than what a beginner would come up with but it does require deeper understanding. I guess this is one of the reasons why Rust is considered 'hard to learn' by many people.

You could actually teach Rust using pretty verbose code that would work but it wouldn't be canonical (and often also not efficient, e.g. the classic for... loop that pushes onto a Vec vs something that uses collect()).

This is very true - to fully explain a "hello world" program you'd have to dive into macro syntax... When writing my Rust book I often start by showing the naive solution, and then later move to more canonical code once I've introduced more syntax that enables something more elegant. But I'm aware that I'm showing something non-optimal to start. Maybe that loses some trust, like people think they're learning something only to be told later on that it's wrong? On the other hand if you start with the optimal solution you have to teach so much syntax that it's overwhelming. I expect that some folk want every example to be perfect, but I'm going with an approach where you iterate through each chapter and as you progress through the whole book the examples get better and more idiomatic.
This is basically an eternal battle when teaching. Personally I prefer to try and stay to only idiomatic code if at all possible, but there's pros and cons to each approach.

(This is one reason why I'm glad other people are also writing books! Not everyone likes my style!)

Or when necessary, use the verbose approach to lead toward and explain the idiomatic approach!
Even then: code examples get copied. People go "oh this works for me" and don't bother reading on to the better approach.

It's tough :)

I'd probably write this

    fn add(x: Option<i32>, y: Option<i32>) -> Option<i32> {
        match (x, y) {
            (Some(x), Some(y)) => Some(x + y),
            _ => None,
        }
    }
I like this the best. It is dumb and clear. Anyone can read this even with minimal to no Rust experience but it still flows elegantly.
I'm sorry, but this perspective is absolutely bizarre to me.

We should not avoid language features that reduce boilerplate and drastically increase comprehension for for people who have experience in a language in order to cater to people who have minimal or no experience in that language.

Should we not use `?` in Rust because it might be obscure to someone who's never used the language? Should we not use any of the `Iterator` functions (including `zip` and `map`) because they might be confusing to C programmers who only know `for` loops?

Functional concepts are everywhere these days. Most of them are not hard. `zip` and `map` do not require understanding of homotopy type theory to understand, they are essentially trivial functions. They are available across every type you could possibly iterate over in Rust, and if you understand what they do on one of those you essentially understand what they do on all of them.

This is a toy example, but virtually every piece of hard evidence we have in this field shows that—within reason—more concise code has fewer bugs and is easier to comprehend than longer, more verbose equivalents. Writ large across a project, doubling or tripling the amount of code to bend over backwards accommodating complete novices is lunacy.

I half agree. The question mark version seems to be good as well, in fact also more idiomatic.

If the zip/map version is more common then I take everything back. But it seems less malleable and clear than the pattern matching examples. I had to stop and think for a second to get it. I find pattern matching in general more declarative for small amounts of items.

---

In terms of preferring clearer and simpler code: Absolutely yes. I avoid unnecessary abstractions, especially if the gains are so minor or questionable. It's a matter of empathy and foresight.

Whether that's the case here: I don't know. It might very well be that this is common and clear in the Rust world.

Every abstraction is unnecessary. And the gains are almost always minor… until you apply some of those abstractions across an entire code base.

C-style `for` loops were the norm for decades. Now virtually every language gives you some ability to iterate directly over every element in a collection. Replacing a `for` loop with an iterator over each element is never necessary. The old way worked for decades. The gains are minor. Should we go back to C-style `for` loops? If not, why not?

When you understand the answer to that, you’ll understand why that same logic applies to trivial functions like `zip` and `map` that simply take the idea one minor step further.

I’m not arguing against abstraction in general. I argue that clarity comes first and that in this specific instance pattern matching seems more clear to me.
The `zip` and `map` functions used here are actually functions of the module std::option, and not std::iter. While they are the same idea "in essence", they have different implementation. The std::option ones are a simple pattern match, while the std::iter ones are more complex. For example, std::iter::zip returns an std::iter::Zip, while std::option::zip returns an Option of a tuple.

I'll also add that option's zip and map are also implemented with a pattern match, like above.

One error and one deviation from the established norm for a toy example is a lot. At the scale of a codebase it would be a catastrophe.

Yes, I know.

But if you look closely you'll notice that `zip` and `map` were called directly on an array here and not actually on an iterator. That's a third implementation of the same concepts. If Rust had HKTs they could all be the exact same implementation, but not today.

The important thing, though, is that they all conceptually do the same thing. Understanding one essentially translates to understanding them all. If zip/map are called directly on two Options, you get an Option containing a tuple back out. If they're done on two arrays, you get an array containing tuples back out. If they're done on two iterators, you get an iterator containing tuples back out.

There's nothing to be confused about.

> But if you look closely you'll notice that `zip` and `map` were called directly on an array here and not actually on an iterator.

No, I don't think that's true, unless we're talking about two different things. In the article, and in the following post https://news.ycombinator.com/item?id=34428999, zip is user on an Option<i32>, takes another Option<i32>, return an Option<(i32, i32)> (which is a tuple, not an array), on which map is applied to extract the two values and add them.

> If zip/map are called directly on two Options, you get an Option containing a tuple back out. If they're done on two arrays, you get an array containing tuples back out. If they're done on two iterators, you get an iterator containing tuples back out.

But that's my whole point. std::option::map is not the same function as std::array::map, which is not the same function as core::iter::Iterator::map. One big difference, for example, is that core::iter::Iterator::map is lazy, while the others are not, hence the note to try to avoid chaining std::array::map, and being careful around it in performance-critical code: https://doc.rust-lang.org/src/core/array/mod.rs.html#466.

Even with HKTs, while you could share some code, that wouldn't solve the fact that the "direct map" (std::option::map for example) is strict, and the other map (std::option::iter::map) is lazy. Especially in a language often used for performance-sensitive tasks, I can't agree that understanding one map translates to understanding them all, since that would be ignoring part of their ergonomics, and more importantly their performance characteristics.

Agreed. That's way more readable.
I think the idiomatic way to write that is:

  fn add(x: Option<i32>, y: Option<i32>) -> Option<i32> {
      Some(x? + y?)
  }
> Do folks really prefer the latter example? The first one is so clear to me and the second looks inscrutable.

Literally everything in programming is inscrutable until you learn it the first time. The latter should be trivial to understand for anyone who's spent even a little amount of time in a language with functional elements.

A day-one beginner doesn't understand a `for` loop. You probably think they're trivial. Bitwise operations are the same. They might be new to you, but `zip` and `map` frankly don't take much more effort to understand than anything else you probably take for granted. `zip` walks through everything in two separate wrappers and pairs up each element inside. `map` opens up a wrapper, lets you do something with what's inside, and re-wraps the result.

For instance, you can do the exact same thing with arrays. Pair up each element inside (like a zipper on clothing), then for every element inside, add them together:

    [1, 3].zip([4, 1]).map(|(a, b)| a + b ) # [5, 4]
That said, you can write this specific function even simpler:

    Some(x? + y?)
The latter is a lot clearer and simpler. The former requires me to reason about control flow, if, and early return, a whole bunch of magic concepts. The latter is just an expression made of normal functions; I could click through and read their implementation if I was confused.
Both are rather ugly. This is much more idiomatic:

    match (x, y) {
        (Some(x), Some(y)) => Some(x + y),
        _ => None,
    }
Don’t know Rust, but wouldn’t this have to be:

    (Some(x), Some(y)) => Some(x + y)
    else => None
You're correct, except that "else" is a keyword and so cannot be used there. You'd want

  _ => None,
instead, which is the "catch all" arm.

(For those that didn't catch it, the parent code is trying to use None, but it's actually a tuple, and there's four different cases here, not two. So the catch-all arm is better than spelling each of them out in this case.)

You're right – fixed. That's what I get from writing code in a simple text area.
Your "fixed" version is also broken (at time of writing). :-)
Fixed again. I'm starting to run out of excuses... ;-)
This has been a thing since Rust 1.0. Just use the beautiful properties of match (or the "later" `if let`, of course). I prefer this and wish I could say it was idiomatic, but some tools like clippy push users over to helper methods instead of simple applications of match.
Pretty sure clippy will tell you to rewrite it as:

    if let (Some(x), Some(y)) = (x, y) {
       Some(x + y)
    } else {
       None
    }
`match` in place of `if` looks weird. IMO example with `zip` is better though.
Clippy will not complain about the parent's code. It's not really in place of an if; there's four cases there. To be honest, I find 'if let... else None' to be worse looking than the match, though I'm unsure if I truly prefer the zip version to the Some(x? + y?) version.

    Some(x? + y?)
Once you know what map does with an option I'd say it is mostly pretty readable. Basically map (when run against an Option value) is a way to say "if the value passed in has a value run this function on it otherwise return None.
The first one is very clear, I agree. However if I wrote Rust daily, I would probably be familiar with the second one and would prefer it. Here's an article kind of related to that, in this case talking about APL, that I think explains very well the tradeoffs: https://beyondloom.com/blog/denial.html.

To try with my own words: programming is about shared understanding of a problem, but also the tools used to solve the problem. Code is text, text has a target audience. When it is experts you can use more complex words, or more domain-specific words. When it's intended for a wider audience, taking the time to explain and properly define things, sometimes multiple times, can be necessary.

According to Rust's documentation of Some:

> zip returns Some((s, o)) if self is Some(s) and the provided Option value is Some(o); otherwise, returns None

> zip_with calls the provided function f and returns Some(f(s, o)) if self is Some(s) and the provided Option value is Some(o); otherwise, returns None

Using zip_with seems more appropriate (x.zip_with(y, +) or something) but zip_with is nightly. I also don't like how object chaining makes so that x seems more "fundamental", or "in another category" than y and +, while really x and y are the same, and + is something else. The if solution shows clearly that x and y are the same, by treating them exactly the same. The second solution also introduces a and b from nowhere, doubling the number of variables used in the function. All small things, but I think it can help put words on why precisely the second isn't as readable as it may seem.

It's interesting how much can be said about a simple "add" function.

First one isn't idiomatic anyway with return. You can just do Some(x? + y?) though.
Using `zip` feels excessively clever to me. I'd probably prefer something in between, like `and_then` followed by `map`, or just matches.
Not sure if it's only me but after using `zip` for the first time in any language, I tend to overuse it too much while there are better, more idiomatic alternatives.
If you are fine with using `map` then I don’t see how this is “clever”. `zip` is basically “map2”.
What’s there to say? It works the same way as `zip` does for an iterator over a `Vec`. So if you understand `zip` as applied to an iterator over `Vec`, then you might understand `zip` applied to `Option`.

In other words:

Y is clear if you already understand X. Like how returning early is simple to understand if you understand if-blocks.

That’s the problem with replying to these kinds of questions: the response is so short and context-dependent that it can look curt.

EDIT: the first code is also inelegant in that it effectively checks if both values are `None` twice in the `Some` case: once in the if-condition and once in `unwrap()` (the check that panics if you try to unwrap a `None`).

Yea the latter code is unreadable to me and feels obfuscated, like the author is trying to force functional programming where it doesn't belong.
You might choose to believe that, but `Option` and `Result` are practically purpose-built in Rust to work extremely well with functional approaches.

And doing so greatly increases the likelihood that the compiler can produce perfectly optimal code around them.

My issue was with the `zip()` usage. For lists I know that it will stop short once one of the lists has run out of items, but I haven't seen it used this way to combine optional values. I'm assuming it only produces a result if all of the elements passed in are non-null (based on the prior code) but it still seems too clever IMO. IDK, maybe this is a common pattern I'm unaware of.
I've done a non trivial amount of functional programming and know what zip and map do but I can't off the top of my head work out how that example works.
`x.zip(y)` evaluates to `Option<(i32, i32)>`
Options can be mapped/zipped/“iterated” over (traversed might be a better word?).

So in this case it’s using that fact to form a tuple of non-null values whenever the option is not null, and then acting on that. I think it’s kinda neat, but I wouldn’t personally use zip in this case, I’d have gone with map or and_then depending on whether the output of my operation is T or Option<T>.

I'd prefer a match approach
As someone new to Rust, I look at the latter and see `.zip()` (apparently unrelated to python zip?), and then a lambda/block which intuitively feels like a heavyweight thing to use for adding (even though I'm like 90% sure the compiler doesn't make this heavyweight).

By comparison, the first one is straightforward and obvious. It's certainly kinda "ugly" in that it treats Options as very "dumb" things. But I don't need to read any docs or think about what's actually happening when I read the ugly version.

So TLDR: This reads a bit like "clever" code or code-golfing, which isn't always a bad thing, especially if the codebase is mature and contributors are expected to mentally translate between these versions with ease.

What you find "clever" or not is really a function of what you are most used to seeing. There are likely many folks who use combinators frequently who find them easier to read, myself included.

The first example, to me, is the worst of all worlds, if you want to be explicit use `match`. Otherwise, having a condition then using `unwrap` just feels like it's needlessly complicated for no reason... Just adding my subjective assessment to the bikeshed.

100%.

In the first example—the longer, tedious one—I have to look at the condition to make sure the resulting `unwrap`s never actually happen, and if I reason about it wrong I get an application panic.

    x.zip(y).map(|(a, b)| a + b )
The above is 100% clear, can obviously never panic, trivially produces optimal code, and is how you'd write the exact same operation to add elements between sets, arrays, or anything else iterable.

People act like the above requires a Ph.D. in Haskell when it really requires about fifteen minutes of playing around with basic functional concepts that are in at least half the popular programming languages these days. At which point you realize a ton of annoyingly tedious problems can be solved in one line of code that can be easily comprehended by anyone else who's done the same thing.

It's the same thing as driving on the highway. Anyone driving slower than you is an idiot, anyone driving faster than you is a maniac.

> (even though I'm like 90% sure the compiler doesn't make this heavyweight).

Yes, as of Rust 1.66, this function compiles to

  example::add:
      xor     edi, 1
      xor     edx, 1
      xor     eax, eax
      or      edx, edi
      sete    al
      lea     edx, [rsi + rcx]
      ret
It's the same zip, just think of an Option as being a list of length at most 1. [] = None, [1] = Some(1), etc.
It is kinda common pattern for some FP languages (Haskell), so it doesn’t seem too clever to me.
> So TLDR: This reads a bit like "clever" code or code-golfing, which isn't always a bad thing, especially if the codebase is mature and contributors are expected to mentally translate between these versions with ease.

You contradict yourself. You can’t deride it as “clever” (whatever the quotes mean) and then in the next breath say that it might be a practical style.

And yes, Rust code in the wild does look like this.