An area of the Rust documentation I've always felt lacked some details is the method lookup process, namely the page in the Rust Reference about method-call expressions, with particular regard to the (completely omitted) explanation about how trait bounds in where
clauses intervene in the process of method selection.
I'm sorry that the following description is quite lengthy, but if you have the patience to bear with me, I'll give an example of a situation where I encounter a puzzling behavior.
Imagine having the following struct
s:
#[derive(Debug)]
pub struct Foo;
#[derive(Debug)]
pub struct Bar;
#[derive(Debug)]
pub struct FooRef;
impl std::ops::Deref for FooRef {
type Target = Foo;
fn deref(&self) -> &Foo {
&Foo
}
}
Bar
leads its own solitary life, whereas FooRef
can dereference to Foo
.
Let's now consider two traits which resemble a home-made version of Into
and From
. To distinguish them from the standard ones, I'll use a double oo
in their names:
// active conversion
trait Intoo<U> {
fn intoo(&self) -> U;
}
// passive conversion
trait Froom<T> {
fn froom(_: &T) -> Self;
}
We want to achieve the following:
- we want active and passive conversions from
Foo
toBar
; - whereas we want to convert from
FooRef
toBar
only by passing throughFoo
, with implicit auto-dereference.
The obvious way seems to be
// blanket implementation
impl<T, U> Intoo<U> for T
where
U: Froom<T>,
{
fn intoo(&self) -> U {
U::froom(self)
}
}
// conversions Foo -> Bar
impl Froom<Foo> for Bar {
fn froom(_: &Foo) -> Bar {
Bar
}
}
but then we notice that the following does not compile:
fn main() {
let fooref: FooRef = FooRef;
let bar: Bar = fooref.intoo();
println!("{:?} {:?}", fooref, bar);
}
Apparently, the blanket implementation impl Intoo<Bar> for FooRef
is not skipped, even though the where
clause Bar: Froom<FooRef>
is not satisfied. Therefore this method is selected as the "correct candidate", but it then fails to pass the trait bounds check at a later stage.
Ok, so maybe the method is not skipped because the trait bound in the where
clause is a bound on the parameter U
and not the type T
itself, which is the subject of the impl
. Let's try adding a bound on T
. We introduce a new trait that controls whether the blanket implementation is available and then signal that we want it for Foo: Intoo<Bar>
:
trait CanAutoIntoo<U> {}
impl CanAutoIntoo<Bar> for Foo {}
// blanket implementation
impl<T, U> Intoo<U> for T
where
U: Froom<T>,
T: CanAutoIntoo<U>,
{
fn intoo(&self) -> U {
U::froom(self)
}
}
Excellent! The code now compiles as expected! During the method lookup process, it was noticed that FooRef: Intoo<Bar>
is unavailable since FooRef: CanAutoIntoo<Bar>
is not satisfied, hence the method was skipped and fooref
gets auto-dereferenced to Foo
, which implements Intoo<Bar>
. Victory!
However, surely we don't want to manually implement CanAutoIntoo
every time we implement Froom
in order to get the blanket implementation too. We should try to implement CanAutoIntoo
automatically. The obvious way is
impl<T, U> CanAutoIntoo<U> for T where U: Froom<T> {}
Very mysteriously however, this change breaks the compilation again! Even though FooRef: CanAutoIntoo<Bar>
is cleary unsatisfied, this time around the method is not skipped and the compiler complains that Bar: Froom<FooRef>
is unsatisfied, instead of moving along and dereference fooref
to Foo
.
After this long journey, I can formulate my question.
How do where
clauses and trait bounds exactly intervene in the decision process whether to accept or skip a method during method lookup?
It seems to me that only some bounds are taken into consideration during an initial pass to select a candidate, and then only afterwards all the bounds are actually verified to confirm that the selection was indeed a good one.
Why does changing from impl CanAutoIntoo<Bar> for Foo {}
to impl<T, U> CanAutoIntoo<U> for T where U: Froom<T> {}
alter the selection of the method? In the former case the method is skipped, in the latter it is not. I find this behavior very confusing, if not actually irritating. What is the difference in nature between the former and the latter that causes the trait bound T: CanAutoIntoo<U>
to behave differently in the blanket implementation?