Hi everyone,
I'm relatively new to Rust, so this might be a basic question, but I've been stuck on this for hours and could really use some help.
I have the following code example, which compiles and works as intended. It allows me to create new structs that implement the Computation
trait, and these structs automatically inherit the methods from the SerializableComputation
trait.
pub trait Computation: SerializableComputation {
fn run(&mut self, input: ComputationInput) -> ComputationOutput;
}
pub trait SerializableComputation: Send + Sync {
fn update_computation(&mut self, map: &HashMap<String, Value>);
fn serialize_computation(&self) -> Vec<u8>;
fn deserialize_computation(&self, data: &[u8]) -> Box<dyn SerializableComputation>;
}
impl<T> SerializableComputation for T
where
T: Computation
+ Serialize
+ for<'de> Deserialize<'de>
+ Clone
+ Send
+ Sync
+ 'static,
{
fn update_computation(&mut self, map: &HashMap<String, Value>) {
let mut value = to_value(self.clone()).expect("Failed to convert to serde_json::Value");
if let Value::Object(ref mut obj) = value {
for (key, val) in map {
obj.insert(key.clone(), val.clone());
}
}
*self = from_value(value).expect("Failed to convert from serde_json::Value");
}
fn serialize_computation(&self) -> Vec<u8> {
bincode::serialize(self).expect("Failed to serialize")
}
fn deserialize_computation(&self, data: &[u8]) -> Box<dyn SerializableComputation> {
Box::new(bincode::deserialize::<T>(data).expect("Failed to deserialize"))
}
}
The problem arises when I try to override the methods of the SerializableComputation
trait. For example:
struct MyComputation {}
impl Computation for MyComputation {
...
}
impl SerializableComputation for MyComputation {
....
}
Gives me:
conflicting implementations of trait `SerializableComputation` for type `MyComputation`
It seems that because I've provided a blanket implementation of SerializableComputation
for all types implementing Computation
, I can't write a custom implementation for specific structs like MyComputation
.
My Goal
I want to provide a default implementation for SerializableComputation
that works for most cases but also allows users to override these methods with custom implementations for their specific structs.
What I've Tried
- Using the current blanket implementation: This works, but it prevents me from overriding
SerializableComputation
methods in specific structs because of the conflict.
- Moving methods inside
SerializableComputation
directly: This approach requires adding bounds like Clone + Serialize
to SerializableComputation
, which leads to issues when trying to create trait objects (dyn Computation
can't be used due to object safety concerns).
You might be able to get away with writing a default impl for SerializableComputation
instead of a blanket impl. It requires a little extra work, as you discovered, namely:
- The supertrait cycle needs to be broken. I decided to remove the
Computation
supertrait from SerializableComputation
.
- Without a blanket impl, you need to add
impl SerializableComputation for DefaultComputation {}
to each Computation
that you want to also be serializable. It's just a one-liner that can be derived trivially with #[derive(SerializableComputation)]
!
- Making the trait methods default means that they can't return
Box<dyn Trait>
. They can return Self
instead.
Here is how I did it:
2 Likes
Well, your blanket implementation is more restricted than all types implementing Computation
, so that depends.
The overlap error in such a case often happens because negative reasoning in coherence is limited. For example, just because a foreign type doesn't implement Clone
today doesn't mean it's not allowed to implement it tomorrow. So coherence will assume the blanket implementation may apply to the foreign type, even if it doesn't today (due to unmet bounds on the implementation).
However, negative reasoning is allowed locally. So if MyComputation
is a local type, you should only get an overlap error if MyComputation
actually meets the bounds (i.e. there is an actual overlap today). Now, for that use case, you're basically wishing for specialization, which has stalled out (won't be coming to stable any time soon). So any (stable) approach for overriding the implementation for types that meet the blanket bounds is going to be some sort of workaround.
Here's one such workaround: add a trait to specifically opt in to the default implementation.
pub trait SerializableOptIn
where
Self: 'static + Computation + Send + Sync + Serialize + for<'de> Deserialize<'de> + Clone
{}
impl<T: SerializableOptIn> SerializableComputation for T {
// ...
}
Now downstream can choose to implement SerializableOptIn
(if they've implemented the required supertrait bounds) or SerializableComputation
for their own types. They'll only get an overlap error if they try to implement both, thanks to the local negative reasoning discussed above.
It might not be very idiomatic, and they can't choose a subset of methods to implement.
Another (orthogonal) tact is to supply free functions for the common approach, to make implementing the "default behavior" easier for downstream. The bounds required on the free functions can also be relaxed to what the individual "default method" needs.
2 Likes
Thank you for your response! The solution seems to work but again, it gives many errors once I am trying to do something like this:
let computations: Vec<Computation> = vec![];
Compiling playground v0.0.1 (/playground)
error[E0038]: the trait `Computation` cannot be made into an object
--> src/main.rs:59:23
|
59 | let computations: Vec<Computation> = vec![];
| ^^^^^^^^^^^^^^^^ `Computation` cannot be made into an object
|
note: for a trait to be "object safe" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety>
--> src/main.rs:85:24
|
85 | pub trait Computation: SerializableComputation {
| ----------- ^^^^^^^^^^^^^^^^^^^^^^^ ...because it requires `Self: Sized`
| |
| this trait cannot be made into an object...
...
91 | Self: Serialize + for<'de> Deserialize<'de> + Clone
| ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^ ...because it requires `Self: Sized`
| |
| ...because it requires `Self: Sized`
|
::: /playground/.cargo/registry/src/index.crates.io-6f17d22bba15001f/serde-1.0.214/src/ser/mod.rs:256:8
It's hard to grasp because the code I initially posted works. I can have a collection of computations and call methods like run
or serialize_computation
on them without any issues. However, moving the code directly into SerializableComputation
breaks this functionality."
Thanks! Using SerializableOptIn
solves the issue by providing a way to override the default methods. However, as you mentioned, it has its downsides—I can’t override just a subset of methods. Additionally, each computation now needs to explicitly implement either SerializableOptIn
or SerializableComputation
.
Yes, Vec<Computation>
is invalid because Computation
is a trait. You need a trait object (the erased type must be behind a pointer) like Vec<Box<dyn Computation>>
. And moving the SerializableComputation
methods to default impl makes trait objects impossible with this trait: Deserialize
requires Sized
and there is no way to implement the SerializableComputation
trait without it. (You can drop the Clone
constraint, but it's not enough.)
Since you need to type erase (using trait objects), that rules out default impls for SerializableComputation
.
My experience with trait objects has been pretty painful beyond trivial uses. dyn-compatibility/object safety has very bad ergonomics.
But, if you want dyn Computation
instead of dyn SerializableComputation
, maybe there is still a way out? The supertrait on Computation: SerializableComputation
is what makes Computation
non-dyn-compatible when SerializableComputation
has a default impl with Deserialize
. If you are able to remove the supertrait, then it can work... But it has the problem that you can no longer use the SerializableComputation
methods after type erasure.
On the other hand, macros can do a lot of the heavy lifting by producing pretty much anything into the parser's token stream. So, let's say you write some helper macros to implement the trait and its methods:
Now you can freely mix and match "default methods" with custom method overrides. Using the macros is sort of a "syntactic salt" that gets sprinkled around with this approach. But it preserves the ask for default impls and type erasure.
1 Like
Thank you both! I really appreciate your responses. I aimed to keep the client code as simple as possible, so I decided to use a macro with derive
. Now, users of the library can override the serialization methods with their own implementations if needed, or simply use the default ones by deriving SerializableComputation
.
I still face the challenge of not being able to override parts of the implementation. However, I can simplify this by providing some free functions, as you also suggested.