Default method implementation in a trait

Hi,

I'm learning Rust, and also trying to progressively move from hacky scripts to acceptable code, as I'm not a developer by trade even though I have experience with programming quick and dirty things in other languages. I've started a small project to experiment with a few concepts.

In this file replicating a part of what I'm doing, I'm creating a concept Notifier which can send_message. It's a trait and there are several implementations. I've added a concept of NotifierChain, which accepts a sort of builder pattern (probably not by the book though) to aggregate several Notifiers. The NotifierChain behaves like a Notifier and can send_message too, which it does by looping over each Notifier it knows about and calling its own send_message method. To make this as general as possible, the NotifierChain therefore implements the Notifier trait. So far so good.

Now I get stuck at the next thing I'd like to improve: rather than creating a NotifierChain and adding Notifier instances to it, I'd like the extra flexibility to create a Notifier, and then chain_with another one to return a NotifierChain. I'm tempted to add chain_with to the Notifier trait, with a default implementation that will work for all my "regular" Notifier structs, and override it inside NotifierChain. However, no matter how I approach this, I get stuck and drown quickly in error messages I'm not sure how to handle. But if I don't, I have to define chain_with with exactly the same definition in each Notifier struct, which sounds like a really bad idea.

What would be a clean solution to this problem? It's not so much that I need this; I'm just as well creating an empty NotifierChain first whenever I need to sequence 2 Notifiers. However I think I might learn something useful if someone manages to explain the solution to me...

Below the code that works as is, with comments as to the changes I'm not successful at making.

Thanks for any inputs on this!
Pierric.

trait Notifier {
    fn send_message(&self, msg: String);
    // fn chain_with would ideally come in here with a default implementation
    // used by "regular" Notifier implementations, and overriden by the NotifierChain one
}

struct NotifA;
impl NotifA {
    fn new() -> Self { Self {} }
    // for now, all I can manage is to have the same implementation duplicated
    // for every implementation of a regular Notifier
    fn chain_with(self, other: Box<dyn Notifier>) -> NotifierChain {
        let nc = NotifierChain::new().chain_with(Box::new(self));
        nc.chain_with(other)
    }
}
impl Notifier for NotifA {
    fn send_message(&self, msg: String) {
        println!("{}", msg);
    }
}

struct NotifB;
impl NotifB {
    fn new() -> Self { Self {} }
    // same code as in NotifA
    fn chain_with(self, other: Box<dyn Notifier>) -> NotifierChain {
        let nc = NotifierChain::new().chain_with(Box::new(self));
        nc.chain_with(other)
    }
}
impl Notifier for NotifB {
    fn send_message(&self, msg: String) {
        println!("{}", msg);
    }
}

struct NotifierChain {
    notifiers: Vec<Box<dyn Notifier>>
}
impl NotifierChain {
    fn new() -> Self {
        Self {
            notifiers: Vec::new()
        }
    }
    fn chain_with(mut self, other: Box<dyn Notifier>) -> NotifierChain {
      self.notifiers.push(other);
      self
    }
}
impl Notifier for NotifierChain {
    fn send_message(&self, msg: String) {
        for n in self.notifiers.iter() {
            n.send_message(msg.clone());
        }
    }
}

fn main() {
    let a = NotifA::new().chain_with(Box::new(NotifB::new()));
    a.send_message("fdfs".to_owned());
}

(Playground)

Output:

fdfs
fdfs

Errors:

   Compiling playground v0.0.1 (/playground)
warning: associated function `chain_with` is never used
  --> src/main.rs:27:8
   |
27 |     fn chain_with(self, other: Box<dyn Notifier>) -> NotifierChain {
   |        ^^^^^^^^^^
   |
   = note: `#[warn(dead_code)]` on by default

warning: `playground` (bin "playground") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 2.10s
     Running `target/debug/playground`

It's not an error, it's just a warning, your code will compile and run just fine as it is. However, if you want to provide a default trait implementation for something you can. In your case it would look something like this:

trait Notifier {
    fn send_message(&self, msg: String);
    fn chain_with(self, other: Box<dyn Notifier>) -> NotifierChain where Self: Sized {
        let nc = NotifierChain::new().chain_with(Box::new(self));
        nc.chain_with(other)
    }
}
1 Like

The errors you see when you just copy and paste the method into the trait have to do with the default assumptions that traits make about the types implementing them. In particular inside of a trait the type isn't assumed to have a statically known size (i.e. could be a trait object)

You can fix it by just telling the compiler that you'll always call the method with a type that has a fixed size which looks like where Self: Sized

You'll also get an error about Self not living long enough, because by default Box<dyn Notifier> actually means Box<dyn Notifier + 'static> which translates roughly to "this trait object doesn't contain any lifetimes we need to worry about tracking". We can fix that error by adding + 'static to our bound above so the compiler knows any types with lifetimes in them shouldn't be allowed to call the method at all.

Playground

trait Notifier {
    fn send_message(&self, msg: String);

    fn chain_with(self, other: Box<dyn Notifier>) -> NotifierChain
    where
        Self: Sized + 'static,
    {
        let nc = NotifierChain::new().chain_with(Box::new(self));
        nc.chain_with(other)
    }
}
2 Likes

Thank you so much @semicoleon, that did the trick! I need to read your answer again slowly tomorrow with a fresh brain to see if I really understand :slight_smile: but clearly you've nailed it.

@Aiden2207 sorry I might not have been super clear; I kept the warnings at the end of the post but when trying to modify my code as per the comments, I really was getting errors. The Self: Sized + 'static change fixes them though.

Thanks to both of you, I will revert here if my brain refuses to process the explanation :wink:

Hi!

Thanks for your guidance, I've re-read the Rust book sections about trait objects and the Sized trait, and I think this is making sense now. To recap and make sure I got it right:

  • Self is assumed ?Sized in methods declared inside the trait (I'm not too clear why, i.e., what would be the impact of assuming Sized like everywhere else, and overriding to ?Sized when needed -- but at least I can take this state of things for granted)
  • Because of that, the compiler refuses the method declaration, since a ?Sized can't be passed as a function parameter directly, only a reference would be ok
  • When I copied the method implementation into each implementation of the trait, it was working because there, Self is Sized
  • By specifying where Self: Sized in the trait method declaration, the compiler is happy with the declaration, and it can then use monomorphism to generate basically what I had done manually by duplicating the method implementation for each trait implementation (I might be lacking more specific words than "implementation" here)

Probably the least clear explanation in the world, but I think I'm putting the pieces together.

Now, the part I'm still struggling with is actually the 'static one... This brings the following questions to me:

  1. why do we even need a lifetime declaration, if we're not using any references in the method parameters? (or am I wrong considering that Box does not count as a reference for this purpose?)
  2. isn't it bad practice to use 'static? what if I had hundreds of such objects being created every second by my program (not making much sense for a "notifier" use case of course, I'm asking for the sake of learning)
  3. I've tried playing with lifetimes to see if I could use an arbitrary lifetime there, and align everything else in the code to that lifetime, but no success, I can't get any version to compile. Is that even possible? How would it work?

Thanks!
Pierric.

1 Like

Because otherwise it'd have to be overridden every time someone might want to have a dyn Trait.

When you do impl Trait for Type, Type can itself have a lifetime (e.g. if it is a reference itself). By requiring Self: 'static, you rule out these cases.

You seem to hit the common misconception. In short, T: 'static doesn't mean that T will live forever - it means that it's valid for it to live forever. A possibility, not an obligation.

3 Likes

Thank you @Cerber-Ursi !

My mind explodes at the idea that one could implement a trait on a type that itself is a reference :brain: I will park that thought for now :smiley:

You are completely right about the fact that I suffer from this misconception. Thank you for the link, I've read that section very quickly and I think it clarifies a few things. I will read the entire post carefully in the coming days, it seems very relevant for me at this point. I have a lot of learning ahead of me still to really be able to think in the Rust way!

Well, reference is a full-fledged type, and it can be used everywhere the type is expected - impl Trait for Type, generic parameters, macros expecting types, and so on. In this, it's not special at all.

In fact, this is used even in standard library: for example, Read trait is implemented not only for File, as one might expect, but also for &File. This allows one to read from the file having only a shared reference to it, despite Read trait itself requiring &mut Self.

I cannot wrap my mind around this, my first reaction is: how is that possible without it being unsafe, if reading (I assume) mutates the File object? Is this something that goes along the lines of: read has &mut self in its signature, self is in fact &File, so the method is defined on &mut (&File) which means that when reading, a new File object can be created and the &File reference can be updated to point to that new File?

The position in the file is maintained by the kernel, the File struct just contains some sort of identifier the program can use to look up an open file and do operations on it.

1 Like

Not to mention the way that IntoIterator is implemented for &Vec (and &mut Vec) and similarly to other collection types, making it possible to iterate either by value (consuming the collection), by reference (borrowing it), or mut reference (exclusively borrowing it), simply by passing either vec, &vec, or &mut vec to anything expecting an IntoIterator, such as the for..in loop!

2 Likes

If you have learned about shared mutability, aka interior mutability, you can think of File having interior mutability (albeit supplied by the operating system in this case).

4 Likes

Just wanted to thank everyone again for your helpful answers. I learned a lot from a single thread! Although I'm also very aware of how much is left to learn. They weren't kidding about the Rust learning curve, but neither were they about the great Rust community!

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.