mardi 25 mai 2021

Duplicating the signature of a method at compile time in Rust

In my project I have a trait called Processor, which looks like this (Frame is another trait that abstracts over arrays of numerical samples of any size):

use crate::Frame;

pub trait Processor<const NI: usize, const NO: usize> {
    type Input: Frame<NI>;
    type Output: Frame<NO>;

    fn process(&mut self, input: Self::Input) -> Self::Output;
}

In the same module, I have a number of structs that implement Processor, each with varying fields and behaviors. However, each one has a new method for creating an instance. For example, here's the Map struct along with its new method and Processor/From impls:

pub struct Map<FI, FO, M, const NI: usize, const NO: usize>
where
    FI: Frame<NI>,
    FO: Frame<NO>,
    M: FnMut(FI) -> FO,
{
    pub(super) func: M,
    pub(super) _marker: std::marker::PhantomData<(FI, FO)>,
}

impl<FI, FO, M, const NI: usize, const NO: usize> Map<FI, FO, M, NI, NO>
where
    FI: Frame<NI>,
    FO: Frame<NO>,
    M: FnMut(FI) -> FO,
{
    pub fn new(func: M) -> Self {
        Self { func, _marker: Default::default() }
    }
}

impl<FI, FO, M, const NI: usize, const NO: usize> Processor<NI, NO> for Map<FI, FO, M, NI, NO>
where
    FI: Frame<NI>,
    FO: Frame<NO>,
    M: FnMut(FI) -> FO,
{
    type Input = FI;
    type Output = FO;

    fn process(&mut self, input: Self::Input) -> Self::Output {
        (self.func)(input)
    }
}

Also in my project, I have another trait called Signal, these are streams of Frames that behave similarly to the stdlib Iterator. It makes sense to be able to "plug in" a Processor into a Signal to transform it; indeed, I already have such a method defined on Signal:

pub trait Signal<const N: usize> {
    type Frame: Frame<N>;

    fn next(&mut self) -> Option<Self::Frame>;

    fn process<P, const NO: usize>(self, processor: P) -> SigProcess<Self, P, N, NO>
    where
        Self: Sized,
        P: Processor<N, NO, Input = Self::Frame>,
    {
        // This is a new `Signal` type.
        SigProcess { signal: self, processor }
    }
}

For ergonomics, I'd love to be able to have the user avoid having to import Map/Processor and dealing with its semantics, so I'm planning on adding a map method to Signal itself:

pub trait Signal<const N: usize> {
    /* PREVIOUSLY SHOWN CODE */

    fn map<FO, M, const NO: usize>(self, func: M)
        -> Process<Self, Map<Self::Frame, FO, M, N, NO>, N, NO>
    where
        Self: Sized,
        FO: Frame<NO>,
        M: FnMut(Self::Frame) -> FO,
    {
        let processor = Map::new(func);
        self.process(processor)
    }
}

Whew, that was a lot of build up. Now here's the main question I'm asking: how can I programatically add these methods to the Signal trait at compile time for every Processor implementor I have? As can be seen, creating a Signal::map that bakes in an instance of Map is pretty straightforward: Map::new requires a func: M as input, as does Signal::map (self aside). But it's a LOT of boilerplate code, and it's going to be a pain to manage once I end up with the dozens of Processors in the final version, each of which will need to have a separate corresponding method in Signal.

I'm at a loss as to how to approach this, given:

  1. The complexity with type and const generics, especially since I might have to merge or rename type/const vars.
  2. Not knowing how to do code reflection in Rust, or if it's even possible! I'd need to effectively be able to ask the compiler "please look in the processors module, get a list of all types that impl Processor, and for each one: copy the signatures and generics for the new method and insert a new method in the Signal trait".
  3. If there is a way to approach this with something like procedural macros, I'd have to learn them from scratch, but I'd be willing to do so if it's possible to make this work.

Any advice would be greatly appreciated!





Aucun commentaire:

Enregistrer un commentaire