Are some of Box trait "forwards" just legacy?

Hello,

I've been wondering why Box implements so many traits. (I'm not the only one.)

For example it implements BufRead if the target type does so as well. Let's consider the following program:

use std::io;
use std::io::prelude::*;

trait Greet {
    fn greet(&self) { println!("Hello"); }
}

impl<'a> Greet for io::StdinLock<'a> {}

fn main() -> Result<(), io::Error> {
    // Type io::StdinLock implements BufRead.
    let mut lstdin: Box<io::StdinLock> = io::stdin().lock().into();
    let mut line = String::default();
    // The following works because Box implements BufRead:
    lstdin.read_line(&mut line)?;
    // The following also works for the same reason:
    Box::read_line(&mut lstdin, &mut line)?;
    println!("{}", line);
    // The following works because of Box' Deref impl:
    lstdin.greet();
    // The following doesn't work because Box does not implement Greet directly:
    // Box::greet(&lstdin);
    Ok(())
}

So OK, it seems that if Box didn't implement BufRead, the program would still work, only that we would have to call read_line as a method and couldn't call Box::read_line instead. But that's probably OK. After all, other std smart pointers do not implement BufRead. (But they do implement other traits if their target does.)

Are there guidelines on which traits smart pointers should "forward" and which not? Or is this forwarding perhaps a leftover from before Deref existed?

Well, it's not okay due to backwards compatibility, but I think your question is more "if we have auto-Deref methods anyway, why have the implementation for Box<_>".

To address that, consider this variation where we pass by value:

fn not_main<R: BufRead>(mut lstdin: R) -> Result<(), io::Error> {
    let mut line = String::default();
    lstdin.read_line(&mut line)?;
    println!("{}", line);
    Ok(())
}

fn main() -> Result<(), io::Error> {
    let lstdin: Box<io::StdinLock> = io::stdin().lock().into();
    not_main(lstdin)
}

It relies on the implementation. (Even Box<dyn BufRead> doesn't implement BufRead without the implementation.)

Workaround no one wants to have to use for completeness
fn not_main<R>(mut lstdin: R) -> Result<(), io::Error>
where
    R: DerefMut,
    <R as Deref>::Target: BufRead,

Even more problematic (e.g. no analogous workaround) are traits that have a self receiver method.

2 Likes

Box is also special because it's what's called a "fundamental" type. You can impl UpstreamTrait for Box<LocalType> despite that not being valid for other types like Vec. (So if a forwarding impl isn't provided, adding it later is an API breaking change.)

If you have an object-safe trait (i.e. one where dyn Trait is allowed), it's often considered good practice to provide forwarding implementations for &T, &mut T, and Box<T> (where T: ?Sized + Trait), whichever are viable based on what the trait method signatures are. It isn't necessary nor even always desirable, but it's the typical way to be able to use an owned trait object in a generic API.

This is primarily because BufRead methods take &mut T, and the other smart pointers (i.e. Rc and Arc) don't provide access to &mut T. Thus they can't.

3 Likes

Thanks for the quick and insightful replies!

Yes, that's what I meant. I should have picked a trait whose methods do not require unique references and as such is also implemented for other smart pointers. AsFd comes to mind.

Ignoring for a moment your remark about cases where it doesn't work, isn't your workaround conceptually more elegant? Because (and that's what motivated my post in the first place - I just tried to present a concrete example), aren't smart pointers that are expected to implement all (or many) possibly useful traits a leaky (and unsustainable) abstraction?

For example Box, Rc, and Arc implement AsFd, but Cow doesn't. Now if a generic function expects a smart pointer that forwards AsFd it will be less generic than one that uses a variant of the workaround that you mention.

I should have chosen a more general example to illustrate my point. Let's consider the serde::Serialize trait. It provides lots of forwarding impls, for example

impl<'a, T> Serialize for Cow<'a, T>
where
    T: ?Sized + Serialize + ToOwned,

but of course it cannot anticipate all possible third-party smart pointers. For example there's the supercow crate whose Supercow struct does not impl serde::Serialize as far as I can tell. Now if supercow was to ever becomes widely used, serde could add a forwarding impl (since Supercow is not a fundamental type :wink: ).

But is this way of working sustainable? I mean if there are N generally useful traits, and M generally useful structs, there will be N times M trait impls to be specified.


Thanks for pointing out this additional complication. So if serde had forgotten to provide

impl<T> Serialize for Box<T>
where
    T: ?Sized + Serialize,

and I'm implementing Serialize for MyType, I may also provide impl Serialize for Box<MyType>. And serde wouldn't be able to fix this without breaking backwards compatibility.

So when a crate provides a generally useful trait, should it also provide a forwarding impl for Box<T> like the one above?

It's not purely more capable/generic. It only works for pointers.[1]

Or vice-versa, sure.

For all applicable fundamental type constructors, yes.


You raise some valid points, but I don't think there's a silver bullet.


  1. If we defined "pointers" as approximately "implements Deref". ↩︎

Serde can't add that impl unless it starts depending on supercow. Nor is that the responsibility of Serde.

For a wildly popular and fundamental, almost-std crate such as serde, the burden of implementing Serialize and Deserialize is on all other 3rd-party crates that wish/should support Serde.

That's just… physics? How else would you expect it to work? What does this anything to do with forwarding impls? This really hasn't got anything to do with the design of the standard library or the language. This is just how things necessarily are.

Some background:

In a library that I'm working on, I'm considering whether to make a graph-like structure generic over ways its nodes are held or not. Depending on the particular use case it could be best to either own the nodes, or keep them under Rc, Arc, or Cow.

This got me started into looking into intricacies of Rust's AsRef, Borrow, and Deref traits. And into how smart pointers are implemented and what traits are involved.

But on the other hand, why should it be necessary for a smart pointer crate to depend on a serialization library (so that it can impl<'a, T> Serialize for Supercow<'a, T> to stay with the example)? Note that this would not serve the purpose of serializing the smart pointer itself. It would be only there so that the smart pointer can be used together with serde like the std-lib defined smart pointers.

I would naively expect that the standard library provides the traits that are necessary to express that something is a smart pointer and that the business of being a smart pointer is completely orthogonal to serialization. But apparently that's not the case, just like the smart pointers in the standard library depend on implementation details of file operations (they implement AsFd).

To compare the situation with C++ (because that's a language that I happen to know well), a smart pointer is a type that provides operator* and operator-> methods. And that's it. Thanks to this thread I now understand somewhat why this is technically necessary in Rust, but is this a bug or a feature?

What I meant is that if there are N orthogonal concepts (e.g. "can be displayed", "is a smart pointer", "can be serialized with serde", etc.) and M types that each implement these concepts that apply to them, I would expect the number of trait implementations to grow like N M times a constant, and not like N times M. For example, all the smart pointer types would fulfill the "smart pointer concept" but otherwise be independent from the other concepts. Another example: std's Rc implements AsFd (for Unix) and AsHandle (for Windows). If now another operating system family is to be supported, it seems wrong to have to impl another unrelated trait for Rc - a type that has nothing to do with file operations after all.

In an ideal world, that would be true. Serde could just implement Serialize<P::Target> where P: Deref. But that sort of blanket impl would massively reduce the usefulness, because it would start conflicting with all sorts of other stuff.

That's Deref in Rust. Whatever you can do with overloading */-> in C++, you can do the same (and more!) in Rust by bounding types by Deref.

Concepts are traits in Rust. You are observing the fact that – in accordance with contemporary good practice – Rust's traits are finer-grained. You are bounding by a trait that you need. You need dereferencing? Bound by Deref. You need to write to a stream? Bound by io::Write.

The "smart pointer concept" as a whole is a lot of different, not necessarily related (or even well-defined) concepts mashed together, which Rust deems not useful enough to justify, but you can make your own MyDefinitionOfSmartPointer trait, which you can piggyback on for the purpose of writing forwarding blanket impls of any other trait you control.

2 Likes

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.