Shorter syntax for taking either trait A or B

Consider the following fragment with couple of traits with an single method similar in spirit but that cannot be unified behind a single trait for measured performance reasons.

pub trait TA {
    fn get_slice(&self) -> &str;    
}

pub trait TB {
    fn copy_slice(&self, destination: &mut String);    
}

pub enum A_Or_B<A: TA, B: TB> {
    A(A),
    B(B),
}

pub fn get_slice<'a, A: TA, B: TB>(a_or_b: &'a A_Or_B<A, B>, buffer: &'a mut String) -> &'a str {
    match a_or_b {
        A_Or_B::A(a) => a.get_slice(),
        A_Or_B::B(b) => {
            buffer.clear();
            b.copy_slice(buffer);
            buffer.as_str()
        }
    }   
}

As the test function demonstrates, taking either A or B requires rather verbose type declaration:

get_slice<'a, A: TA, B: TB>(a_or_b: &'a A_Or_B<A, B>, buffer: &'a mut String)

With more descriptive names and quite a few methods needed to take either A or B this becomes rather distracting when reading the code. So I wonder is it possible to have a shortcut or some kind of super trait that would allow to reduce that to something like:

get_slice(a_or_b: impl Something, buffer: &'a mut String)
pub trait TA {
    fn get_slice(&self) -> &str;    
}

pub trait TB {
    fn copy_slice(&self, destination: &mut String);    
}

pub struct MarkerA;
pub struct MarkerB;
pub trait AOrB<M> {
    fn do_thing<'a>(&'a self, buffer: &'a mut String) -> &'a str;
}
impl<T: TA> AOrB<MarkerA> for T {
    fn do_thing<'a>(&'a self, _: &'a mut String) -> &'a str {
        self.get_slice()
    }
}
impl<T: TB> AOrB<MarkerB> for T {
    fn do_thing<'a>(&'a self, buffer: &'a mut String) -> &'a str {
        buffer.clear();
        self.copy_slice(buffer);
        buffer.as_str()
    }
}

pub fn get_slice<'a, M, T: AOrB<M>>(a_or_b: &'a T, buffer: &'a mut String) -> &'a str {
    a_or_b.do_thing(buffer)
}
1 Like

This is interesting. The goal here is to optimize based on what contract the type implements, but in practice the abstraction ends up collapsing to the more general path.

Right now, TA gives you a borrowed &str, while TB forces you to copy into a buffer. When you try to unify both behind a single interface, you inevitably fall back to the copy_slice model, because you can’t safely construct and return a borrowed &str out of thin air.

A naive (and honestly realistic) unification would just make everything behave like copy_slice, since that’s the lowest common denominator.

If we still want to preserve the optimization that TA provides (i.e., avoid allocation when possible), then the return type has to reflect that duality. One reasonable way to express this is with Cow<'_, str>:

use std::borrow::Cow;

pub trait SliceTrait {
    fn copy_slice(&self, destination: &mut String);

    fn get_slice(&self) -> Cow<'_, str> {
        let mut s = String::new();
        self.copy_slice(&mut s);
        Cow::Owned(s)
    }
}

Types that can return a borrowed slice (TA-like) can override get_slice to return Cow::Borrowed, while others fall back to the default allocation.

This way:

  • You keep a single trait

  • You preserve the optimization for implementors that can borrow

  • And you avoid forcing everything into a purely copying model

It’s not as “clean” as returning &str, but that’s the cost of unifying two fundamentally different ownership models.

2 Likes

The core problem here is that there's no such thing as mutually exclusive traits. As such, something could implement both A and B, and it's fundamentally ambiguous.

Perhaps you can have one trait with a try_as_str(&self) -> Option<&str> on it that you call first, and if not fall back to the other less-efficient-but-always-works thing?

I almost have the desired functionality with:

mod private {
    pub trait Sealed {}
}

pub trait Common: private::Sealed {
    const CAN_BORROW: bool;
    
    fn get_slice(&self) -> &str {
        unreachable!("Missing CAN_BORROW check");
    }
    
    fn copy_slice(&self, destination: &mut String);
}

pub trait TA {
    fn get_slice(&self) -> &str;    
}

pub trait TB {
    fn copy_slice(&self, destination: &mut String);    
}

impl<T: TA> Common for T {
    const CAN_BORROW: bool = true;

    #[inline]
    fn get_slice(&self) -> &str {
        T::get_slice(self)        
    }

    #[inline]
    fn copy_slice(&self, destination: &mut String) {
        destination.push_str(T::get_slice(self))
    }
}

impl<T: TB> Common for T {
    const CAN_BORROW: bool = false;

    #[inline]
    fn copy_slice(&self, destination: &mut String) {
        T::copy_slice(self, destination)
    }
}


pub fn get_slice<'a, T: Common>(t: &'a T, buffer: &'a mut String) -> &'a str {
    if T::CAN_BORROW {
        t.get_slice()
    } else {
        buffer.clear();
        t.copy_slice(buffer);
        buffer.as_str()
    }   
}

But that does not compile due to that conflicting implementations of trait Common error. So take it work negative trait bounds are necessary.

This can definitely be cleaned up (I'm not sure where best to place the methods and sealing for your use case), but here's a demonstration of using the type system to avoid the conflicting implementations.

Modeled on this approach; perhaps you can find a cleaner application of the idea.

3 Likes

Yes, aka

Why does this approach not work for you?

Note while this works for a this particular example, the problem is that in the real code there are a lot of callers, the optimization is needed in 3 different cases deep in the call chain and the real trait method returns more than just bytes. Of cause, all 3 cases can be covered with the marker approach via 3 trait methods, but then the trait will need to expose the specific usage pattern from different crates.

This is a splendid hack that works around the absence of negative trait bounds via abusing TypeID.

But the complexity is too high. I guess I will go with just single trait with a constant that tells which method should be called to emphasize that it is the type that decides the availability of the optimization, not a particular instance.