Ergonomics initiative discussion: Allowing owned values where references are expected

@aturon suggested starting a thread for this and provided this nice place to start:

The strawman proposal is to automatically coerce via AsRef or AsMut, which will have the effect of allowing owned where borrowed is expected. This automatic coercion is not like full-blown implicit coercions, because the types involved (going from a reference to a reference) strongly limit what you can do, much like with Deref and deref coercions.

We already use this pattern in std, but it'd be nice to have it apply uniformly and not have to muck up signatures with it.

6 Likes

I’d mostly like to add a usecase — allowing a function that accepts a reference to be implicitly converted when used as a Fn* trait:

struct Foo;

impl Foo {
    fn with_ref(&self) -> u8 { 42 }
    fn with_val(self) -> u8 { 42 }
}

fn main() {
    let a = Some(Foo);

    a.map(|f| f.with_val());
    a.map(Foo::with_val);

    a.map(|f| f.with_ref());
    a.map(Foo::with_ref); // Does not compile
}

I feel that this code appears inconsistent. I can’t just automatically see (|x| x.foo()) and replace it with (X::foo).

2 Likes

I don’t think this proposal would allow that, at least not in isolation, for the same reason deref coersions don’t allow this:

let v: Vec<String>;
v.iter().map(str::trim)...

Even then T coerces to U, a function taking U arguments doesn’t coerce to one accepting T arguments, nor does a function returning T coerce to one returning U. This could be a useful feature (even without what’s proposed here) but it’s a separate thing.

1 Like

Are you proposing that only for Fn types, or in general?

Would that apply to function calls? Could I call a function taking &str by giving it a String argument?

fn foo(s: &str) {}

foo("String".to_owned());

I am only stating that that's one place where I get bitten, so I'm hoping that the eventual RFC / feature can solve my problem :innocent:

I believe that I have heard suggestions that it should. That is...

foo("String".to_owned())

// Can desugar to...

{
    let arg1 = "String".to_owned();
    foo(&arg1);
}

Personally, I've grown used to that bit of explicitness (foo(&"String".to_owned())), but I can see how newcomers could be put off by it, so I'm not against it.

1 Like

@hanna-kruppe is right about this being a separate feature. These ref/deref coercions all happen at the method call site, you can think of them transformations of self.method(arg) into the ‘canonical’ <T as Trait>::method(self, arg). We don’t ever actually coerce between the types that those methods have right now.

I think I’d be more eager to see Scala style ‘lambda shorthand’ than these type level coercions, like a.map(self.foo($)) as a shorthand for a.map(|x| self.foo(x)) or something along those lines.

It seems like both of you are operating with the knowledge that there's already a planned implementation for how to address this ergonomic initiative, we plan on no further improvements, and my example will not be solved by that implementation. If this has already been formulated and decided upon, I apologize for wasting time. I'd appreciate being pointed to the appropriate RFC / issue / internals thread so I can read up.

Otherwise, I still feel like this fits squarely under the "use owned values where references are expected" banner. I have a closure where both an owned value and a reference already work using the longer syntax. When I change to shorter syntax, it no longer works. If there's a proposed implementation that doesn't "fix" this case, perhaps that implementation also doesn't "fix" the ergonomics goal (or to the extent we want).

As a familiar user of the language, I get caught on this more than I care to admit. I'd expect (but have no proof) that people newer would hit it more frequently.

The feature that we had in mind is only vaguely sketched, but its about applying coercions to non-receiver arguments along the same lines as the deref coercions that method receivers get. As @hanna-kruppe pointed out, the feature you’re talking about applies equally well to the existing coercions the receiver gets (coering an &self method to an Fn(Self)), and so even if we didn’t do that this feature request would still be relevant.

Its not a bad request and I agree that the current behavior is a bit of a wart (and probably why the ‘point free style’ is not more popular). I am a bit skeptical it will work well with type inference and existing coercions though - if I’m wrong I’d be happy to find out.

The original proposal was essentially just that if you have an argument &T, and pass a type U where U: AsRef<T>, the compiler will accept it and insert an as_ref coercion. The justification was that:

  • It would let you pass a T to an &T argument, but you would lose ownership, in other words ‘both sides get what they want.’
  • The APIs that take e.g. P: AsRef<Path> become cluttered and difficult to understand, but are very popular. Let’s just build that into the language so its not distracting in the API surface.
  • AsRef coercions are necessarily cheap (unless doing some pathological side effect) because they have to return a reference (this is unlke Into).

We could also consider the same thing with AsMut and &mut T but I’m concerned with how that would essentially create a C++ call-by-reference situation for Copy types:

let mut x = 0;
let mut y = 1;
mem::swap(x, y);
//x is now 1 and y is now 0, because swap takes &mut T, &mut T

@shepmaster’s idea actually fits into this in a sort of interesting way: we could define some analog to AsRef for function types so that essentially fn(&T): AsRef<fn(T)>. In other words, its not that the coercion happens at the level of the type but still just when passing the argument (so an fn(&T) is not coercible to fn(T) in general).

(Its also interesting that this ownership coercion is essentially contravariant for higher order functions, just like subtyping.)

EDIT: I also remember that we probably want to apply any AsRef coercions to binary operators as well, isislovecruft posted an RFC issue which this would be a solution for: https://github.com/rust-lang/rfcs/issues/1936

3 Likes

It doesn't seem like it should be possible to see the effects of the swap in this case. I would expect the expansion to be equivalent to something like

let mut x = 0;
let mut y = 1;
{
    let mut x = AsMut::as_mut(_copy(x));
    let mut y = AsMut::as_mut(_copy(y));
    mem::swap(x, y);
}
println!("{}, {}", x, y); // => "0, 1"

Basically the Copy expansion would happen inside the AsMut expansion, so you would be passing in mutable references to temporaries, not the original values. It could be annoying that the code compiles at all since it's a no-op, but maybe there could be a lint for some functions that don't make sense to be called via AsMut.

Random thought, this would make the following two statements equivalent:

mem::swap(&mut x, y);
mem::replace(&mut x, y);

AsRef is not complete as it is: T does not implement AsRef<T>, except in a few special cases. This seems to me a major missing thing before any kind of automatic coercion goes through AsRef.

The discussion here seems to presume a different state of the AsRef trait than the present. :wink:

2 Likes

Aren’t Borrow and BorrowMut the right traits for this?

In any case, there are big foot guns here so actually doing this stuff should wait until the current effort to unify the handling of ref in pattern matching in for and match reaches some conclusion and even approaches stabilization. Of course, this discussion is helpful in clarifying what should happen with match patterns.

This is true but it will be rectified as soon as we have intersection specialization. :slight_smile: If this feature were to be implemented earlier we could have the rule by T | U: AsRef<T>.

This is interesting; there are essentially two desugars we could use here:

// Given:
foo: fn(&T)
x: U where U: AsRef<T>

// Surface code:
foo(x)

// Desugaring A:
foo(AsRef::as_ref( { x } )) // note the braces forcing a move (therefore a new copy)

// Desugaring B:
foo(AsRef::as_ref(x))
drop(x) // drop after asref

Your interpretation is the "braces" version, whereas mine was the "drop" version. I think the braces version is more elegant. I worry it will still surprise people though, just in that they expected call-by-ref behavior.

The choice of AsRef was to support more flexible conversions like String -> Path but this is definitely an open question.

For what it’s worth, having to use foo(&bar) where bar is an owned type and foo() takes a reference was one of the reasons that caused me to cast aside Rust on one of my first attempts to write it. To me, since non-mutable references are cheap and harmless to create, there’s not a lot of value in making it explicit at the call sites.

I do feel that the trade-off is quite different for &mut, though. Making mutability explicit in argument passing feels very useful even independent of the borrow checker, and it protects users from a different class of errors.

1 Like

My understanding of the proposed feature is that you'd still have to take an explicit reference if you don't want to transfer ownership. In code:

struct Thing;

fn foo(_: &Thing) {}

fn currently_does_not_work_but_would_with_the_proposal(thing: Thing) {
    foo(thing);
}

fn would_not_work_even_with_the_proposal(thing: Thing) {
    foo(thing);
    foo(thing); // Probably "Error: use of moved value"
}

Are you proposing that the second example should work? That feels similar to C++'s pass-by-reference.

Yeah, I was expecting that your second example would work. It seems like it’d be pretty surprising if you pass an owner binding to a function that takes a reference, and suddenly it ends up being moved. I guess it might be okay if you have a good error message about it, but to me the Do What I Mean behavior is more like just adding the & where needed.

We already have an error message for this:

struct Bar;

fn foo(_: Bar) {}

pub fn main() {
    let bar = Bar;
    foo(bar);
    let baa = bar;
}
error[E0382]: use of moved value: `bar`
 --> <anon>:9:9
  |
8 |     foo(bar);
  |         --- value moved here
9 |     let baa = bar;
  |         ^^^ value used here after move
  |
  = note: move occurs because `bar` has type `Bar`, which does not implement the `Copy` trait

The only difference we'd now have, is that foo would be fn foo(_: &Bar) {}, so the error message could include

help: did you mean to pass by reference: foo(&bar)

in case of implicit owned->ref conversion

I can understand why this is surprising. OTOH, I've definitely found the opposite in the past as well (when Rust used to do more coercions by default, such as when we used to do Box<T> -> &T by default) -- that I found it confusing to see foo(x) not as a move of x but rather a borrow. It's nice to able to reason relatively locally about where things get moved or borrowed, particularly given how important borrows are for the borrow checker's rules.

10 Likes

We don't need (or really want) this reflexive impl for the coercion. Coercions generally apply when the type wouldn't otherwise match. If you're in a context where you have &T and &T is expected, coercions won't even be used.