Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to compare trait objects within an `Arc`?

Let's imagine I have the following context :

let a : Arc<dyn SomeTrait> = getA();
let b : Arc<dyn SomeTrait> = getB();

Now, I would like to know whether a and b hold the same object, but the following two approaches are flagged by Clippy as comparing trait object pointers compares a non-unique vtable address:

let eq1 = std::ptr::eq(a.as_ref(), b.as_ref());
let eq2 = Arc::ptr_eq(&a, &b);

What is the recommended way of checking for trait object equality ?

like image 622
Magix Avatar asked Jan 24 '23 09:01

Magix


2 Answers

I think it is important to explain why the comparison can be ill-advised, since, depending on your use case, you may not be concerned by the problems.

The main reason for something as simple as a (fat) pointer comparison to be considered an anti-pattern is that such a comparison may yield surprising results; for a boolean test, there are only two unintuitive cases:

  • false positives (two expected distinct things nevertheless compare equal);

  • false negatives (two expected equal things end up not comparing equal).

Obviously here all relates to the expectation. The test performed is pointer equality:

  • most would then expect that if two pointers point to the same data then they should compare equalThis is not necessarily the case when talking about fat pointers. This can thus definitely lead to false negatives.

  • some, especially those not used to zero-sized types, may also imagine that two distinct instances must necessarily live in disjoint memory and "thus" feature different addresses. But (instances of) zero-sized types cannot possibly overlap, even if they live at the same address, since such overlap would be zero-sized! That means that you can have "distinct such instances" living at the same address (this is also, by the way, the reason why many languages do not support zero-sized types: losing this property of unique addresses has its own share of caveats). People unaware of this situation may thus observe false positives.

Examples

Two fat pointers can have equal data pointers and yet compare unequal

  • There is a very basic example of it. Consider:

    let arr = [1, 2, 3];
    let all = &arr[..]; // len = 3, data_ptr = arr.as_ptr()
    let first = &arr[.. 1]; // len = 1, data_ptr = arr.as_ptr()
    assert!(::core::ptr::eq(all, first)); // Fails!
    

    This is a basic example where we can see that the extra metadata bundled within a fat pointer (hence their being dubbed "fat") may vary "independently" of the data pointer, leading to these fat pointers then comparing unequal.

Now, the only other instance of fat pointers in the language are (pointers to) dyn Traits / trait objects. The metadata these fat pointers carry is a reference to a struct containing, mainly, the specific methods (as fn pointers) of the trait corresponding to the now erased original type of the data: the virtual method table, a.k.a., the vtable.

Such a reference is automagically produced by the compiler every time a (thus slim) pointer to a concrete type is coerced to a fat pointer:

&42_i32 // (slim) pointer to an integer 42
    as &dyn Display // compiler "fattens" the pointer by embedding a
                    // reference to the vtable of `impl Display for i32`

And it turns out that when the compiler, or more precisely, the current compilation unit, does this, it creates its own vtable.

This means that if different compilation units perform such a coercion, multiple vtables may be involved, and thus the references to them may not all be equal to each other!

I have indeed been able to reproduce this in the following playground which (ab)uses the fact src/{lib,main}.rs are compiled separately.

  • Demo

At the time of my writing, that Playground fails with:

thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `0x5567e54f4047`,
 right: `0x5567e54f4047`', src/main.rs:14:9

As you can see, the data pointers are the same, and the assert_eq! error message only shows those (the Debug impl of fat pointers does not show the metadata).


Two distinct objects may live at the same address

Very simple showcase:

let box1 = Box::new(()); // zero-sized "allocation"
let box2 = Box::new(()); // ditto
let vec = vec![(), ()];

let at_box1: *const () = &*box1;
let at_box2: *const () = &*box2;
let at_vec0: *const () = &vec[0];
let at_vec1: *const () = &vec[1];

assert_eq!(at_vec0, at_vec1); // Guaranteed.
assert_eq!(at_box1, at_box2); // Very likely.
assert_eq!(at_vec0, at_box1); // Likely.
  • Demo

Conclusion?

Now that you know the caveats of fat pointer comparison, you may make the choice to perform the comparison nonetheless (knowingly silencing the clippy lint), if, for instance, all your Arc<dyn Trait> instances are "fattened" (coerced to dyn) in a single place in your code (this avoids the false negatives from different vtables), and if no zero-sized instances are involved (this avoids the false positives).

Example:

mod lib {
    use ::std::rc::Rc;

    pub
    trait MyTrait { /* … */ }

    impl MyTrait for i32 { /* … */ }
    impl MyTrait for String { /* … */ }

    #[derive(Clone)]
    pub
    struct MyType {
        private: Rc<dyn 'static + MyTrait>,
    }

    impl MyType {
     // private! /* generics may be instanced / monomorphized across different crates! */
        fn new<T : 'static + MyTrait> (instance: T)
          -> Option<Self>
        {
            if ::core::mem::size_of::<T>() == 0 {
                None
            } else {
                Some(Self { private: Rc::new(instance) /* as Rc<dyn …> */ })
            }
        }

        pub fn new_i32(i: i32) -> Option<Self> { Self::new(i) }
        pub fn new_string(s: String) -> Option<Self> { Self::new(s) }

        pub
        fn ptr_eq (self: &'_ MyType, other: &'_ MyType)
          -> bool
        {
            // Potentially ok; vtables are all created in the same module,
            // and we have guarded against zero-sized types at construction site.
            ::core::ptr::eq(&*self.private, &*other.private)
        }
    }
}

But, as you can see, even then I seem to be relying on some knowledge about the current implementation of vtable instantiation; this is thus quite unreliable. That's why you should perform slim pointer comparisons only.

TL,DR

Thin each pointer first (&*arc as *const _ as *const ()): only then it is sensible to compare pointers.

like image 162
Daniel H-M Avatar answered Jan 29 '23 23:01

Daniel H-M


What is the recommended way of checking for trait object equality ?

Not sure there's one, aside from "don't do that". Possible alternatives:

  • adding the operation to the trait, either as an intrinsic operation or by adding unique identifiers to the instances (e.g. a UUID) which the trait exposes.
  • using nightly and transmuting to a TraitObject and checking if the data members are identical.
  • casting the fat pointer to a thin pointer and comparing that
like image 27
Masklinn Avatar answered Jan 30 '23 01:01

Masklinn