简体   繁体   中英

Create an Object-safe Trait in Rust with a method that accepts a closure

I want to create a trait for Map with the following definition:

pub trait Map<K: Sync, V> {
    fn put(&mut self, k: K, v: V) -> Option<V>;
    fn upsert<U: Fn(&mut V)>(&self, key: K, value: V, updater: &U);
    fn get<Q: ?Sized>(&self, k: &Q) -> Option<V> where K: Borrow<Q>, Q: Eq + Hash + Sync;
    // other methods ommited for brevity
}

Now, the problem is that if I implement this trait, for example as MyHashMap , then I cannot have an expression like this:

let map: Box<Map<i32, i32>> = Box::new(MyHashMap::<i32, i32>::new());

The error would be:

the trait map::Map cannot be made into an object

How can one solve this issue? Because it's not a good idea to start using a Map implementation directly, as it's not a good software engineering practice.

The main issue is that get and upsert methods in the trait accept generic type parameters. My first attempt was to get rid of these generic type parameters.

For get method, it's possible, even though it deviates from the common signature of get in rust collections and makes its usage scenarios more limited. Here is the result:

pub trait Map<K: Sync, V> {
    fn put(&mut self, k: K, v: V) -> Option<V>;
    fn upsert<U: Fn(&mut V)>(&self, key: K, value: V, updater: &U);
    fn get(&self, k: &K) -> Option<V>;
    // other methods ommited for brevity
}

However, I do not have any idea about the way to remove the generic type parameter in upsert .

Any idea on how to deal with this issue?

How can one solve this issue? Because it's not a good idea to start using a Map implementation directly, as it's not a good software engineering practice.

This is good practice in Java, but not necessarily in other languages. For example, in dynamically typed languages, provided all Map implementations use the same naming convention for the methods, they can be substituted without a lot of code changes.

In languages like Rust, which have good type inference, you don't typically need to pollute the code with excessive type annotations. As a result, if you need to change a concrete type, there are fewer places that need to be updated, and it is less of a problem than you'd find in languages like Java.

"Good" Java has the implicit goal that you might want to swap any implementation of your abstract type at run-time . Java makes this easy to do, so it's reasonable to do it, even though, in practice, this is needed very rarely. More likely, you will use some code that expects an abstract type, and you provide it with a concrete instance which is known at compile-time .

This is exactly how Rust works with parameters. When you specify a M: Map parameter, you can work to any M which also implements Map . But the compiler will figure out at compile-time which concrete implementation you are actually using (this is called monomorphization). If you need to change the concrete implementation, you can do so by changing just one line of code. This also has huge benefits for performance.

So, coming back to your first question:

How can one solve this issue?

If you really want to do this, you can introduce another trait object for the mapper function. The reason why a trait object can't have a method with its own generic arguments is because the compiler can't know at compile-time the size of whatever will go there. So just make your function argument into a trait object too, so that this problem goes away:

fn upsert(&self, key: K, value: V, updater: &Fn(&mut V));

But my real answer is, as I've described above, to keep things simple. If you really need this Map abstraction, it should work perfectly well with type parameters whose instantiation is known at compile-time. Use trait objects for when a concrete type cannot be known at compile-time, for example where the implementation can change at run-time.

Disclaimer: I find the premise (good practices) faulty, but still think the question worth answering. Run-time polymorphism has its place, notably to reduce compilation-times.

It is perfectly possible to create an object-safe version of your trait, it just requires two components:

  • the methods that you wish to use via run-time polymorphism should not have generic type parameters,
  • the methods that have type parameters (and which you cannot use via run-time polymorphism) should be guarded via a where Self: Sized clause.

It is possible to offer both alternatives of such methods, though in Rust it requires different names:

pub trait Map<K: Sync, V> {
    fn put(&mut self, k: K, v: V) -> Option<V>;

    fn upsert_erased(&self, key: K, value: V, updater: &Fn(&mut V));

    fn upsert<U: Fn(&mut V)>(&self, key: K, value: V, updater: &U)
        where Self: Sized
    {
        self.upsert_erased(key, value, updater);
    }
}

Not how I chose here to provide a default implementation of upsert via upsert_erased , it reduces the number of methods the concrete type will have to implement while still offering the possibility to actually implemented it if performance warrants it.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM