Ergonomics initiative discussion: Allowing owned values where references are expected

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.

I don't think so; those traits are more restrictive, and generally apply only to slice/owned views of types, where you expect equality, hashing, etc to be the same. AsRef, by contrast, just says that you can view one reference type as another.

What led you to think that Borrow would be better?

Can you elaborate? What footguns do you see? And what's the relation to the match proposal?

A bit of background: Rust makes a distinction between coercion and subtyping. Coercions only apply at the "top level" of a type -- so you can go from &&T to &T, but not Vec<&&T> to Vec<&T>. Subtyping comes in with lifetimes, and triggers at all levels: you can go from &[&'static T] to &[&'a T].

Now, consider what it would take to allow coercions to apply deeply; in a case like Vec<&&T> above, you would actually have to create a new vector, because you're changing the elements in a significant way. Even if we could give you the tools to tell Rust how to do this, it's not the kind of thing we would generally want to happen as part of an implicit coercion, I think.

However, there might be potential to apply coercions in more places than today, without applying them arbitrarily deeply. Here's how I would think of it:

  • The AsRef proposal is about adding a new coercion based on AsRef, which fits into our existing coercion story.
  • Separately, as a distinct ergonomics improvement, we should ask whether there are situations where we can apply coercions at the function level in general. It seems quite plausible -- it would effectively be sugar for writing a closure that simply calls the function (i.e., |x| f(x)), which would apply coercions.

I'd suggest starting a separate thread on that extension, and linking to it from the Ergonomics Roadmap tracker.

2 Likes

Just my own poor understanding probably. :wink:

I'd miss-understood proposal. It'd be dangerous to treat a mut X as a &mut X and continue using it after the call mutates it, but consuming an X by value so that you cannot use it later, while passing the &mut X sounds completely fine.

If anything, consuming by value to pass an &mut focuses the &mut noise onto the real mutable borrows and makes the code easier to read. :thumbsup:

1 Like

But if we're talking about passing owned values we pass a T to a function that takes an &T; without the reflexive impl we need a special case for that.

I can see the argument: because of the restrictiveness on borrow, its more conservative than using AsRef - you can pass strings as string slices, but not as paths. However I don't think this is an upside - because Borrow's restrictions are entirely unenforced, if we do this we encourage violating those restrictions & potentially lead to people with broken hashmaps.

4 Likes

I'm still really confused; but I think most of all I'm looking for an explanation about why my request is not part of completing the the initiative Allowing owned values where references are expected, which is the feeling that I get from phrases like "starting a separate thread", "it's a separate thing", "a separate feature". My guess is that I do not understand the true scope of the initiative.

  • If my request falls under the initiative, but is impossible to fix, I'd like to know that.
  • If my request falls under the initiative, but we don't care to address it (at least this year), I'd like to know that.
  • If my request has nothing to do with the initiative as worded, I'd like to understand why not. I'd then like to help rephrase the initiative to help avoid anyone else from being confused in the same way, and to help set expectations for the eventual feature.

With that request made, I'll attempt to restate my position to reduce any confusion I might have introduced. Using the previous code, this is my understanding of the state of the world with the initial proposal.

free function

struct Thing;

fn foo(_: &Thing) {}

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

fn currently_does_not_work_but_would_with_the_proposal_also(thing: Thing) {
    Some(thing).map(|t| foo(t));
}

fn would_not_work_even_with_the_proposal(thing: Thing) {
    Some(thing).map(foo);
}

We will be able to transfer ownership of objects to methods expecting references.

method

struct Thing;

impl Thing {
    fn foo(&self) {}
}

fn currently_works(thing: Thing) {
    thing.foo();
}

fn currently_works_also(thing: Thing) {
    Some(thing).map(|t| t.foo());
}

fn currently_does_not_work_but_would_with_the_proposal(thing: Thing) {
    Some(thing).map(|t| Thing::foo(t));
}

fn would_not_work_even_with_the_proposal(thing: Thing) {
    Some(thing).map(Thing::foo);
}

This is a place where an owned value is already allowed to act as if it is a reference (map(|t| t.foo()), but not when using a different syntax that appears equivalent (map(Thing::foo)). The proposal will make it even closer (map(|t| Thing::foo(t))) which means the fact that the non-functional syntax is even harder to explain.

2 Likes

So, I think you've got a really good idea here, and I'm pretty strongly in favor of some kind of "implicit wrapper functions" that will coerce their arguments. Nonetheless, I too would prefer to see it discussed in its own thread. Not because I think there's no thematic fit, but because it is a distinct sort of coercion, and i'd rather have the space to see it discussed in full.

I think @aturon's point was that this idea -- which, iiuc, is basically that if you have some func that implements Fn(&U), we might implicitly generate a wrapper fn that implements Fn(T) where T: AsRef<U> and have this wrapper call the original -- is actually even more broadly applicable than "allowing owned values where references are expected". That is, it can be used for that, but we could also generate wrappers performing other, unrelated sorts of coercions (e.g., something this reminds me of was the idea that you could have one Rust fn (fn foo()) automatically generate wrappers with different ABIs, so that you don't need to manually write extern "C" fn foo() { ... }).

Anyway, I'd like to point out that Discuss has this awesome "spin off a related thread" button that is pretty much exactly meant for this purpose -- and I'd be pushing it right now, but that it's time for me to close the computer over here and go to bed. So maybe tomorrow. =)

3 Likes

Done, thread is here. (For reference, the bottom is under the "chain" icon, which has a "+ New Topic" section.)

I think it's worth spelling out what's going on here. In my view, it's part of the natural transition from "problem orientation" to "solution orientation".

On the one hand, you're absolutely right: solving the problem you mention is very much in scope, and should be part of the overall initiative.

On the other hand, when it comes to design, there's a danger of "scenario solving": designing a feature too specifically to a particular use case. That can lead to global designs that don't scale well, or have lots of inconsistencies.

So, when we set out to solve a problem like "allowing owned values where references are expected", we need to simultaneously keep our eye on the prize -- the full set of problems we want to solve -- and yet make sure the design we do fits into the broader context of the language.

What people have realized on this thread is that we can:

  1. Extend the applicability of coercions, so that they are applied when working with functions or closures as well.
  2. Extend the set of coercions to include AsRef.

These two things together solve the original problem. But two separate pieces of design are involved -- and in particular, the key is that we want (1) to be applicable to all coercions, not just AsRef coercions. We set out to solve one particular, narrow problem, but found that part of our solution can actually apply to a much broader range of problems, i.e. to all kinds of coercions that you want to apply at the function/closure level.

Of course, in the end we do need to make sure that we land designs for (1) and (2) such that we solve the original problem; they're not entirely orthogonal. And that's just how the design process goes: from local to global and back, iterating until you get somewhere that solves a satisfactory set of specific problems while extending the language in a globally coherent way.

3 Likes

this is why the point-free style is not popular:

https://bitbucket.org/iopq/fizzbuzz-in-rust/src/d4638b2ba3f09efe053ee0c4251036607847987c/src/lib.rs?at=master&fileviewer=file-view-default

figuring out how to write the apply function properly was impossible for me, and took a week of sitting on IRC to figure out that I cannot write it as two functions (real_deref and apply)

writing apply_0 to make the last line point-free was a waste of time, and syntax so I took it out

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.