Bounds for default method implementations

When writing trait definitions, I sometimes need to specify methods that have an obvious implementation for most, but not all, types that implement the trait. The most recent example comes from my answer to this thread on URLO, where I needed to specify parallel methods: One that has a receiver type of Self (where Self: Sized) and another that has a receiver of type Box<Self> (to allow usage by trait objects).

Ideally, I'd like to provide a default implementation of the boxed version that's only valid for Sized types, but this isn't possible today. Would something along these lines be reasonable to add to Rust?:

trait Consume {
    fn consume(self) where Self: Sized;
    fn consume_boxed(self: Box<Self>) default where Self:Sized { // example syntax
        Self::consume(*self)
    }
}

This syntax¹ (default where) is meant to indicate the bounds required for the default implementation to function. This is distinct from a normal where clause, which describes the bounds that must be fulfilled for the method to be called; both clauses may be present on the same method.

In this case, all types that implement the Consume trait must have a valid consume_boxed implementation (because there's no ordinary where clause). Sized types can use the default implementation, but !Sized types must override consume_boxed within the corresponding impl Consume block.

Like the unbounded default methods Rust currently has, any impl Consume block may provide its own implementation of consume_boxed. This is necessary to maintain forward compatibility: The default where bound could become satisfied if a mentioned type adds a trait implementation, which isn't supposed to be a breaking change.

¹ I haven't thought through the particular syntax much; that can be bikeshedded if the community decides this is a worthwhile capability to add.

I think this can be done with specialization and by splitting it into multiple traits:

#![feature(specialization)]

trait Consume {
    // this needs to have a default implementation or you can't impl Consume for T where T: ?Sized,
    fn consume(self)
    where
        Self: Sized
    {
        // a bit better, @2e71828 below
        unimplemented!()
    }
}

trait ConsumeBoxed: Consume {
    fn consume_boxed(self: Box<Self>);
}

impl<T: Consume> ConsumeBoxed for T {
    // default implementation if T: Sized
    default fn consume_boxed(self: Box<Self>) {
        Self::consume(*self);
    }
}

impl Consume for str {}

impl ConsumeBoxed for str {
    fn consume_boxed(self: Box<Self>) {
        // do something
    }
}

which is notably more verbose.

1 Like

Additionally, the no-op default for consume() is unfortunate, as the compiler will no longer complain if you forget to implement it.

Using trait objects is also cumbersome, as you need to remember that Box<T:Consume> should be coerced into Box<dyn ConsumeBoxed> instead of Box<dyn Consume>.

well since Consume::consume already needs a default impl might as well do this.

#![feature(min_specialization)]

trait Consume {
    fn consume(self)
    where
        Self: Sized
    {
        unimplemented!()    
    }
    
    fn consume_boxed(self: Box<Self>);
}

impl<T> Consume for T {
    default fn consume_boxed(self: Box<Self>) {
        Self::consume(*self);
    }
}

impl Consume for str {
    fn consume_boxed(self: Box<Self>) {
        // do something
    }
}

That causes problems when trying to implement Consume for a Sized type: Playground

impl Consume for u32 {
    fn consume(self) { dbg!(self); }
}
error[E0520]: `consume` specializes an item from a parent `impl`, but that item is not marked `default`
  --> src/lib.rs:27:5
   |
14 | / impl<T> Consume for T {
15 | |     default fn consume_boxed(self: Box<Self>) {
16 | |         Self::consume(*self);
17 | |     }
18 | | }
   | |_- parent `impl` is here
...
27 |       fn consume(self) { dbg!(self); }
   |       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ cannot specialize default item `consume`
   |
   = note: to specialize, `consume` in the parent `impl` must be marked `default`

To fix this, you need to add another default implementation of fn consume() inside the impl<T> block. Playground

IIRC, the way to write this with feature(specialization) is

#![feature(specialization)]

trait Consume {
    fn consume(self)
    where
        Self: Sized;
    fn consume_boxed(self: Box<Self>);
}

//          vvv T: Sized implicit anyways
default impl<T> Consume for T {
    fn consume_boxed(self: Box<Self>) {
        Self::consume(*self)
    }
}

The fact that Rust currently complains about missing methods when the method has Self: Sized and the implementor is unconditionally unsized is a separate concern. But it has the effect that implementing this trait as-is for str is kind-of impossible, unless I’m missing something xD

1 Like

Apparently, this approach doesn’t like any overlap either, e.g.

struct S<T: ?Sized>(T);

impl<T: ?Sized> Consume for S<T> {
    fn consume(self)
    where
        Self: Sized,
    {
    }
    fn consume_boxed(self: Box<Self>) {}
}
error[E0119]: conflicting implementations of trait `Consume` for type `S<_>`
  --> src/lib.rs:20:1
   |
12 | default impl<T> Consume for T {
   | ----------------------------- first implementation here
...
20 | impl<T: ?Sized> Consume for S<T> {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `S<_>`

error: aborting due to previous error; 1 warning emitted

I guess that this needs improvement. Makes me wonder what the effect of a default impl is if it specializes a non-default impl? ………


A few tests later… Okay, it doesn’t specialize anything effectively, e.g. see this playground (where actual specialization only happens with an ordinary impl, as can be seen by the runtime behavior if you click Run). But it still errors in the same cases as ordinary impl does, e.g. in this playground where the default was removed from the fn consume_boxed in impl<T: ?Sized> Consume for S<T>.

1 Like

I believe default impl allows you to implement only part of all required methods of a trait and then allow later impl blocks to implement the rest of the methods to end up with a complete implementation of the trait.

Edit: I was correct:1210-impl-specialization - The Rust RFC Book

The specialization design in this RFC also allows for default impls , which can provide specialized defaults without actually providing a full trait implementation:
[...]
This default impl does not mean that Add is implemented for all Clone data, but just that when you do impl Add and Self: Clone , you can leave off add_assign :
[...]

This is accomplishable with just min_specialization, a split trait, and no dummy impls: [playground]

#![feature(min_specialization)]

trait Consume: ConsumeBoxed + Sized {
    fn consume(self);
}

trait ConsumeBoxed {
    fn consume_boxed(self: Box<Self>);
}

impl<T> ConsumeBoxed for T
where
    T: Consume,
{
    default fn consume_boxed(self: Box<Self>) {
        dbg!("default consume_boxed");
        Self::consume(*self)
    }
}

impl Consume for i32 {
    fn consume(self) {
        dbg!("i32::consume");
        dbg!(self);
    }
}

impl Consume for String {
    fn consume(self) {
        dbg!("String::consume");
        dbg!(self);
    }
}

impl ConsumeBoxed for String {
    fn consume_boxed(self: Box<Self>) {
        dbg!("String::consume_boxed");
        dbg!(self);
    }
}

impl ConsumeBoxed for str {
    fn consume_boxed(self: Box<Self>) {
        dbg!("str::consume_boxed");
        dbg!(self);
    }
}

fn main() {
    let s: String = "owned".into();
    s.consume();
    let s: Box<str> = "boxed".to_string().into_boxed_str();
    s.consume_boxed();
    let s: Box<String> = Box::new("double indirection".into());
    s.consume_boxed();

    let i: i32 = 5;
    i.consume();
    let i: Box<i32> = Box::new(5000);
    i.consume_boxed();
}

With full specialization, being able to use only one trait with a partial specialized default is definitely nice, however. [playground]

#![feature(trivial_bounds)]
#![feature(specialization)]

trait Consume {
    fn consume(self)
    where
        Self: Sized;
    fn consume_boxed(self: Box<Self>);
}

default impl<T> Consume for T {
    default fn consume_boxed(self: Box<Self>) {
        dbg!("default consume_boxed");
        Self::consume(*self)
    }
}

impl Consume for i32 {
    fn consume(self) {
        dbg!("i32::consume");
        dbg!(self);
    }
}

impl Consume for String {
    fn consume(self) {
        dbg!("String::consume");
        dbg!(self);
    }

    fn consume_boxed(self: Box<Self>) {
        dbg!("String::consume_boxed");
        dbg!(self);
    }
}

impl Consume for str {
    fn consume(self)
    where
        str: Sized,
    {
        unreachable!("type system contradiction")
    }
    fn consume_boxed(self: Box<Self>) {
        dbg!("str::consume_boxed");
        dbg!(self);
    }
}

fn main() {
    let s: String = "owned".into();
    s.consume();
    let s: Box<str> = "boxed".to_string().into_boxed_str();
    s.consume_boxed();
    let s: Box<String> = Box::new("double indirection".into());
    s.consume_boxed();

    let i: i32 = 5;
    i.consume();
    let i: Box<i32> = Box::new(5000);
    i.consume_boxed();
}

The point of this is mostly to say that default where isn't really necessary, because it's pretty much isomorphic to the primary use case (and contains the same fallout issues as) min_specialization, so sound specialization would both be a prerequisite to sound default where and make default where mostly unnecessary.

Default function method implementations are after all almost just sugar for a blanket (partial) specialization. (Exception: all methods provided.)

3 Likes

Oh, wow, I didn’t know of #![feature(trivial_bounds)].

2 Likes

Neither did I, I just did what the compiler told me to do :upside_down_face:

Tracking issue: Tracking issue for RFC #2056: Allow trivial constraints to appear in where clauses · Issue #48214 · rust-lang/rust · GitHub

RFC: Allow trivial constraints to appear in where clauses by sgrif · Pull Request #2056 · rust-lang/rfcs · GitHub

Indeed, I noticed that too, the error message if you remove it mentions it. I’ve had to think a bit about why I’ve never seen that message in the past. I got the answer now: Last time I hit this problem I’ve tried something like implementing this trait

trait Foo {
    fn method(self) where Self: Sized;
}

but of course, without specialization, I was on stable and implementing this trait for str on stable only gives the way more subtle

  = help: see issue #48214

The approaches I tried at the time were probably these

impl Foo for str {
    fn method(self) where Self: Sized {}
}
error[E0277]: the size for values of type `str` cannot be known at compilation time
 --> src/lib.rs:7:5
  |
7 |     fn method(self) where Self: Sized {}
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ doesn't have a size known at compile-time
  |
  = help: the trait `Sized` is not implemented for `str`
  = help: see issue #48214

error: aborting due to previous error

and

impl Foo for str {
    fn method(self) {}
}
error[E0277]: the size for values of type `str` cannot be known at compilation time
 --> src/lib.rs:7:15
  |
7 |     fn method(self) {}
  |               ^^^^ doesn't have a size known at compile-time
  |
  = help: the trait `Sized` is not implemented for `str`
help: function arguments must have a statically known size, borrowed types always have a known size
  |
7 |     fn method(&self) {}
  |               ^

error: aborting due to previous error

and

impl Foo for str {}
error[E0046]: not all trait items implemented, missing: `method`
 --> src/lib.rs:6:1
  |
2 |     fn method(self) where Self: Sized;
  |     ---------------------------------- `method` from trait
...
6 | impl Foo for str {}
  | ^^^^^^^^^^^^^^^^ missing `method` in implementation

error: aborting due to previous error

Spotting the #48214 amongst all the errors and figuring out that it’s supposed to be about a feature that removes the error instead of just providing explanation on the error itself is the hard part.

1 Like

I, too, have had to sort of learn from experience that the compiler will only tell me about features on nightly. It'd be nice if there was some clue to at least check this (though I'd also understand concerns about seeming to imply a feature is coming to stable at all or any time soon).

I realised #[requires] might be just enough to address this:

trait Consume {
    fn consume(self) where Self: Sized;
    fn consume_boxed(self: Box<Self>);

    #[requires(consume)]
    default {
        fn consume_boxed(self: Box<Self>) {
            Self::consume(*self)
        }
    }
}