Can Option::map() be simplified?

This topic is not a concrete proposal. I am making it for discussion because this is a problem I find frequently when working with beginners in Rust.

I think one of the challenges that beginners to Rust face is how closures are needed excessively, especially when handling options. Closures as concept is sometimes not familiar to novice programmers and the syntax can be a challenge too. Even for people more acquainted with Rust, closures can turn ugly and complex quickly.

In Kotlin for example this not a problem because the ? operator function like Option map.

fun main() {
    var message: String? = "";
    
    var len: Int? = message?.length;
}

Where in Rust you would write:

fn main() {
    let message = Some("");
    
    let len = message.map(|e| e.len());
    // or 
    // let len = message.map(str::len);
    // but this is not always possible
}

It is also possible to avoid closures by using a separate function however it can be not convenient.

fn main() {
    let message = Some("");
    
    let len = get_len(message);
}


fn get_len(message: Option<&str>) -> Option<usize> {
    Some(message?.len())
}

If rust for example had syntax or mechanism to simplify these situations it could be simpler to use (although its complexity would increase)

Example of some imaginary syntax (which I don't support) where x!.function() can be roughly equal to x.map(|e| e.function()).

use std::net::IpAddr;

fn main() {
    let address = Some("1.1.1.1");

    let address: Option<IpAddr> = address!.parse()!.ok().flatten();

    // instead of this
    // let address: Option<IpAddr> = address.and_then(|e| e.parse().ok());
 }

I am not sure this is simpler.

The Bang dot !. operator is used in typescript as semantics for unwrap().

What you want is actually a pipeline operator, like |> , it's... somehow ugly.

It would be great if the function reference syntax were more powerful, e.g. by adding autoref/deref support (probably requires generating shim functions). Java and Scala do this. Partial function application might be useful too.

The resulting code might not be much shorter, but it'd contain fewer sigils and would read more like text.

Another advantage if they were more commonly used would be that those functions items could become nameable which could with specializing some frequently-passed closures. Simplified example: one could turn a .reduce(Add::add) into a .sum() if that's beneficial.

1 Like

I think this would be a case where postfix macros could be useful.

message.map!(.len());
message.try!(?.len());

expanding to

message.map(|_0| _0.len());
try {
    message?.len()
};
5 Likes

When I was learning C, the syntax for reading a variable was horrible.

int value;
scanf("%d", &value);

I do remember one of my classmate asking “why the &?”, but at this stage of learning, a simple “this is what you have to do, just copy paste this template” was more than enough, assuming that the compiler was giving an easy to recognize error (not even easy to understand, just recognizable).

Because of this experience, I really don’t think that syntax is what is hard when learning, or when writing. Only when reading. So I’m not sure that special casing Option::map is a good idea.

And map is not unique to Option, so learning what Option::map is will help them understand Iterator::map later, while a dedicated syntax for Option::map would not help for the later.

Making calling str::len or making short lambda even shorter may help. That being said, even if I do think that C++ needs a much shorter lambda syntax, I’m not sure that Rust does. Even if something like message.map(_1.len()) would be nice. Having multiple form of lambdas increase the complexity budget of the language.

7 Likes

I'd love to find a way to get this fixed. We should be able to have coercions and stuff when passing function items. It always makes me sad when changing .map(|x| foo(x)) to .map(foo) doesn't work.


The other thing that's available on nightly is that foo.map(|x| x.bar).map(|x| x.qux) can be written as try { foo?.bar?.qux } (modulo some type annotations needed that we'll hopefully fix soon™). So that might help some of these cases, and starts to look more like it does in Kotlin or C#, just with a block for visible scoping.

EDIT: err, not quite; see below.

16 Likes

Maybe something like an Into for Fn's? Sounds plausible with specialization or something.

Due to possible combinations of ref/deref/copy adjustments on arguments and the return value I don't think we can express this as traits.


struct Foo {}

impl Foo {
    fn foo(&mut self, a: &u8, b: u8) -> &u8 {
        &0
    }
}


fn bar(fun: fn(Foo, u8, &u8) -> u8) {}


fn use_it() {
    bar(|mut f, a, &b| *f.foo(&a, b))
}

would ideally be writable as

bar(Foo::foo)

I was specifically thinking of something like:

// not currently legal for a bunch of reasons
impl <SourceFn, Target> Coerce<
  Fn(Target, SourceFn::Args[1..]) -> SourceFn::Output
> for SourceFn
where SourceFn: Fn,
          SourceFn::Args[0]: Coerce<Target>
{
   ...
}

What would be the benefit of being able to express it in the trait system rather than having the compiler just do it under the hood?

Makes it clear that some generic-ness / conversion is happening, eg. .map(fn(&Foo) -> Bar) is not the same machine code as .map(fn(Foo) -> Bar).

And though it's probably a really bad idea but if it was actually Into<> it would be user-customizable, e.g.:

impl Iterator {
  // guessing at the generics here
  fn map<F, R>(self, f: F) -> Map<Self, F, R> where F: Into<Fn(Self::Item) -> R>
}

struct MyMapper;
impl Into<Fn(Foo) -> Bar> for MyMapper {
  ...
}

Wouldn't this rather be foo.and_then(|x| x.bar).map(|x| x.qux)?

2 Likes

Introducing closures does cause a lot of trouble, not the least of which is the inability to use control flow from upper contexts such as return, break or continue, not to mention the potential performance loss. The same trouble occurs when trying to encapsulate recurring operations involving Result and/or Option and control flow into functions. This is mostly seen in a complex loop involving IO that requires more complex error handling logic than just returning an error with the question mark operator. Usually when this happens I have to write a macro like the old try! and put my own error handling logic in it.

1 Like

Actually why I feel this area is lacking in Rust is because of me thinking something similar. Having some syntax that is easily brute-forcable and remember-able by users can make them easier to prototype. Even if the users don't understand the language in depth. I probably had to explain closures and maps too many times and probably will continue to do so. If someone is learning, If I could only say try using the following (!, ->, |> or whatever operator) he could get into habit of trying this to prototype (maybe a bad habit).

The try keyword is nice, although I think it is more useful to people more acquainted with the language. Once it is stable I may try to recommend it more so that learners can use it when they feel stuck handling options.

I agree finding ways to reduce closures can help people acquainted with the language. Closures are useful and are concise in Rust but in my opinion they are currently excessively used.

Oops, yes it would.

I guess I can interpret that as more evidence that the ?. form would be nice, since it doesn't requiring remembering this stuff.

Making it user-implementable would have downsides. If we had a compiler-generated type that indicates function A adapted to signature B then we can reason about the properties of A if it is known. If it's user-implementable then the user might be injecting arbitrary logic in-between.

Still, try blocks solve the motivational example at the top of the post: try { message?.len() }. So they are a good first answer to avoiding map, though maybe they are a bit too heavyweight for that.

Just wanted to chime in and suggest ..: It is short, still reminds the developer of . for normal function calling and doesn't conflict with early returns on Option and Result ?, or macros !.

Example:

use std::net::IpAddr;

fn main() {
    let address = Some("1.1.1.1");

    // I'm not sure if I've set the `..` right, compared to the original example which also contains two `!`.
    let address: Option<IpAddr> = address..parse()..ok().flatten();
 }

.. still reminds of the two ways functions are called in rust: MyType::parse(x) and x.parse() and is thus probably easier to remember than |>.

The main problem would be distinguishing between the rang syntax 3..4 and this, as the following could be interpreted in two ways, at least if Options can be the start of a range:

address..parse()

// Call function on address
address.map(|x| x.parse())

// Call parse and create a Range
fn parse() {}
let end = parse();
address..end // Range

The problem is, Options can be at the start of a range, since ranges are generic. For example your address..parse() is already valid if parse() if a free function returning Option<&str>.

I don't think your proposal is any less ambiguous than ? or !

3 Likes

Speaking for myself, one slightly confusing part of the rust journey is:

  • First, get used to the idea that it's often better or more idiomatic to use the standard library iterators, combinators like .map etc., and .collect, than use explicit for loops, collect results into mutable variables, use if let Some(...) = ... to unpack options, etc. Even though for loops and if statements may be very familiar to a beginner, the combinators style avoids some explicit mutable state, so it can avoid some bugs.
  • Then, as you advance and start doing async stuff, discover that none of this combinator stuff is async, and that once you have to do something asynchronous within the closure you pass to Option::map or similar, many would say it's better to go back to if let Some(...) at that point.

I'm left with the overall idea that it's perhaps not essential for beginners (or veterans) to learn all the combinator stuff cold. If it makes your code shorter for now, great, but you need to be prepared to rework all that to be for and if let if you have to start introducing async calls in there. So it may even be simpler to just start with for and if let in the first place and not bother with combinators, since you may end up there anyway. And maybe learning the whole combinator style is not something that's worth it for beginners to focus on.

4 Likes