Scrapmetal — Scrap Your Rust Boilerplate
TLDR: I translated some of the code and ideas
from
Scrap Your Boilerplate: A Practical Design Pattern for Generic Programming by
Lämmel and Peyton Jones to Rust and it’s available as
the scrapmetal
crate.
Say we work on some software that models companies, their departments, sub-departments, employees, and salaries. We might have some type definitions similar to this:
One of our companies has had a morale problem lately, and we want to transform
it into a new company where everyone is excited to come in every Monday through
Friday morning. But we can’t really change the nature of the work, so we figure
we can just give the whole company a 10% raise and call it close enough. This
requires writing a bunch of functions with type signatures like fn(self, k:
f64) -> Self
for every type that makes up a Company
, and since we recognize
the pattern, we should be good Rustaceans and formalize it with a trait:
A company with increased employee salaries is made by increasing the salaries of each of its departments’ employees:
A department with increased employee salaries is made by increasing its manager’s salary and the salary of every employee in its sub-units:
A sub-unit is either a single employee or a sub-department, so either increase the employee’s salary, or increase the salaries of all the people in the sub-department respectively:
An employee with an increased salary, is that same employee with the salary increased:
And finally, a lone salary can be increased:
Pretty straightforward.
But at the same time, that’s a whole lot of boilerplate. The only interesting
part that has anything to do with actually increasing salaries is the impl
Increase for Salary
. The rest of the code is just traversal of the data
structures. If we were to write a function to rename all the employees in a
company, most of this code would remain the same. Surely there’s a way to factor
all this boilerplate out so we don’t have to manually write it all the time?
In the paper Scrap Your Boilerplate: A Practical Design Pattern for Generic Programming, Lämmel and Peyton Jones show us a way to do just that in Haskell. And it turns out the ideas mostly translate into Rust pretty well, too. This blog post explores that translation, following much the same outline from the original paper.
When we’re done, we’ll be able to write the exact same salary increasing functionality with just a couple lines:
We have a few different moving parts involved here:
-
A function that transforms a specific type:
FnMut(T) -> T
. In theincrease
example this is the closure|s: Salary| Salary(s.0 * 1.1)
. -
We have
Transformation::new
, which lifts the transformation function from transforming a single, specific type (FnMut(T) -> T
) into transforming all types (for<U> FnMut(U) -> U
). If we call this new transformation with a value of typeT
, then it will apply ourT
-specific transformation function. If we call it with a value of any other type, it simply returns the given value.Of course, Rust doesn’t actually support rank-2 types, but we can work around this by passing a trait with a generic method, anywhere we wanted to pass
for<U> FnMut(U) -> U
as a parameter. This trait gets implemented byTransformation
:
-
Next is
Everywhere::new
, whose result is also afor<U> FnMut(U) -> U
(aka implements theGenericTransform
trait). This is a combinator that takes a generic transformation function, and traverses a tree of values, applying the generic transformation function to each value along the way. -
Finally, behind the scenes there are two traits:
Term
andCast
. The former provides enumeration of a value’s immediate edges in the value tree. The latter enables us to ask some genericU
if it is a specificT
. These traits completely encapsulate the boilerplate we’ve been trying to rid ourselves of, and neither require any implementation on our part.Term
can be generated mechanically with a custom derive, andCast
can be implemented (in nightly Rust) with specialization.
Next, we’ll walk through the implementation of each of these bits.
Implementing Cast
The Cast
trait is defined like so:
Given some value, we can try and cast it to a T
or if that fails, get the
original value back. You can think of it like instanceof
in JavaScript, but
without walking some prototype or inheritance chain. In the original Haskell,
cast
returns the equivalent of Option<T>
, but we need to get the original
value back if we ever want to use it again because of Rust’s ownership system.
To implement Cast
requires specialization, which is a nightly Rust feature. We
start with a default blanket implementation of Cast
that fails to perform the
conversion:
Then we define a specialization for when Self
is T
that allows the cast to
succeed:
That’s it!
Here is Cast
in action:
Implementing Transformation
Once we have Cast
, implementing generic transformations is easy. If we can
cast the value to our underlying non-generic transformation function’s input
type, then we call it. If we can’t, then we return the given value:
For example, we can lift the logical negation function into a generic transformer. For booleans, it will return the complement of the value, for other values, it leaves them unchanged:
Implementing Term
The next piece of the puzzle is Term
, which enumerates the direct children of
a value. It is defined as follows:
In the original Haskell, map_one_transform
is called gmapT
for “generic map
transform”, and as mentioned earlier GenericTransform
is a workaround for the
lack of rank-2 types, and would otherwise be for<U> FnMut(U) -> U
.
It is important that map_one_transform
does not recursively call its
children’s map_one_transform
methods. We want a building block for making all
different kinds of traversals, not one specific traversal hard coded.
If we were to implement Term
for Employee
, we would write this:
And for SubUnit
, it would look like this:
On the other hand, a floating point number has no children to speak of, and so it would do less:
Note that each of these implementations are driven purely by the structure of
the implementation’s type. enum
s transform whichever variant they are, structs
and tuples transfrom each of their fields, etc. It’s 100% mechanical and 100%
uninteresting.
It’s easy to write a custom derive for implementing Term
. After that’s done,
we just add #[derive(Term)]
to our type definitions:
Implementing Everywhere
Everywhere
takes a generic transformation and then uses
Term::map_one_transform
to recursively apply it to the whole tree. It does so
in a bottom up, left to right order.
Its definition and constructor are trivial:
Then, we implement GenericTransform
for Everywhere
. First we recursively map
across the value’s children, then we transform the given value. This
transforming of children first is what causes the traversal to be bottom up.
If instead we wanted to perform a top down traversal, our choice to implement
mapping non-recursively for Term
enables us to do so:
So What?
At this point, you might be throwing up your hands and complaining about all the infrastructure we had to write in order to get to the two line solution for increasing salaries in a company. Surely all this infrastructure is at least as much code as the original boilerplate? Yes, but this infrastructure can be shared for all the transformations we ever write, and not even just for companies, but values of all types!
For example, if we wanted to make sure every employee in the company was a good culture fit, we might want to rename them all to “Juan Offus”. This is all the code we’d have to write:
Finally, the paper notes that this technique is more future proof than writing out the boilerplate:
Furthermore, if the data types change – for example, a new form of
SubUnit
is added – then the per-data-type boilerplate code must be re-generated, but the code for increase [..] is unchanged.
Queries
What if instead of consuming a T
and transforming it into a new T
, we wanted
to non-destructively produce some other kind of result type R
? In the Haskell
code, generic queries have this type signature:
Translating this into Rust, thinking about ownership and borrowing semantics, and using a trait with a generic method to avoid rank-2 function types, we get this:
Similar to the Transformation
type, we have a Query
type, which lifts a
query function for a particular U
type (FnMut(&U) -> R
) into a generic query
over all types (for<T> FnMut(&T) -> R
aka GenericQuery
). The catch is that we
need some way to create a default instance of R
for the cases where our
generic query function is invoked on a value that isn’t of type &U
. This is
what the D: FnMut() -> R
is for.
When constructing a Query
, and our result type R
implements the Default
trait, we can use Default::default
as D
:
Otherwise, we require a function that we can invoke to give us a default value when we need one:
Here we can see Query
in action:
Next, we extend the Term
trait with a map_one_query
method, similar to
map_one_transform
, that applies the generic query to each of self
’s direct
children.
Note that this produces zero or more R
values, not a single R
! The original
Haskell code returns a list of R
values, and its laziness allows one to only
actually compute as many as end up getting used. But Rust is not lazy, and is
much more explicit about things like physical layout and storage of values. We
don’t want to allocate a (generally small) vector on the heap for every single
map_one_query
call. Instead, we use a callback interface, so that callers can
decide if and when to heap allocate the results.
Implementing map_one_query
for Employee
would look like this:
And implementing it for SubUnit
like this:
Once again, map_one_query
’s implementation directly falls out of the structure
of the type: querying each field of a struct, matching on a variant and querying
each of the matched variant’s children. It is also mechanically implemented
inside #[derive(Term)]
.
The final querying puzzle piece is a combinator putting the one-layer querying
traversal together with generic query functions into recursive querying
traversal. This is very similar to the Everywhere
combinator, but now we also
need a folding function to reduce the multiple R
values we get from
map_one_query
into a single resulting R
value.
Here is its definition and constructor:
We implement the Everything
query traversal top down by querying the given
value before mapping the query across its children and folding their results
together. The wrapping into and unwrapping out of Option
s allow fold
and the
closure to take r
by value; Option
is essentially acting as a “move cell”.
With Everything
defined, we can perform generic queries! For example, to find
the highest salary paid out in a company, we can query by grabbing an
Employee
’s salary (wrapped in an Option
because we could have a shell
company with no employees), and folding all the results together with
std::cmp::max
:
If we were only querying for a single value, for example a Department
with a
particular name, the Haskell paper shows how we could leverage laziness to avoid
traversing the whole search tree once we’ve found an acceptable answer. This is
not an option for Rust. To have equivalent functionality, we would need to
thread a break-or-continue control value from the query function through to
map_one_query
implementations. I haven’t implemented this, but if you want to,
send me a pull request ;-)
However, we can prune subtrees from the search/traversal with the building
blocks we’ve defined so far. For example, EverythingBut
is a generic
transformer combinator that only transforms the subtrees for which its predicate
returns true
, and leaves other subtrees as they are:
What’s Next?
The paper continues by generalizing transforms, queries, and monadic transformations into brain-twisting generic folds over the value tree. Unfortunately, I don’t think that this can be ported to Rust, but maybe you can prove me wrong. I don’t fully grok it yet :)
If the generic folds can’t be expressed in Rust, that means that for every new
kind of generic operation we might want to perform (eg add a generic cloning
operation for<T> FnMut(&T) -> T
) we would need to extend the Term
trait and
its custom derive. The consequences are that downstream crates are constrained
to only use the operations predefined by scrapmetal
, and can’t define their
own arbitrary new operations.
The paper is a fun read — go read it!
Finally, check out the scrapmetal
crate, play with it, and send me pull
requests. I still need to implement Term
for all the types that are exported
in the standard library, and would love some help in this department. I’d
also like to figure out what kinds of operations should come prepackaged, what
kinds of traversals and combinators should be built in, and of course some help
implementing them.