While working on machine-tree I found that Rust has a very powerful arsenal when it comes to dynamic programming and hiding type information.
First of all, what's machine-tree? It's a project aiming to have a React-like library on Rust. But unlike React which is focused on GUI, machine-tree is focused on the tree aspect. Think of a couple of machines structured in the form of a tree each capable of running independently. It is still experimental and suboptimal, so please don't mind the code.
In a React-like construct, the library caller dictates the form inside each node in the tree. The blueprint of the node is called a Component. While at the same time the library manages the tree's data structure in the memory and schedule when each node is executed.
As Above, So Below
Now we have two parties with two different interests. One is the library and the other is the caller. These two different interests are represented as two different data types.
-
The caller sees a node, which is an instance of a component, as a program. It has an input from which the node will get the value from the outside world. It has an output from where the type is restricted to be components too, which later will be treated as the nodes' children. These children nodes can be a different type of component with a different type of input.
-
The library sees nodes as runnable blobs. All nodes are uniformly typed. A node is a program that receives an input of an unknown type and outputs other nodes.
Because of these two different interests, I make two representing types for a node. The first representation is a component trait:
pub trait Component
where
Self: Sized + 'static,
{
type Input: Sized + Clone + 'static;
fn construct(input: &Self::Input) -> Self
where
Self: Sized + 'static;
fn step(
&mut self,
control: &mut dyn Control,
input: &Self::Input
) -> Vec<Node>;
fn seed(input: Self::Input, key: String) -> Node {
// convert the component into a thing called
// StepFn, which relates a lot to `step` fn above
let step_fn: AbstractStepFnBrc =
Box::new(RefCell::new(component_utils::generate_abstract_step_fn::<
Self,
Self::Input,
>(&input)));
let input: AbstractInputBox = Box::new(input);
Node {
// there are other fields, but hidden due to irrelevancies
input,
step_fn,
}
}
}
The Component is a trait with the Input
associated type. A struct can implement the Component trait, which then will have to also implement construct
and step
functions. The construct
function is used to create the struct when supplied with an input
. The step
is the method to be run when it is the node's turn to run. (When a node runs is scheduled by the library). The last function is called seed
, which is typically used in the step
function by calling OtherComponent::seed(some_input)
, which results in a child node.
The step
function is similar to React's render
function. seed
function is similar to React.createElement
or simply <OtherComponent />
in JSX.
The second representation is a Node struct.
pub struct Node {
// there are other fields, but hidden due to irrelevancies
pub(crate) input: AbstractInputBox,
pub(crate) step_fn: AbstractStepFnBrc,
}
// where
pub type StepFn<Input> = Box<dyn FnMut(&mut dyn Control, &Input) -> Vec<Node>>;
pub(crate) type AbstractStepFn = StepFn<AbstractInputBox>;
pub(crate) type AbstractStepFnBrc = Box<RefCell<AbstractStepFn>>;
pub(crate) type InputBox<Input> = Box<Input>;
pub(crate) type AbstractInputBox = InputBox<dyn Any>;
Node is how the library sees an instance of a component. A tree simply contains many nodes. In this case, a node has an input
and a step_fn
. Both are boxed because they have varying sizes and thus need to be put in the heap. Both use Any which helps the library to ignore the actual type of Component::Input
and Component::step
.
In StepFn
's case, it is a dyn FnMut
which is a little powerful type representing a micro stateful micro machine which I will explain in more detail later. StepFn
receives Control
and Input
. In AbstractStepFnBrc
, the StepFn
is wrapped into another box and a refcell which allows us to mutate what is inside of it. We can ignore Control
now because it's irrelevant to this article.
Take notice that definitions of StepFn<Input>
in the Node
struct and step
method in the Component
trait are similar.
Also take notice of the default implementation of the seed
function, which calls component_utils::generate_abstract_step_fn
. This particular function converts component_utils::generate_abstract_step_fn
.
This way, the library can do whatever it wants with Node without worrying about its internal type. Because this is exactly what a library needs to have and be -- an abstraction layer.
FnMut, Closure, A Stateful Micro Machine
A little detour here on how FnMut
is such a powerful construct in Rust. Here's a little code you can access in this Rust Playground.
use std::{any::Any, cell::RefCell};
fn main() {
// define the FnMut here
let mut some_stored_number: u32 = 0;
let fnmut: Box<RefCell<Box<dyn FnMut(u32) -> u32>>> =
Box::new(RefCell::new(Box::new(move |operand: u32| {
some_stored_number = some_stored_number
.checked_add(operand)
.unwrap_or(some_stored_number);
some_stored_number
})));
// Make the FnMut Any
let fnmut: Box<dyn Any> = fnmut;
// Downcast to identify the FnMut as dyn FnMut(u32) -> u32
let fnmut = fnmut
.downcast::<RefCell<Box<dyn FnMut(u32) -> u32>>>()
.unwrap();
// Borrow from RefCell
let mut fnmut_refmut = (*fnmut).borrow_mut();
// Fire the FnMut
println!("{:?}", fnmut_refmut(1)); // 1
println!("{:?}", fnmut_refmut(1)); // 2
println!("{:?}", fnmut_refmut(2)); // 4
println!("{:?}", fnmut_refmut(3)); // 7
}
In essence, this piece of code does a couple of things:
-
Create an
FnMut(u32) -> u32
, a mutable function, with a closure. -
Erase its type into a
Box<dyn Any>
. -
Make the program convert it back to its original type.
-
Fire it.
When fired, you can observe that it stores the previous number.
-
The first call returns 1 from 0 (previously stored number) + 1 (argument)
-
The second call returns 2 from 1 (previously stored number) + 1 (argument)
-
The third call returns 4 from 2 (previously stored number) + 2 (argument)
-
The fourth call returns 7 from 4 (previously stored number) + 3 (argument)
This showcases how FnMut
is a stateful micro-machine. It is a powerful arsenal that is utilized heavily in machine-tree.
The Type Abstraction
Let's get back to this function.
component_utils::generate_abstract_step_fn
The general idea of this function is to convert a Component, including both its input
and its struct instance
into a StepFn
. So first let's define its type-level requirements.
fn generate_abstract_step_fn<Machine, Input>(input: &Input) -> AbstractStepFn
where
Input: Sized + Clone + 'static,
Machine: Sized + Component<Input = Input> + 'static
First, it requires two type parameters, one is a type that implements the Component trait that I dearly call Machine
, and the second is Input
which is assigned as Machine::Input
. The function also receives an input
because we want to construct the Machine
inside this function. Last, is the return type of the function, which is AbstractStepFn
.
Now get to the content of the function.
{
let mut self_state = Machine::construct(input);
Box::new(move |control: &mut dyn Control, input: &AbstractInputBox| {
let input_ref = input.downcast_ref::<Input>().unwrap();
self_state.step(control, input_ref)
})
}
It may seem simple but it might take some time to process. The first line is the construction of the Machine
while the rest of the function is the creation of the closure/FnMut
. The construction is executed instantly, but the code inside the closure is not.
The code inside the closure will be called when the StepFn
is being fired by the library -- a subject to the library's scheduling method. Let's dissect the closure:
-
The closure alongside the box is the
StepFn
. It receives a Control and anAbstractInputBox
, which is essentiallyBox<dyn Any>
. -
The next line attempts to downcast the
AbstractInputBox
into the proper type. If theInput
isu32
so it will be downcasted intou32
, if it'sf32
it will be downcasted into anf32
, and so on. Now you may think that theoretically you can supply any box in there and theunwrap
call will cause panic throughout the whole application. Yes, that is possible! This is why the library's correctness should be verified to always supply a box with the correct type. How? TheNode
struct is the answer. TheNode
struct should always be constructed so that theinput
field and thestep_fn
field are from a matchingMachine
so that the type is always correct. -
The last line in the closure is the
self_state
calling thestep
method. If we revisit thestep
method from theComponent
trait, we can see that it receives an input with an exact type, not the abstract one. This is why the downcasting in the previous line is important.
Sneak Peek Into machine-tree's Inner Working
The scheduling mechanism of the machine-tree is basically how the early version of React does it, without the fiber mechanism. It's quite ancient and I don't want to expose too much here. But the essence is, whenever a node decides that it requires to be "refired" (or in React's terminology "rerendered"), the scheduler will fire that specific node and then subsequently the nodes below it in breadth-first order. There's no memoization mechanism right now either.
Within the firing mechanism of one node. This is how it roughly looks:
local_render_queues
.for_each(|currently_rendered_node_rcc|{
// borrow the node
let mut node_borrow_refmut =
currently_rendered_node_rcc.borrow_mut();
let node_borrow = &mut *node_borrow_refmut;
// borrow the step_fn
let step_fn_borrow = &mut *(node_borrow.step_fn.borrow_mut());
// create the control
let control = Control {
// ...fill fields
};
// Call the borrowed step_fn with control
// and input from node_borrow
let children_nodes: Vec<NodeRcc> =
(step_fn_borrow)(&mut control, &node_borrow.input);
})
A mutable step_fn reference is borrowed and then later called with the input. In this situation, the library can't afford to have nodes with all type information intact because then it will have to manage which input goes to which node. With all type information gone, this code can simply call (step_fn_borrow)(&mut control, &node_borrow.input)
. At the same time, a Component author also can't afford to write the downcasting. Because... ergonomics!
This highlight how neat the component_utils::generate_abstract_step_fn
can be to transform step
function to an abstract one.
The complete code can be found in my repo (GitHub - Kelerchian/machine-tree)