Note: Re-post from users.rust-lang after moderator note there.
tl;dr
When the compiler sees a self.XXXX
on a default method implementation within
a trait, it should wait until impl
of that trait before evaluating whether
self
has XXXX
or not. As of date it emits an error: no field XXXX on type &Self
. As an alternative, we could have an approach which is modeled upon
Clojure's spec.
Related to RFC#1546 (Allow fields in traits that map to lvalues in impl'ing type) which is 3+ years old and was postponed without a timeline.
Consider a standalone program containing a single trait
trait SaysHello {
fn say_hello(&self) {
println!("Hello, {}!", self.name);
}
}
This will fail at compilation because there is no constraint provided for
Self
which ensures it always has the field name
used in the println!
statement. And the compiler will complain about it.
To resolve this one can, move the implementation of say_hello()
inside the
impl
block when the trait is applied to some type having a field name
. This
will work because the compiler can now see that the field is present for impl
'd type (User
in the example below).
trait SaysHello {
fn say_hello(&self);
}
struct User {
name: String,
}
impl SaysHello for User {
fn say_hello(&self) {
println!("Hello, {}!", self.name);
}
}
The problem with the above solution is that it inhibits code re-use. If we want
to provide the say_hello()
method to new types other than User
without
changing it's implementation, there is no way. We have to write the
implementation for each type. In effect, we cannot re-use say_hello()
.
Another approach to the above problem could be to go old-school OO and implement getters
trait SaysHello {
fn get_name(&self) -> &String;
fn say_hello(&self) {
println!("Hello, {}!", self.get_name());
}
}
In the above approach, say_hello()
is shared and any type which impl SaysHello
needs to provide only an implementation of get_name()
. Depending
upon one's subjective preference they may like it or not. Personally, I do not
prefer it. This gets bloated very quickly as the number of fields increase.
Yet, another approach involves use of macros to generate the same implementation for different types. It makes code hard-to-reason about and is not very editor/IDE friendly.
Suggestion - I
How about compiler let the standalone trait be and only check when it is
impl
'd against something?
Going back to the first code-block in the post, the compiler does not care if
the default implementation refers to some fields on self
(or &self
etc.). The
code below compiles with no compiler errors.
trait SaysHello {
fn say_hello(&self) {
// The compiler is not bothered by self.name;
// it is yet to come across an `impl` of SaysHello
println!("Hello, {}!", self.name);
}
}
The compiler, instead, evaluates the trait at the point of impl
and succeeds
if the target type contains all fields referenced in the default
implementation against self
(and &self
etc.).
For example, the one below passes because the compiler can see
that the impl
is for User
and User
does indeed have a field name
.
struct User {
name: String,
}
// This passes
impl SaysHello for User {}
But the one below fails because the User
has no field name
.
struct User {
email: String,
}
// This fails
impl SaysHello for User {}
The compiler could emit a message like: User does not implement data: String
.
This approach could get a little tricky with generic types:
impl SaysHello for T {}
But unconstrained generic types are problematic irrespective. On the other hand, it may be possible to evaluate the constrained ones recursively.
In effect, this approach is suggesting that the compiler implicitly infer what the RFC#1546 is asking developers to provide explicitly when typing out traits.
Suggestion - II
The problem with Suggestion - I is that it will get very tedious for the
compiler as the size and complexity of the code base it is compiling increases.
Also the approach mentioned in the RFC#1546
is not very clean because it is mixing data (fields
) with behaviour (trait
).
We could borrow, adapt and improvise upon spec from Clojure (or similar solutions) to introduce a new construct to specify the contract at data level between two or more Rust objects.
Assuming that it will be called as Spec
in Rust, we then the following analogy:
Trait -> Behaviour :: Spec -> Data
Just like traits, spec can be used to constrain types but at data-level.
pub fn<T>()
// T must define a union of fields specified in Spec1 and Spec2
where T: Trait1 + Trait2 + Spec1 + Spec2 // .... and so on
{}
Or one could add a new keyword for constraining on spec:
pub fn<T>()
where T: Trait1 + Trait2
// conforms is a new keyword but has the same effect as above
conforms T: Spec1 + Spec2 {}
Compared to the approach suggested in the RFC#1546, this has the added benefit of de-linking data constraints from behavioural constraints.
About a year ago there was some conversation around trait fields here on the forum.