Like #176 this was a well-intentioned but ultimately ill-advised early design decision.
The idea here was to have a Peripherals trait that is a super trait of all of the individual peripheral traits, for a few reasons:
1) so that users of the API could simply ask for a P: Peripherals instance instead of needing to grow 7+ generic parameters
2) so that users of peripheral instances could just operate on a single instance, i.e. Gpio::read(p, ...), Clock::get_milliseconds(p, ...). This is particularly useful in tests
3) so that we don't require peripheral implementations that want to share state with other peripherals (i.e. an Input impl and an Output impl that share state to act as a loopback device) to use external state sharing mechanisms (i.e. Arc<Mutex<_>>, RefCell<_>, etc.)
The 3rd point is a little subtle; here's an example:
under the "obvious" paradigm of having peripheral-trait-users be generic over the individual peripheral traits, you end up with users of the traits having types that look like the PeripheralsSet:
struct MyNeatType<I: Input, O: Output, ...> {
input: I,
output: O,
...
}
the issue with the above is that it requiresinput and output to be separate instances; if we want one type (i.e. LoopbackIo) and one instance of that type to provide both our Input and Output implementations we need to resort to "external" state sharing mechanisms like Arc<Mutex<_>> to produce two instances that ultimately reference the same instance or some shared data
having a Peripherals supertrait on the other hand lets us sidestep this:
the Peripherals implementor can choose to shell out to another type for a particular peripheral trait
but nothing requires it to shell out to separate types (or instances) for each peripheral trait that it is a super trait of
We still recognized that the common case was definitely going to be separate instances for each peripheral and that it would be unacceptable for users to implement Peripherals for each unique set of peripheral trait implementors they wanted to use together, so:
we created PeripheralSet which held instances of each peripheral and was generic over the 7 peripherals
we had PeripheralSetimplement each of the peripheral traits by shelling out to the peripheral instance contained within (this is what the peripheral_trait! macro did)
Problems
This worked but had several downsides, mostly with the peripheral_trait macro:
it's super brittle! the peripheral_trait macro contains an ad-hoc redefinition of the Rust grammar's definition of what constitutes legal syntax for trait definitions: it's both very hard to read and subtly wrong in many ways
bad developer experience: rust-analyzer and other tooling (i.e. rustfmt) don't interact well with items declared inside declarative macros like this. errors stemming from mistakes in the declarative macro's definition of trait definition syntax are extremely hard to diagnose for developers not very familiar with Rust declarative macros
it doesn't compose well; we cannot, for example, do what's described in #... with peripheral_trait! because we can't have a declarative macro like it run on the output of a macro like #[async_trait]
A Solution
3 years later, with the benefit of hindsight, we can come up with a solution that sidesteps these issues while still fulfilling the design constraints that led us to write peripheral_trait.
Peripherals trait
First: We should have the Peripherals trait have associated types for each of the peripheral traits within instead of being a super trait. Something like:
Our goal was to hide the particular types used for the peripherals instead of having them leak into users of the peripherals as generic parameters. Associated types are a cleaner way of achieving this than supertraits.
As an added bonus it's now easier to selectively have some peripherals be shared; i.e. if you want to have a LoopbackIo device, you only need to:
create a struct holding your LoopbackIo device and whatever other peripherals you want to use
implement Peripherals on that struct, exposing the same type and instance for Input and Output and their getters
Under the existing setup you would have had to write delegating trait implementations of all the peripheral traits for your new type (with the Input and Output impls delegating to the same instance).
PeripheralsWrapper
The above addresses the "shared state between peripheral impls" concern (the 3rd point) and manages to make using the Peripherals in your own API not overly cumbersome (you can just be generic over P: Peripherals instead of all 7 individual peripheral traits) but it does not address the 2nd concern: actually using this version of the Peripherals trait is still more cumbersome that the current incarnation because you now need to call get_gpio, etc. before calling your particular peripheral trait's methods.
We can fix this by: providing a type that does have all the peripheral traits implemented on it and delegates to an underlying Peripherals instance.
I think we had kind of the right idea with peripheral_trait! but just bad execution: it's hard to make a declarative macro like this robust and composable but proc macro attributes are a good fit for this task. ambassador is one such proc macro that does exactly what peripheral_trait was looking to do.
We can use ambassador to provide delegated impls of all the peripheral traits on PeripheralsWrapper, where PeripheralsWrapper is a type like:
we'd like to just implement, for example <P: Peripherals> Gpio for P but ambassador doesn't have a way to do this so we use the PeripheralsWrapper newtype
We can then offer this extension to actually construct a PeripheralsWrapper without consuming the underlying Peripherals instance:
pub trait PeripheralsExt: Peripherals {
/// Gets you a wrapper type that impls all the traits.
fn peripherals_wrapper(&self) -> &'_ PeripheralsWrapper<Self> {
unsafe { core::mem::transmute(self) } // safe because of `repr(transparent)`
}
fn peripherals_wrapper_mut(&mut self) -> &'_ mut PeripheralsWrapper<Self> { ... }
}
impl<P: Peripherals> PeripheralsExt for P { }
And have the Deref/DerefMut impls on InstructionInterpreter leverage the above methods.
We can also provide delegated impls on PeripheralSet to cover the common case.
steps
[ ] revise the Peripherals trait
[ ] drop the peripheral_trait! macro
[ ] introduce the ambassador provided delegated impls
what
Background
I.e. this macro: https://github.com/ut-utp/core/blob/5e0f0eb43de2fe9e94f124cff6bc7db0821c7737/traits/src/peripherals/mod.rs#L196-L358
which is invoked on all the peripheral trait definitions: https://github.com/ut-utp/core/blob/5e0f0eb43de2fe9e94f124cff6bc7db0821c7737/traits/src/peripherals/gpio.rs#L141
Like #176 this was a well-intentioned but ultimately ill-advised early design decision.
The idea here was to have a
Peripherals
trait that is a super trait of all of the individual peripheral traits, for a few reasons: 1) so that users of the API could simply ask for aP: Peripherals
instance instead of needing to grow 7+ generic parameters 2) so that users of peripheral instances could just operate on a single instance, i.e.Gpio::read(p, ...)
,Clock::get_milliseconds(p, ...)
. This is particularly useful in tests 3) so that we don't require peripheral implementations that want to share state with other peripherals (i.e. anInput
impl and anOutput
impl that share state to act as a loopback device) to use external state sharing mechanisms (i.e.Arc<Mutex<_>>
,RefCell<_>
, etc.)The 3rd point is a little subtle; here's an example:
PeripheralsSet
:input
andoutput
to be separate instances; if we want one type (i.e.LoopbackIo
) and one instance of that type to provide both ourInput
andOutput
implementations we need to resort to "external" state sharing mechanisms likeArc<Mutex<_>>
to produce two instances that ultimately reference the same instance or some shared dataPeripherals
supertrait on the other hand lets us sidestep this:Peripherals
implementor can choose to shell out to another type for a particular peripheral traitFor the above reasons, we made
Peripherals
a supertrait: https://github.com/ut-utp/core/blob/5e0f0eb43de2fe9e94f124cff6bc7db0821c7737/traits/src/peripherals/mod.rs#L37We still recognized that the common case was definitely going to be separate instances for each peripheral and that it would be unacceptable for users to implement
Peripherals
for each unique set of peripheral trait implementors they wanted to use together, so:PeripheralSet
which held instances of each peripheral and was generic over the 7 peripheralsPeripheralSet
implement each of the peripheral traits by shelling out to the peripheral instance contained within (this is what theperipheral_trait!
macro did)Problems
This worked but had several downsides, mostly with the
peripheral_trait
macro:peripheral_trait
macro contains an ad-hoc redefinition of the Rust grammar's definition of what constitutes legal syntax for trait definitions: it's both very hard to read and subtly wrong in many waysrustfmt
) don't interact well with items declared inside declarative macros like this. errors stemming from mistakes in the declarative macro's definition of trait definition syntax are extremely hard to diagnose for developers not very familiar with Rust declarative macrosperipheral_trait!
because we can't have a declarative macro like it run on the output of a macro like#[async_trait]
A Solution
3 years later, with the benefit of hindsight, we can come up with a solution that sidesteps these issues while still fulfilling the design constraints that led us to write
peripheral_trait
.Peripherals
traitFirst: We should have the
Peripherals
trait have associated types for each of the peripheral traits within instead of being a super trait. Something like:Our goal was to hide the particular types used for the peripherals instead of having them leak into users of the peripherals as generic parameters. Associated types are a cleaner way of achieving this than supertraits.
As an added bonus it's now easier to selectively have some peripherals be shared; i.e. if you want to have a
LoopbackIo
device, you only need to:LoopbackIo
device and whatever other peripherals you want to usePeripherals
on that struct, exposing the same type and instance forInput
andOutput
and their gettersUnder the existing setup you would have had to write delegating trait implementations of all the peripheral traits for your new type (with the
Input
andOutput
impls delegating to the same instance).PeripheralsWrapper
The above addresses the "shared state between peripheral impls" concern (the 3rd point) and manages to make using the Peripherals in your own API not overly cumbersome (you can just be generic over
P: Peripherals
instead of all 7 individual peripheral traits) but it does not address the 2nd concern: actually using this version of thePeripherals
trait is still more cumbersome that the current incarnation because you now need to callget_gpio
, etc. before calling your particular peripheral trait's methods.We can fix this by: providing a type that does have all the peripheral traits implemented on it and delegates to an underlying
Peripherals
instance.I think we had kind of the right idea with
peripheral_trait!
but just bad execution: it's hard to make a declarative macro like this robust and composable but proc macro attributes are a good fit for this task.ambassador
is one such proc macro that does exactly whatperipheral_trait
was looking to do.We can use
ambassador
to provide delegated impls of all the peripheral traits onPeripheralsWrapper
, wherePeripheralsWrapper
is a type like:with delegations like this:
We can then offer this extension to actually construct a
PeripheralsWrapper
without consuming the underlyingPeripherals
instance:And have the
Deref
/DerefMut
impls onInstructionInterpreter
leverage the above methods.We can also provide delegated impls on
PeripheralSet
to cover the common case.steps
Peripherals
traitperipheral_trait!
macroambassador
provided delegated implswhere
branch:
imp/peripheral-trait-reform
open questions