Idea: trait mask

It seems fairly common for people to want to implement a trait for another trait.

Trait objects can accomplish it, but they'll use dynamic dispatch.

impl Display for &dyn ToJson { ... }
impl Display for &dyn ToXml { ... }

// Assume the datatype of document implements ToJson and ToXml
(&document as &dyn ToJson).fmt(&mut formatter);

Blanket implementations can sometimes accomplish it if there aren't conflicting implementations, but it won't be able to do it for another trait.

impl<T> MyDisplay for T where T: ToJson { ... } // this alone might compile
impl<T> MyDisplay for T where T: ToXml { ... } // compile error due to ambiguity over conflicting implementations

A generic wrapper type can be used to get around this ambiguity.

struct Json<T>(T);
struct Xml<T>(T);

impl<T> Display for Json<T> where T: ToJson { ... }
impl<T> Display for Xml<T> where T: ToXml { ... }

// Display knows to use the ToJson implementation
Json(document).fmt(&mut formatter);

The idea of a trait mask is to combine the trait object idea with the wrapper idea. Traits can be implemented for a trait mask. A value can only use that implementation if it puts on the trait mask. When a value puts on a trait mask, it restricts itself to only have the functionality of that trait and any traits implemented for the trait mask.

The following code would work just like the generic wrapper code.

impl Display for mask ToJson { ... }
impl Display for mask ToXml { ... }

(document as mask ToJson).fmt(&mut formatter);

I'm curious if anyone else thinks such a feature would be a good idea or if it would cause problems.

So what's the benefit wrt wrapper? That it inherits everything (else?) that the wrapped type has defined?

Wouldn't this be roughly equivalent to the following?

struct Json<T>(T);
impl AsRef<T> for Json<T> {
  // ...
}
impl<T> Display for JSon<T> where T: ToJson {
  // ...
}

This is a bad idea on two fronts:

Firstly, it duplicates an existing functionality as you demonstrated so yourself. Having more than one way to do it is adding accidental complexity that is harmful. When should I use a wrapper struct? When should I prefer a mask?

Secondly, on a more fundamental level you have a modelling problem. The blanket impl meaning in plain English is:

All types that can be converted to XML have the following Display.

You cannot have two different "the Display" implementations - that's nonsensical. That's why you're trying to add more syntax to disambiguate between the two conflicting "the Displays"

IMO it would be way more readable to simply use an explicit method call.

document.to_json().fmt(&mut formatter); //  Very clear what's going on here

Brings to mind the KISS principle :wink:

3 Likes
trait Display<Mask: ?Sized> {
    fn fmt(&self, f: &mut Formatter) -> Result;
}

trait ToJson {
    // whatever
}

impl<T> Display<dyn ToJson> for T where T: ToJson {
    // ...
}


Display::<ToJson>::fmt(&document, &mut formatter);

wouldn't this work? (sort of)

  1. The idea to use a wrapper type does not come naturally for beginners, where as the idea of using a trait to implement a trait comes very naturally.

  2. There isn't a need to explicitly define a new wrapper type. There is no boiler plate code for said wrapper type. There aren't multiple wrapper types for the same trait because there is only ever one mask Trait.

  3. The expression Json(document) necessarily moves document. You might not be able to get it back. At the very least you would need to reassign it to document. The expression document as mask ToJson would automatically restore document unless it is moved during its masked state.

Another use case is for when impl Trait is a return type. The return values could use trait methods that were acquired by implementing for mask Trait. Maybe it would make more sense just to extend impl Trait syntax to do what I've laid out for mask Trait, but I'm not as interested in the syntax at this stage.

I agree with this principle, but if it is adhered to too strictly then we wouldn't have the ? operator whose functionality already existed in match expressions. I view trait masks as being similar to the ? operator in that provides an intuitive way to do an intuitive thing that would otherwise require an unintuitive workaround, which is how I regard the wrapper types in the example I provided. The reason I brought up the wrapper struct example is to clarify how mask traits would work.

But doesn't having some way to disambiguate solve the conflicting implementation problem? A variable can only ever use traits implemented directly on it's datatype, so it would not conflict with mask trait implementations. Mask implementations only need to be considered when types are impl Trait or mask Trait.

It's more readable, but it is less clear. I don't know what document.to_json() does without more context. Maybe it unnecessarily creates a String in JSON format. Maybe it unnecessarily creates some JSON Value datatype which implements Display. Maybe it just nests it into a wrapper which implements Display. I wouldn't need more context to know that document as mask ToJson is just telling the compiler to restrict the variable to ToJson functionality.

I think this sentence itself is very arguable.

2 Likes

I definitely have wanted to do it before. I even tried to do it in golang once and was slightly surprised that it didn't work.

Then provide a different name! I'd actually agree that this is not a great name: to typically suggests a conversion returning an owned value in Rust APIs, where as suggests a view into the original data.

I've seen everything from

write!(f, "{}", document.fmt_json())

to

write!(f, "{}", DisplayAsJson(&document))

and several other options. You can pick which is most clear for your library, but at some point, you will need to check the documentation to confirm you understand what the functions are doing properly.

It's on the function author if it's not clear if it creates a Displayable or eagerly formats into a string. (Ideally, the latter should never exist, imo.)

1 Like

To be honest, I didn't think much about the name, just used the same name from the trait.

My main point was that using a method is appropriate here and I agree with you that a better name would be as_json().

The crux of the issue here imo is (as usual) an XY problem. The OP is trying to apply a mental model common to OOP languages such as Java where the standard practice is to upcast to the interface and use its methods. Unfortunately, this is not compatible with Rust's model where traits are not local interfaces and instead they are used as constraints over types and we have globally coherent specification of behaviour.

I don't think rust could reconsider coherence at this stage without essentially forking the language.

1 Like

I'm glad you've actually stated and explained your objection, though I still don't fully understand it. I would need to understand what you mean by coherence and what constitutes global coherence, as well as what makes something a local interface.

My mental model is that traits are instructions and trait bounds are instruction requirements. Sometimes instructions can be used to do other instructions. Rust allows this with impl Trait for dyn Trait. I thought it could be helpful if this can be done while keeping static dispatch. I discovered that it can be done with static dispatch using wrapper structs and thought it would be useful if it could be made more accessible.

1 Like

Wrapper structs are a great solution to coherence "issues". Most of the time, if a beginner really thinks about the impl they are trying to write, it doesn't make much sense. Specialization is great in the limited cases where it is needed, but wrapper structs and perhaps extension methods for ergonomics are the way to go in most cases. The only real problem in my opinion, is that wrapper structs and the newtype pattern isn't a first class citizen, something that delegation would address.