Reviving conversation around trait fields (RFC#1546)

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.

2 Likes

How will this interact with privacy? This looks like a way to access arbitrary fields of a struct. How about if the types don't line up?

What errors should this code snippet give?

// crate A

trait Foo {
    fn call(&self) -> &str { &self.x }
}

// crate B

struct Bar {
    x: i32
}

impl crateA::Foo for Bar {}

// crate C

struct Quax {
    x: String
}

impl crateA::Foo for Quax {}
5 Likes

Thanks for the follow-up! Still thinking about the privacy case.

How about if the types don't line up?

Since the compiler is evaluating the trait when it encounters the impl, it could be able to validate the types (and ops involved). Taking a leaf out of your example:

trait Foo {
    fn call(&self) -> &str { &self.x }
}

struct Bar {
    x: i32
}

// The compiler can emit type error for the method call()
// expected &str; found i32 for this impl
impl Foo for Bar {}

Similarly, the one below may also fail at the point of impl

trait Foo {
    fn by_hundred(&self) -> i32 { self.x / 100 }
}

struct Bar {
    x: String
}

// This too will fail with error message about std::ops::Div
// not present for String 
impl Foo for Bar {}

That's sounds almost like SFINAE, and doesn't feel Rustic. Everywhere else the compiler demands all names defined before they're used, so typos fail at the point of definition, not at the point of use.

Why not declare required fields?

trait SaysHello {
    field name: String;
    fn say_hello(&self);
}

or maybe some kind of specialization + structural typing:

default impl SayHello for T where T.name: String {
    fn say_hello(&self) {
       self.name
    }
}
7 Likes

Like the second example. It's better! :+1:

But there is a problem with that: re-usability. (Note: this may affect current where declarations for traits too and I am not aware of any workarounds.)

impl Foo for Human where T.name: String + T.age: i32 {} // ... few more constraints

impl Foo for Animal where T.name: String + T.age: i32{} // ...few more constraints

Suddenly, the code is bloated with all these repetitive where clauses. That is what is being covered in Suggestion-II in the OP that we create an abstraction that can make these clauses reusable and shareable.

Why bother with automatic detection? That seems more like a Go thing than a Rust thing. (let is just the least unreasonable keyword for this)

trait T {
  let some_field: usize;
}

struct K {
  my_private_name: usize;
}

impl T for K {
  // Required grammar for RHS is |self.$expr|.
  let some_field = self.my_private_name;
}

Bound type parameters provide the offset so that e.g. &<t as T>.some_field is transformed into &t.my_private_name, trait objects can carry the offset around in their vtables.

If what you want is automatic implementation of methods due to field presence, rather than making presence of a field of type T part of the API, then @kornel's suggestion of specialization + structural bounds is 100% the way to go.

What you want are bound aliases, which would generalize trait aliases.

2 Likes

Is the reference here to RFC#1733? (The bound alias issue was closed in favour of 1733.)

1 Like

The only two kinds of bounds right now are trait bounds and lifetime bounds, and since traits can be bound by lifetimes, fully general bound aliases don't make terribly much sense. Introducing a new type of bound (i.e., structural bounds that are not traits) will probably make actual bound aliases (as distinct from trait aliases, maybe?) more useful.

Is there some RFC/ticket for these new type of bounds?

I'm not sure! I would be surprised if there was, seeing as trait aliases are good enough right now.

I also don't think that bound aliases are terribly relevant to this thread; if we were to add something like structural bounds, I think bound aliases would come much later.