Reputation: 98
I implemented a small library to make calculations step by step by modifying plan incrementally. I would like to allow making an introspection of the plans without modifying the library itself. For instance I need implementing a function which prints next plan after each step of execution. Or I may need to convert a plan into another representation.
The central abstraction of the library is a Plan<T, R>
trait which inputs an
argument T
and calculates R
:
pub trait Plan<T, R> {
fn step(self: Box<Self>, arg: T) -> StepResult<R>;
}
Plan returns StepResult<R>
which is either an immediate result or new plan
from ()
to R
:
pub enum StepResult<R> {
Plan(Box<dyn Plan<(), R>>),
Result(R),
}
Finally I have few specific plans, for example these two:
pub struct OperatorPlan<T, R> {
operator: Box<dyn FnOnce(T) -> StepResult<R>>,
}
impl<T, R> Plan<T, R> for OperatorPlan<T, R> {
fn step(self: Box<Self>, arg: T) -> StepResult<R> {
(self.operator)(arg)
}
}
pub struct SequencePlan<T1, T2, R> {
first: Box<dyn Plan<T1, T2>>,
second: Box<dyn Plan<T2, R>>,
}
impl<T1: 'static, T2: 'static, R: 'static> Plan<T1, R> for SequencePlan<T1, T2, R> {
fn step(self: Box<Self>, arg: T1) -> StepResult<R> {
match self.first.step(arg) {
StepResult::Plan(next) => StepResult::plan(SequencePlan{
first: next,
second: self.second,
}),
StepResult::Result(result) => StepResult::plan(ApplyPlan{
arg: result,
plan: self.second,
}),
}
}
}
Using the plans I can combine operators and calculate R
from T
step by step
building a plan incrementally.
I have read answers to How do I create a heterogeneous collection of objects? But both "trait" and "enum" solutions doesn't work to me.
I could add new function like fmt
or convert
into Plan<T, R>
trait each
time but the goal is to provide a single function to allow introspection
without modifying the library itself.
I cannot list all plan types as a enum because some of them (like
SequencePlan
) are generics and thus OperatorPlan
can return a Plan
which
exact type is not known in advance.
I tried implementing a Visitor pattern by adding new trait Visitor<T, R>
and
method to accept it into Plan<T, R>
trait:
pub trait Plan<T, R> {
fn step(self: Box<Self>, arg: T) -> StepResult<R>;
fn accept(&self, visitor: &Box<dyn PlanVisitor<T, R>>);
}
trait PlanVisitor<T, R> {
fn visit_operator(&mut self, plan: &OperatorPlan<T, R>);
// Compilation error!
//fn visit_sequence<T2>(&mut self, plan: &SequencePlan<T, T2, R>);
}
This doesn't compile because function visiting SequencePlan
is parameterized
by additional type. On the other hand I don't need to know the full type of the
Plan
to print it.
In C++ I could use dynamic_cast<Display>
to see if Plan
is printable and
use the pointer to Display
interface after. I know that Rust doesn't support
downcasting out of the box.
I would like to know what is a natural way to implement such introspection in Rust?
More complete code on playground
Upvotes: 2
Views: 259
Reputation: 98
I am posting here the code I finally wrote after @battlmonstr answer.
Plan uses erased_serde::Serialize
as a supertrait, but serde::Serialize
also needs to be implemented as specific plans incorporates dyn Plan
trait objects:
use serde::{Serialize, Serializer};
// Generic plan infrastructure
pub trait Plan<T, R>: erased_serde::Serialize {
fn step(self: Box<Self>, arg: T) -> StepResult<R>;
}
impl<T, R: Serialize> Serialize for dyn Plan<T, R> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
erased_serde::serialize(self, serializer)
}
}
In most cases one can use #[derive(Serialize)]
to derive the implementation. Plan
implementations require Serialize
for type parameters:
/// Sequence plan concatenates two underlying plans via middle value of T2 type.
#[derive(Serialize)]
pub struct SequencePlan<T1, T2, R> {
first: Box<dyn Plan<T1, T2>>,
second: Box<dyn Plan<T2, R>>,
}
impl<T1: 'static + Serialize, T2: 'static + Serialize, R: 'static + Serialize> Plan<T1, R> for SequencePlan<T1, T2, R> {
fn step(self: Box<Self>, arg: T1) -> StepResult<R> {
match self.first.step(arg) {
StepResult::Plan(next) => StepResult::plan(SequencePlan{
first: next,
second: self.second,
}),
StepResult::Result(result) => StepResult::plan(ApplyPlan{
arg: result,
plan: self.second,
}),
}
}
}
Sometimes custom implementation is required:
/// Operator from T to StepResult<R> is a plan which calls itself when executed
pub struct OperatorPlan<T, R> {
operator: Box<dyn FnOnce(T) -> StepResult<R>>,
descr: String,
}
impl<T: Serialize, R: Serialize> Plan<T, R> for OperatorPlan<T, R> {
fn step(self: Box<Self>, arg: T) -> StepResult<R> {
(self.operator)(arg)
}
}
impl<T: Serialize, R: Serialize> Serialize for OperatorPlan<T, R> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.descr.as_str())
}
}
Finally one can write a method to serialize a plan into a string:
fn plan_to_string<R>(step: &Box<dyn Plan<(), R>>) -> String {
let mut buf = Vec::new();
let json = &mut serde_json::Serializer::new(Box::new(&mut buf));
let serializer = &mut <dyn erased_serde::Serializer>::erase(json);
step.erased_serialize(serializer).unwrap();
std::str::from_utf8(buf.as_slice()).unwrap().to_string()
}
Full code is at the playground
Upvotes: 1
Reputation: 6290
First of all, Rust is not a dynamic language, so after compilation introspection is not possible unless you are prepared for that. Basically you have to modify your Plan type and your library in some way to support external introspection.
The options are that either you expose all the fields as public, so that you can go over them from an external crate function:
pub SequencePlan {
pub first: ...,
pub second: ...,
Or you have a Visitor-like trait inside the library that goes over the structure for you, and with that you can externally get all of the structure's details.
You can roll own your own Visitor, but having a generic visitor is not a basic task. You need to decide on a common output type for all Plan subtypes, and that type needs to cover all the possible use cases.
In Rust the general case introspection is done in a library serde which is typically used for serialization/conversion.
P.S. It is possible to do something like dynamic_cast
in Rust see downcast_ref, but it is typically not what I'd recommend.
P.P.S. Note that your current Plan is isomorphic to Future, because the only thing you can do with a plan is to call step()
a number of times until you get to a result. Same with a future: you call poll()
multiple times until you get to Poll::Ready(result)
. So consider implementing Plan-s in terms of Future-s, or just use Future-based types directly.
Upvotes: 1