Implementing an operation that can take an expression by value or reference

I'm trying to implement a syntax extension within a proc macro to implement postfix syntax similar to .await to make working with a wrapper type, Tracked<T>, easier. The operation is sort of monadic; if a and b are of type Tracked<usize>, in let c = a.op + b.op, c has type Tracked<usize>. I'd like this operation to take its operand either by value or reference, whichever is least restrictive and allows the code to compile. This would allow for this to work ergonomically.

One way I could imagine doing that is by implementing a method on two different receivers, T and &T. However, without specialization, I don't know how to do something like that. Even with specialization, I'm not sure if this technique would work.

Does anyone have any tips or suggestions on how I might implement this? I'd be happy to share more information on what I'm trying to do, or how I currently handle a variant of this operation, if it helps. Thank you!

Do you mean something like that?

use std::ops::Add;

#[derive(Copy, Clone, PartialEq, Debug)]
struct Tracked<T>(T);

impl<T> Add<Tracked<T>> for Tracked<T> where T: Add<Output = T> + Copy {
    type Output = Tracked<T>;
    
    fn add(self, other: Self) -> Self::Output {
        Tracked(self.0 + other.0)
    }
}

impl<T> Add<&Tracked<T>> for Tracked<T> where T: Add<Output = T> + Copy {
    type Output = Tracked<T>;
    
    fn add(self, other: &Self) -> Self::Output {
        Tracked(self.0 + other.0)
    }
}

impl<T> Add<Tracked<T>> for &Tracked<T> where T: Add<Output = T> + Copy {
    type Output = Tracked<T>;
    
    fn add(self, other: Tracked<T>) -> Self::Output {
        Tracked(self.0 + other.0)
    }
}

impl<T> Add<&Tracked<T>> for &Tracked<T> where T: Add<Output = T> + Copy {
    type Output = Tracked<T>;
    
    fn add(self, other: &Tracked<T>) -> Self::Output {
        Tracked(self.0 + other.0)
    }
}

fn main() {
    let t1: Tracked<usize> = Tracked(1);
    let t2: Tracked<usize> = Tracked(2);
    
    let t3 = t1 + t2;
    let t4 = t1 + &t2;

    let t5 = &t1 + t2;
    let t6 = &t1 + &t2;
    
    assert_eq!(t3, t4);
    assert_eq!(t3, t5);
    assert_eq!(t3, t6);
}

Playground.

I read the question as being more concerned with the .op syntax extension, and not the Add trait. Where presumably the .op extension will be desugared to a method call that needs either an owned or borrowed self receiver. If that's the case, the AsRef trait is what I would reach for:

#[derive(Debug)]
struct Tracked<T> {
    inner: T,
}

impl<T> AsRef<Tracked<T>> for Tracked<T> {
    fn as_ref(&self) -> &Tracked<T> {
        self
    }
}

fn takes_tracked<V, T>(value: V)
where
    V: AsRef<Tracked<T>>,
    T: std::fmt::Display,
{
    println!("Got: {}", value.as_ref().inner);
}

fn main() {
    let t = Tracked { inner: 42_usize };

    takes_tracked(&t);
    takes_tracked(t);
    // takes_tracked(t); // ERROR: use of moved value: `t`
}

AsRef is what I would use in this case too, I just want to mention that AsRef is not an auto trait, nor does it have a blanket implementation, as mentioned int the documents, so you must explicitly add the impl s for the Tracked wrapper type.

ps: although some might tempt to use Borrow here as it has a blanket implementation, personally I would stick with AsRef due to the subtle semantic differences.

3 Likes

Yeah I wasn't the clearest in posing my question, I'm more concerned about the op syntax extension. I think my question fundamentally requires more context, so here goes:

(I'm not sure if/how AsRef might help with the content below, so please bear with me:)

I'm writing a reactive UI library, and the Tracked<T> wrapper stores when a value was last updated, something like this:

struct Tracked<T> {
    inner: T,
    gen: usize,
}

Use of tracked values is pretty common, so I implemented a procedural macro to desugar their use. Basically, when creating a new variable as the result of an expression that has ops, the new variable is of type Tracked<_>, with its gen being the maximum of the gens of the Tracked values in the expression.

Right now, here's what the sugar looks like:

let c = op!(a[b]); // a[b] evaluates to &Tracked<usize>

sugars to something like

let mut __max_gen = 0;
let c = {
    let __val = a[b];
     __max_gen = std::cmp::max(__max_gen, __val.gen);
    Tracked {
        inner: __val.inner,
        gen: __max_gen,
    }
};

The problem with this is that a[b] only returns a reference, but my desugaring code is interpreted as trying to move a value out of a, which isn't allowed.

Instead, the user has to write let c = op!(&a[b]), which is kind of confusing; you might intuitively expect
the syntax to be let c = &op!(a[b]), but that won't compile.

So really, I'd like a way to get let c = &a[b].op; to be desugarable to something that will compile, but also still work in other cases. The expression to the right of = can be almost anything, after all.
let c = (&a[b]).op; seems really ugly to me, but if there's disagreement on that, that feedback would be useful as well.

If you're still with me, some practical cases where this comes up are in this file; op!() is really called tracked!().

Thank you for the additional context. It might be that you are just overthinking the problem, and it's really very simple. op!(a[b]) could expand to ... let __val = &a[b]; .... I.e., just always add the reference operator, no matter what the expression is.

This should work because &&T coerces to &T. Or put another way, this will allow both op!(a[b]) and op!(&a[b]) to be accepted.

2 Likes

Hmm, the problem is I do sometimes want to allow the user to consume Tracked values and move out the inner value. This would become impossible with your solution, unless I'm missing something. Something that your solution inspires me with is the idea of changing the desugaring depending on expression type, so for the Index operator I always add the reference, for example. However that seems very complicated, and I'd prefer a better solution if possible.

Well, yes. I suppose that could be worked around by destructuring the Tracked struct and then dropping the expression. This assumes T: Copy.

Edit; No, don't do this. It's late and I'm not thinking straight. Sorry. :frowning:

Bad suggestion
let mut __max_gen = 0;
let c = {
    let &Tracked { inner, gen } = &a[b];
    drop(a[b]);
     __max_gen = std::cmp::max(__max_gen, gen);
    Tracked {
        inner,
        gen: __max_gen,
    }
};

Ok, I had a chance to sleep on it, and I've decided to go back to my original suggestion. Using AsRef will solve the requirement, as much as I understand the requirement. What is needed is implementing the trait on your Tracked type and providing an identity function that uses it:

impl<T> AsRef<Tracked<T>> for Tracked<T> {
    fn as_ref(&self) -> &Tracked<T> {
        self
    }
}

fn tracked_identity<T, V>(value: V) -> Tracked<T>
where
    V: AsRef<Tracked<T>>,
    T: Copy,
{
    let value = value.as_ref();

    Tracked {
        inner: value.inner,
        gen: value.gen,
    }
}

Then change the expansion of your macro to pass the input expression to this new identity function:

let mut __max_gen = 0;
let c = {
    let __val = ::my_crate::tracked_identity(a[b]);
    // new:     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^    ^
     __max_gen = std::cmp::max(__max_gen, __val.gen);
    Tracked {
        inner: __val.inner,
        gen: __max_gen,
    }
};

I don't think it is possible to avoid the extra function. (I would like to learn more if there actually is a way, though!) It seems to have the same issue as type erasure; you can't dynamically dispatch without actually dispatching, aka calling a function!

For instance, using AsRef::as_ref(expr) would not work if expr evaluated to an owned type, because the as_ref() method takes a borrow. The identity function works because its parameter is generic.

edit: Updated the tracked_identity() function to fix the signature (it was waaaay wrong before) and also added a T: Copy bound. It also looks like this can be further improved to combine the AsRef consumer and max(gen) logic into a single function. But this should be enough to give you some other ideas to work with.

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.