Introduction
This book covers the design patterns for context-generic programming (CGP), a new programming paradigm for Rust that allows strongly-typed components to be implemented and composed in a modular, generic, and type-safe way.
What is Context-Generic Programming
A high level overview of CGP is available on the project website. This section contains a summarized version of the overview.
At its core, CGP makes use of Rust's trait system to build generic component interfaces that decouple code that consumes an interface from code that implements an interface. Through this decoupling, code can be written to be generic over any context, and then be wired to be used on a concrete context by writing few lines of code. CGP makes use of Rust's strong type system to help ensure that any such wiring is type-safe, catching any unsatisfied dependencies as compile-time errors.
CGP shares some similarities with other modular programming patterns, such as OCaml modules, Scala implicits, mixins, and dependency injection. Compared to these other patterns, CGP has a unique advantage that it enables high modularity while also being type-safe and concise. With Rust as its host language, CGP also allows high-performance and low-level code to be written in a modular way, without requiring complex runtime support.
CGP is designed to solve a wide range of common problems in programming, including error handling, logging, encoding, and modular dispatch. In particular, it allows writing static-typed Rust programs to be almost as expressive as writing dynamic-typed programs, but with the additional type safety guarantees. If you ever feel that Rust's type system is restricting your ability to reuse code, or be forced to duplicate code through copy-pasting or macros, then CGP may be of help to solve your problem.
That said, programming in CGP is as expressive, but not as easy, as dynamic-typed programming. There may be a steep learning curve in learning how to program in a generic way, and this book aims to help make that learning process more approachable. Thoughout this book, we will slowly understand how CGP works, and learn about useful design patterns that can be used in any programming situation.
Work In Progress
This book is currently a work in progress. A majority of the chapter is yet to be written. Please come back later to check out a completed version of this book.
Scope of This Book
This book is written in the style of a reference material for readers with all levels of expertise. As a result, it may not be as easy to understand for beginners who need a little more introduction to more basic programming techniques in Rust.
A separate book will be written in the future, to provide beginner-friendly tutorials for learning context-generic programming.
For brievity, this book also does not cover motivation or concrete examples of why you should learn and use context-generic programming. We will cover that in blog posts, and a separate book that covers real world use of CGP.
Chapter Outlines
The first section of this book, Terminology, will introduce common terms to be used to understand CGP. We will learn about what is a context, and what are consumer and provider traits.
In the next section, Core Concepts, we will cover the core concepts and the Rust-specific design patterns that we use to enable context-generic programming.
Following that, Design Patterns will introduce general design patterns that are built on top of the foundation of context-generic programming.
The section Domain-Specific Patterns will cover use-case-specific design patterns, such as error handling and logging.
Finally, the secion Related Concepts will compare context-generic programming with related concepts, such as the similarity and differences of context-generic programming as compared to object-oriented programming.
Contribution
This book is open sourced under the MIT license on GitHub.
Anyone is welcome to contribute by submitting pull requests for grammatical correction, content improvement, or adding new design patterns.
A GitHub Discussions forum is available for readers to ask questions or have discussions for topics covered in this book.
Context
In CGP, we use the term context to refer to a type that provide certain functionalities, or dependencies. The most common kind of functionality a context may provide is a method.
Following is a simple hello world example of a context providing a method:
#![allow(unused)] fn main() { struct MyContext; impl MyContext { fn hello(&self) { println!("Hello World!"); } } }
The example above is mostly self explanatory. We first define a struct called MyContext
, followed
by an impl
block for MyContext
. Inside the impl
block, a hello
method is provided,
which prints out "Hello World!"
to the terminal when called.
We can then use the hello
method anywhere that we have a value of type MyContext
, such as:
#![allow(unused)] fn main() { struct MyContext; impl MyContext { fn hello(&self) { println!("Hello World!"); } } let my_context = MyContext; my_context.hello(); }
Contexts vs Classes
The above example may seem trivial for most programmers, especially for those who come from object-oriented programming (OOP) background. In fact, one way we can have a simplified view of a context is that it is similar to OOP concepts such as classes, objects, and interfaces.
Beyond the surface-level similarity, the concept of contexts in CGP is more general than classes and other similar concepts. As a result, it is useful to think of the term context as a new concept that we will learn in this book.
As we will learn in later chapters, aside from methods, a context may provide other kinds of functionalities, such as associated types and constants.
Contexts vs Types
Although a context is usually made of a type, in CGP we do not treat all types as contexts. Instead, we expect CGP contexts to offer some level of modularity, which can be achived by using the programming patterns introduced in this book.
Consumer
In CGP, a consumer is a piece of code that consumes certain functionalities from a context.
There are several ways which a consumer may consume a functionality. At its most basic,
if a consumer has access to the concrete type of a context, it can access any methods
defined by an impl
block of that context.
#![allow(unused)] fn main() { struct Person { name: String } impl Person { fn name(&self) -> &str { &self.name } } fn greet(person: &Person) { println!("Hello, {}!", person.name()); } }
in the above example, we have a greet
function that prints a greeting to a person using
the method Person::name
. In other words, we say that the greet
function is a consumer
to the Person::name
method.
Context-Generic Consumers
The greet
function in our previous example can only work with the Person
struct. However,
if we inspect the implementation of greet
, we can see that it is possible to generalize
greet
to work with any type that has a name.
To generalize greet
, we first need to define a trait that acts as an interface for getting
a name:
#![allow(unused)] fn main() { trait HasName { fn name(&self) -> &str; } fn greet<Context>(context: &Context) where Context: HasName { println!("Hello, {}", context.name()); } }
In the example above, we define a HasName
trait that provides a name
method. We then redefine
greet
to work generically with any Context
type, with the where
clause requiring Context
to
implement HasName
. Inside the function body, we call the name
method, and print out the greeting
of that name.
Notice that in this example, we are able to implement greet
before we have any concrete implementation
of HasName
. Compared to before, greet
is now decoupled from the Person
type, thus making
our code more modular.
In CGP, this new version of greet
is considered a context-generic consumer, as it is able to generically
consume the HasName::name
method from any Context
type that implements HasName
.
The concept of context-generic consumer is not unique to CGP. In fact, it is already commonly used in most of the Rust code that uses traits. However, we make an effort to study this concept, so that we can further generalize the concept in the later chapters of this book.
Provider
In CGP, a provider is a piece of code that implements certain functionality
for a context. At its most basic, a provider is consist of an impl
block for
a trait.
#![allow(unused)] fn main() { trait HasName { fn name(&self) -> &str; } struct Person { name: String } impl HasName for Person { fn name(&self) -> &str { &self.name } } }
In the above example, we implement the HasName
for the Person
struct.
The block impl HasName for Person
is a provider of the HasName
trait
for the Person
context.
Similar to the concept of a consumer, the use of provider is common in any Rust code that implements a trait. However, compared to cosumers, there are limitations on how providers can be defined in Rust.
For this example, the impl
block is a context-specific provider for the
Person
context. Furthermore, due to the restrictions of Rust's trait system,
there can be at most one provider of HasName
for the Person
context.
Another common restriction is that the provider has to be defined in the same
crate as either the trait or the context.
The asymetry between what can be done with a provider, as compared to a consumer, is often a source of complexity in many Rust programs. As we will learn in later chapters, one of the goals of CGP is to break this asymetry, and make it easy to implement context-generic providers.
Providers as Consumers
Although we have providers and consumers as distinct concepts, it is common to have code that serve as both providers and consumers.
#![allow(unused)] fn main() { trait HasName { fn name(&self) -> &str; } struct Person { name: String } impl HasName for Person { fn name(&self) -> &str { &self.name } } trait CanGreet { fn greet(&self); } impl CanGreet for Person { fn greet(&self) { println!("Hello, {}!", self.name()); } } }
The example above shows a new CanGreet
trait, which provides a greet
method.
We then implement CanGreet
for Person
, with the greet
implementation using
self.name()
to print out the name to be greeted.
Here, the block impl CanGreet for Person
is a provider of CanGreet
for the Person
context. At the same time, it is also the consumer of HasName
for the Person
context.
In terms of genericity, the example code is context-specific to the Person
context for both
the consumer and provider side.
As we will see in later chapters, a powerful idea introduced by CGP is that a piece of code can have multiple spectrums of genericity on the consumer and provider sides.
Blanket Trait Implementations
In the previous chapter, we have an implementation of CanGreet
for Person
that
makes use of HasName
to retrieve the person's name to be printed.
However, the implementation is context-specific to the Person
context,
and cannot be reused for other contexts.
Ideally, we want to be able to define context-generic implementations
of Greet
that works with any context type that also implements HasName
.
For this, the blanket trait implementations pattern is one basic way which we can use for
defining context-generic implementations:
#![allow(unused)] fn main() { trait HasName { fn name(&self) -> &str; } trait CanGreet { fn greet(&self); } impl<Context> CanGreet for Context where Context: HasName, { fn greet(&self) { println!("Hello, {}!", self.name()); } } }
The above example shows a blanket trait implementation of CanGreet
for any
Context
type that implements HasName
. With that, contexts like Person
do not need to explicitly implement CanGreet
, if they already implement
HasName
:
#![allow(unused)] fn main() { trait HasName { fn name(&self) -> &str; } trait CanGreet { fn greet(&self); } impl<Context> CanGreet for Context where Context: HasName, { fn greet(&self) { println!("Hello, {}!", self.name()); } } struct Person { name: String } impl HasName for Person { fn name(&self) -> &str { &self.name } } let person = Person { name: "Alice".to_owned() }; person.greet(); }
As shown above, we are able to call person.greet()
without having a context-specific
implementation of CanGreet
for Person
.
Extension Traits
The use of blanket trait implementation is commonly found in many Rust libraries today.
For example, Itertools
provides a blanket implementation for any context that implements Iterator
.
Another example is StreamExt
,
which is implemented for any context that implements Stream
.
Traits such as Itertools
and StreamExt
are sometimes known as extension traits. This is
because the purpose of the trait is to extend the behavior of existing types, without having
to own the type or base traits. While the use of extension traits is a common use case for
blanket implementations, there are other ways we can make use of blanket implementations.
Overriding Blanket Implementations
Traits containing blanket implementation are usually not meant to be implemented manually by individual contexts. They are usually meant to serve as convenient methods that extends the functionality of another trait. However, Rust's trait system does not completely prevent us from overriding the blanket implementation.
Supposed that we have a VipPerson
context that we want to implement a different way of
greeting the VIP person. We could override the implementation as follows:
#![allow(unused)] fn main() { trait HasName { fn name(&self) -> &str; } trait CanGreet { fn greet(&self); } impl<Context> CanGreet for Context where Context: HasName, { fn greet(&self) { println!("Hello, {}!", self.name()); } } struct VipPerson { name: String, /* other fields */ } impl CanGreet for VipPerson { fn greet(&self) { println!("A warm welcome to you, {}!", self.name); } } }
The example above shows two providers of CanGreet
. The first provider is
a context-generic provider that we covered previously, but the second provider
is a context-specific provider for the VipPerson
context.
Conflicting Implementations
In the previous example, we are able to define a custom provider for VipPerson
,
but with an important caveat: that VipPerson
does not implement HasName
.
If we try to define a custom provider for contexts that already implement HasName
,
such as for Person
, the compilation would fail:
#![allow(unused)] fn main() { trait HasName { fn name(&self) -> &str; } trait CanGreet { fn greet(&self); } impl<Context> CanGreet for Context where Context: HasName, { fn greet(&self) { println!("Hello, {}!", self.name()); } } struct Person { name: String } impl HasName for Person { fn name(&self) -> &str { &self.name } } impl CanGreet for Person { fn greet(&self) { println!("Hi, {}!", self.name()); } } }
If we try to compile the example code above, we would get an error with the message:
conflicting implementations of trait `CanGreet` for type `Person`
The reason for the conflict is because Rust trait system requires all types to have unambigious implementation of any given trait. To see why such requirement is necessary, consider the following example:
#![allow(unused)] fn main() { trait HasName { fn name(&self) -> &str; } trait CanGreet { fn greet(&self); } impl<Context> CanGreet for Context where Context: HasName, { fn greet(&self) { println!("Hello, {}!", self.name()); } } fn call_greet_generically<Context>(context: &Context) where Context: HasName, { context.greet() } }
The example above shows a generic function call_greet_generically
, which work with
any Context
that implements HasName
. Even though it does not require Context
to
implement CanGreet
, it nevertheless can call context.greet()
. This is because with
the guarantee from Rust's trait system, the compiler can always safely use the blanket
implementation of CanGreet
during compilation.
If Rust were to allow ambiguous override of blanket implementations, such as what we
tried with Person
, it would have resulted in inconsistencies in the compiled code,
depending on whether it is known that the generic type is instantiated to Person
.
Note that in general, it is not always possible to know locally about the concrete type
that is instantiated in a generic code. This is because a generic function like
call_greet_generically
can once again be called by other generic code. This is why
even though there are unstable Rust features such as
trait specialization,
such feature has to be carefully considered to ensure that no inconsistency can arise.
Limitations of Blanket Implementations
Due to potential conflicting implementations, the use of blanket implementations offer limited customizability, in case if a context wants to have a different implementation. Although a context many define its own context-specific provider to override the blanket provider, it would face other limitations such as not being able to implement other traits that may cause a conflict.
In practice, we consider that blanket implementations allow for a singular context-generic provider to be defined. In future chapters, we will look at how to relax the singular constraint, to make it possible to allow multiple context-generic or context-specific providers to co-exist.
Impl-side Dependencies
When writing generic code, we often need to specify the trait bounds that we would like to use with a generic type. However, when the trait bounds involve traits that make use of blanket implementations, there are different ways that we can specify the trait bounds.
Supposed that we want to define a generic function that formats a list of items
into a comma-separated string. Our generic function could make use the
Itertools::join
to format an iterator. Our first attempt would be to define our generic function
as follows:
#![allow(unused)] fn main() { extern crate core; extern crate itertools; use core::fmt::Display; use itertools::Itertools; fn format_iter<I>(mut iter: I) -> String where I: Iterator, I::Item: Display, { iter.join(", ") } assert_eq!(format_iter(vec![1, 2, 3].into_iter()), "1, 2, 3"); }
The generic function format_iter
takes a generic type I
that implements
Iterator
. Additionally, we require I::Item
to implement Display
. With both
constraints in place, we are able to call Itertools::join
inside the generic
function to join the items using ", "
as separator.
In the above example, we are able to use the method from Itertools
on I
,
even though we do not specify the constraint I: Itertools
in our where
clause.
This is made possible because the trait Itertools
has a blanket implementation
on all types that implement Iterator
, including the case when we do not know
the concrete type behind I
. Additionally, the method Itertools::join
requires
I::Item
to implement Display
, so we also include the constraint in our where clause.
When using traits that have blanket implementation, we can also go the other way
and require I
to implement Itertools
instead of Iterator
:
#![allow(unused)] fn main() { extern crate core; extern crate itertools; use core::fmt::Display; use itertools::Itertools; fn format_iter<I>(mut items: I) -> String where I: Itertools, I::Item: Display, { items.join(", ") } assert_eq!(format_iter(vec![1, 2, 3].into_iter()), "1, 2, 3"); }
By doing so, we make it explicit of the intention that we only care that
I
implements Itertools
, and hide the fact that we need I
to also implement
Iterator
in order to implement Itertools
.
Constraint Leaks
At this point, we have defined our generic function format_iter
with two constraints
in the where
clause. When calling format_iter
from another generic function, the
constraint would also be propagated to the caller.
As a demonstration, supposed that we want to define another generic function that
uses format_iter
to format any type that implements IntoIterator
, we would need
to also include the constraints needed by format_iter
as follows:
#![allow(unused)] fn main() { extern crate core; extern crate itertools; use core::fmt::Display; use itertools::Itertools; fn format_iter<I>(mut items: I) -> String where I: Itertools, I::Item: Display, { items.join(", ") } fn format_items<C>(items: C) -> String where C: IntoIterator, C::IntoIter: Itertools, <C::IntoIter as Iterator>::Item: Display, { format_iter(items.into_iter()) } assert_eq!(format_items(&vec![1, 2, 3]), "1, 2, 3"); }
When defining the generic function format_items
above, we only really care
that the generic type C
implements IntoIterator
, and then pass C::IntoIter
to format_iter
. However, because of the constraints specified by format_iter
,
Rust also forces us to specify the same constraints in format_items
, even if
we don't need the constraints directly.
As we can see, the constraints specified in the where
clause of format_iter
is a form of leaky abstraction, as it also forces generic consumers like
format_items
to also know about the internal details of how format_iter
uses the iterator.
The leaking of where
constraints also makes it challenging to write highly
generic functions at a larger scale. The number of constraints could quickly
become unmanageable, if a high level generic function calls many low-level
generic functions that each has different constraints.
Furthermore, the repeatedly specified constraints become tightly coupled with
the concrete implementation of the low-level functions. For example, if
format_iter
changed from using Itertools::join
to other ways of formatting
the iterator, the constraints would become outdated and need to be changed
in format_items
.
Hiding Constraints with Traits and Blanket Implementations
Using the techniques we learned from blanket implementations, there is a way to
hide the where
constraints by redefining our generic functions as traits with
blanket implementations.
We would first rewrite format_iter
into a trait CanFormatIter
as follows:
#![allow(unused)] fn main() { extern crate core; extern crate itertools; use core::fmt::Display; use itertools::Itertools; pub trait CanFormatIter { fn format_iter(self) -> String; } impl<I> CanFormatIter for I where I: Itertools, I::Item: Display, { fn format_iter(mut self) -> String { self.join(", ") } } assert_eq!(vec![1, 2, 3].into_iter().format_iter(), "1, 2, 3"); }
The trait CanFormatIter
is defined with a single method, format_iter
, which
consumes self
and return a String
. The trait comes with a blanket implementation
for any type I
, with the constraints that I: Itertools
and I::Item: Display
.
Following that, we have the same implementation as before, which calls Itertools::join
to format the iterator as a comma-separated string. By having a blanket implementation,
we signal that CanFormatIter
is intended to be derived automatically, and that no
explicit implementation is required.
It is worth noting that the constraints I: Itertools
and I::Item: Display
are only
present at the impl
block, but not at the trait
definition of CanFormatIter
.
By doing so, we have effectively "hidden" the constraints inside the impl
block,
and prevent it from leaking to its consumers.
We can now refactor format_items
to use CanFormatIter
as follows:
#![allow(unused)] fn main() { extern crate core; extern crate itertools; use core::fmt::Display; use itertools::Itertools; pub trait CanFormatIter { fn format_iter(self) -> String; } impl<I> CanFormatIter for I where I: Itertools, I::Item: Display, { fn format_iter(mut self) -> String { self.join(", ") } } fn format_items<C>(items: C) -> String where C: IntoIterator, C::IntoIter: CanFormatIter, { items.into_iter().format_iter() } assert_eq!(format_items(&vec![1, 2, 3]), "1, 2, 3"); }
In the new version of format_items
, our where
constraints are now simplified
to only require C::IntoIter
to implement CanFormatIter
. With that, we are
able to make it explicit that format_items
needs CanFormatIter
to be implemented,
but it doesn't matter how it is implemented.
The reason this technique works is similar to how we used Itertools
in our
previous examples. At the call site of the code that calls format_items
, Rust
would see that the generic function requires C::IntoIter
to implement
CanFormatIter
. But at the same time, Rust also sees that CanFormatIter
has
a blanket implementation. So if the constraints specified at the blanket
implementation are satisfied, Rust would automatically provide an implementation
of CanFormatIter
to format_items
, without the caller needing to know how
that is done.
Nested Constraints Hiding
Once we have seen in action how we can hide constraints behind the blanket impl
blocks of traits, there is no stopping for us to define more traits with blanket
implementations to hide even more constraints.
For instance, we could rewrite format_items
into a CanFormatItems
trait as follows:
#![allow(unused)] fn main() { extern crate core; extern crate itertools; use core::fmt::Display; use itertools::Itertools; pub trait CanFormatIter { fn format_iter(self) -> String; } impl<I> CanFormatIter for I where I: Itertools, I::Item: Display, { fn format_iter(mut self) -> String { self.join(", ") } } pub trait CanFormatItems { fn format_items(&self) -> String; } impl<Context> CanFormatItems for Context where for<'a> &'a Context: IntoIterator, for<'a> <&'a Context as IntoIterator>::IntoIter: CanFormatIter, { fn format_items(&self) -> String { self.into_iter().format_iter() } } assert_eq!(vec![1, 2, 3].format_items(), "1, 2, 3"); }
We first define a CanFormatItems
trait, with a method format_items(&self)
.
Here, we make an improvement over the original function to allow a reference &self
,
instead of an owned value self
. This allows a container such as Vec
to
not be consumed when we try to format its items, which would be unnecessarily
inefficient.
Inside the blanket impl
block for CanFormatItems
, we define it to work with any
Context
type, given that the generic Context
type implements some constraints
with higher ranked trait bounds (HRTB).
While HRTB is an advanced subject on its own, the general idea is that we require
that any reference &'a Context
with any lifetime 'a
implements IntoIterator
.
This is so that when
IntoIterator::into_iter
is called, the Self
type being consumed is the reference type &'a Context
,
which is implicitly copyable, and thus allow the same context to be reused later
on at other places.
Additionally, we require that <&'a Context as IntoIterator>::IntoIter
implements
CanFormatIter
, so that we can call its method on the produced iterator. Thanks to
the hiding of constraints by CanFormatIter
, we can avoid specifying an overly verbose
constraint that the iterator item also needs to implement Display
.
Individually, the constraints hidden by CanFormatIter
and CanFormatItems
may
not look significant. But when combining together, we can see how isolating the
constraints help us better organize our code and make them cleaner.
In particular, we can now write generic functions that consume CanFormatIter
without having to understand all the indirect constraints underneath.
To demonstrate, supposed that we want to compare two list of items and see
whether they have the same string representation. We can now define a
generic stringly_equals
function as follows:
#![allow(unused)] fn main() { extern crate core; extern crate itertools; use core::fmt::Display; use itertools::Itertools; pub trait CanFormatIter { fn format_iter(self) -> String; } impl<I> CanFormatIter for I where I: Itertools, I::Item: Display, { fn format_iter(mut self) -> String { self.join(", ") } } pub trait CanFormatItems { fn format_items(&self) -> String; } impl<Context> CanFormatItems for Context where for<'a> &'a Context: IntoIterator, for<'a> <&'a Context as IntoIterator>::IntoIter: CanFormatIter, { fn format_items(&self) -> String { self.into_iter().format_iter() } } fn stringly_equals<Context>(left: &Context, right: &Context) -> bool where Context: CanFormatItems, { left.format_items() == right.format_items() } assert_eq!(stringly_equals(&vec![1, 2, 3], &vec![1, 2, 4]), false); }
Our generic function stringly_equals
can now be defined cleanly to work over
any Context
type that implements CanFormatItems
. In this case, the function
does not even need to be aware that Context
needs to produce an iterator, with
its items implementing Display
.
Furthermore, instead of defining a generic function, we could instead use the
same programming technique and define a CanStringlyCompareItems
trait
that does the same thing:
#![allow(unused)] fn main() { extern crate core; extern crate itertools; use core::fmt::Display; use itertools::Itertools; pub trait CanFormatIter { fn format_iter(self) -> String; } impl<I> CanFormatIter for I where I: Itertools, I::Item: Display, { fn format_iter(mut self) -> String { self.join(", ") } } pub trait CanFormatItems { fn format_items(&self) -> String; } impl<Context> CanFormatItems for Context where for<'a> &'a Context: IntoIterator, for<'a> <&'a Context as IntoIterator>::IntoIter: CanFormatIter, { fn format_items(&self) -> String { self.into_iter().format_iter() } } pub trait CanStringlyCompareItems { fn stringly_equals(&self, other: &Self) -> bool; } impl<Context> CanStringlyCompareItems for Context where Context: CanFormatItems, { fn stringly_equals(&self, other: &Self) -> bool { self.format_items() == other.format_items() } } assert_eq!(vec![1, 2, 3].stringly_equals(&vec![1, 2, 4]), false); }
For each new trait we layer on top, we can build higher level interfaces that
hide away lower level implementation details. When CanStringlyCompareItems
is
used, the consumer is shielded away from knowing anything about the concrete
context, other than that two values are be compared by first being formatted
into strings.
The example here may seem a bit stupid, but there are some practical use cases of implementing comparing two values as strings. For instance, a serialization library may want to use it inside tests to check whether two different values are serialized into the same string. For such use case, we may want to define another trait to help make such assertion during tests:
#![allow(unused)] fn main() { extern crate core; extern crate itertools; use core::fmt::Display; use itertools::Itertools; pub trait CanFormatIter { fn format_iter(self) -> String; } impl<I> CanFormatIter for I where I: Itertools, I::Item: Display, { fn format_iter(mut self) -> String { self.join(", ") } } pub trait CanFormatItems { fn format_items(&self) -> String; } impl<Context> CanFormatItems for Context where for<'a> &'a Context: IntoIterator, for<'a> <&'a Context as IntoIterator>::IntoIter: CanFormatIter, { fn format_items(&self) -> String { self.into_iter().format_iter() } } pub trait CanStringlyCompareItems { fn stringly_equals(&self, other: &Self) -> bool; } impl<Context> CanStringlyCompareItems for Context where Context: CanFormatItems, { fn stringly_equals(&self, other: &Self) -> bool { self.format_items() == other.format_items() } } pub trait CanAssertEqualImpliesStringlyEqual { fn assert_equal_implies_stringly_equal(&self, other: &Self); } impl<Context> CanAssertEqualImpliesStringlyEqual for Context where Context: Eq + CanStringlyCompareItems, { fn assert_equal_implies_stringly_equal(&self, other: &Self) { assert_eq!(self == other, self.stringly_equals(other)) } } vec![1, 2, 3].assert_equal_implies_stringly_equal(&vec![1, 2, 3]); vec![1, 2, 3].assert_equal_implies_stringly_equal(&vec![1, 2, 4]); }
The trait CanAssertEqualImpliesStringlyEqual
provides a method that
takes two contexts of the same type, and assert that if both context
values are equal, then their string representation are also equal.
Inside the blanket impl
block, we require that Context
implements
CanStringlyCompareItems
, as well as Eq
.
Thanks to the hiding of constraints, the trait CanAssertEqualImpliesStringlyEqual
can cleanly separate its direct dependencies, Eq
, from the rest of the
indirect dependencies.
Dependency Injection
The programming technique that we have introduced in this chapter is sometimes
known as dependency injection in some other languages and programming paradigms.
The general idea is that the impl
blocks are able to specify the dependencies
they need in the form of where
constraints, and the Rust trait system automatically
helps us to resolve the dependencies at compile time.
In context-generic programming, we think of constraints in the impl
blocks not as
constraints, but more generally dependencies that the concrete implementation needs.
Each time a new trait is defined, it serves as an interface for consumers to include
them as a dependency, but at the same time separates the declaration from the concrete
implementations.
Provider Traits
In the previous chapters on blanket implementations
and impl-side dependencies, we learned about the power
of using blanket impl
blocks to simplify and hide the dependencies required by
each part of the implementation. However, one major limitation of blanket implementations
is that there cannot be multiple potentially overlapping implementations, due to
restrictions in Rust's trait system. In CGP, we can overcome this limitation by introducing
the concept of provider traits.
The main idea behind provider traits is to define Rust traits that are dedicated for
providers to define new implementations, and separate it from the
consumer traits that are more suitable for consumers that use the traits.
Consider a simple consumer trait CanFormatToString
, which allows formatting a context into string:
#![allow(unused)] fn main() { pub trait CanFormatString { fn format_string(&self) -> String; } }
The trait we defined here is almost identical to the standard library's
ToString
trait.
But we will duplicate the trait here to tweak how it is implemented. We first
note that the original ToString
trait has a blanket implementation for any
type that implements Display
:
#![allow(unused)] fn main() { use core::fmt::Display; pub trait CanFormatString { fn format_string(&self) -> String; } impl<Context> CanFormatString for Context where Context: Display, { fn format_string(&self) -> String { format!("{}", self) } } }
Although having this blanket implementation is convenient, it restricts us from
being able to format the context in other ways, such as using Debug
.
#![allow(unused)] fn main() { use core::fmt::{Display, Debug}; pub trait CanFormatString { fn format_string(&self) -> String; } impl<Context> CanFormatString for Context where Context: Display, { fn format_string(&self) -> String { format!("{}", self) } } // Error: conflicting implementation impl<Context> CanFormatString for Context where Context: Debug, { fn format_string(&self) -> String { format!("{:?}", self) } } }
To overcome this limitation, we can introduce a provider trait that we'd call
StringFormatter
, which we will then use for defining implementations:
#![allow(unused)] fn main() { pub trait StringFormatter<Context> { fn format_string(context: &Context) -> String; } }
Compared to CanFormatString
, the trait StringFormatter
replaces the implicit
context type Self
with an explicit context type Context
, as defined in its
type parameter. Following that, it replaces all occurrances of &self
with context: &Context
.
By avoiding the use of Self
in provider traits, we can bypass the restrictions of
Rust trait system, and have multiple implementations defined. Continuing the earlier
example, we can define the Display
and Debug
implementations of CanFormatString
as two separate providers of StringFormatter
:
#![allow(unused)] fn main() { use core::fmt::{Display, Debug}; pub trait StringFormatter<Context> { fn format_string(context: &Context) -> String; } pub struct FormatStringWithDisplay; pub struct FormatStringWithDebug; impl<Context> StringFormatter<Context> for FormatStringWithDisplay where Context: Display, { fn format_string(context: &Context) -> String { format!("{}", context) } } impl<Context> StringFormatter<Context> for FormatStringWithDebug where Context: Debug, { fn format_string(context: &Context) -> String { format!("{:?}", context) } } }
With provider traits, we now have two named providers FormatStringWithDisplay
and FormatStringWithDebug
, which are defined as dummy structs. These structs
are not meant to be used inside any code during run time. Rather, they are used
as identifiers at the type level for us to refer to the providers during
compile time.
Notice that inside the implementation of StringFormatter
, the types
FormatStringWithDisplay
and FormatStringWithDebug
are in the position that is
typically used for Self
, but we don't use Self
anywhere in the implementation.
Instead, the original Self
type is now referred explicitly as the Context
type,
and we use &context
instead of &self
inside the implementation.
From the point of view of Rust's trait system, the rules for overlapping implementation
only applies to the Self
type. But because we have two distinct Self
types here
(FormatStringWithDisplay
and FormatStringWithDebug
), the two implementations are not
considered overlapping, and we are able to define them without any compilation error.
Using Provider Traits Directly
Although provider traits allow us to define overlapping implementations, the main downside is that consumer code cannot make use of an implementation without explicitly choosing the implementation.
Consider the following Person
context defined:
#![allow(unused)] fn main() { use core::fmt::{self, Display, Debug}; pub trait StringFormatter<Context> { fn format_string(context: &Context) -> String; } pub struct FormatStringWithDisplay; pub struct FormatStringWithDebug; impl<Context> StringFormatter<Context> for FormatStringWithDisplay where Context: Display, { fn format_string(context: &Context) -> String { format!("{}", context) } } impl<Context> StringFormatter<Context> for FormatStringWithDebug where Context: Debug, { fn format_string(context: &Context) -> String { format!("{:?}", context) } } #[derive(Debug)] pub struct Person { pub first_name: String, pub last_name: String, } impl Display for Person { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} {}", self.first_name, self.last_name) } } let person = Person { first_name: "John".into(), last_name: "Smith".into() }; assert_eq!( FormatStringWithDisplay::format_string(&person), "John Smith" ); assert_eq!( FormatStringWithDebug::format_string(&person), "Person { first_name: \"John\", last_name: \"Smith\" }" ); }
Our Person
struct is defined with both Debug
and Display
implementations.
When using format_string
on a value person: Person
, we cannot just call
person.format_string()
. Instead, we have to explicitly pick a provider Provider
,
and call it with Provider::format_string(&person)
.
On the other hand, thanks to the explicit syntax, we can use both FormatStringWithDisplay
and FormatStringWithDebug
on Person
without any issue.
Nevertheless, having to explicitly pick a provider can be problematic, especially
if there are multiple providers to choose from. In the next chapter, we will look
at how we can link a provider trait with a consumer trait, so that we can use back
the simple person.format_string()
syntax without needing to know which provider
to choose from.
Beyond String Formatting
In this chapter, we make use of a very simplified example of formatting strings to
demonstrate the use case of provider traits. Our example may seem a bit redundant,
as it does not simplify the code much as compared to directly using format!()
to format the string with either Debug
or Display
. However, the provider trait
allows us to also define providers that format a context in other ways, such as
by serializing it as JSON:
#![allow(unused)] fn main() { extern crate anyhow; extern crate serde; extern crate serde_json; pub trait StringFormatter<Context> { fn format_string(context: &Context) -> Result<String, Error>; } use anyhow::Error; use serde::Serialize; use serde_json::to_string; pub struct FormatAsJson; impl<Context> StringFormatter<Context> for FormatAsJson where Context: Serialize, { fn format_string(context: &Context) -> Result<String, Error> { Ok(to_string(context)?) } } }
To allow for error handling, we update the method signature of format_string
to
return a Result
, with anyhow::Error
being used as a general error type.
We will also be covering better ways to handle errors in a context-generic way in
in later chapters.
If we recall from the previous chapter, the
CanFormatIter
trait in fact has the same method signature as StringFormatter
.
So we can refactor the code from the previous chapter, and turn it into a
context-generic provider that works for any iterable context like Vec
.
This use of provider traits can also be more useful in more complex use cases, such as
implementing Serialize
,
or even the Display
trait itself. If we were to implement these traits using CGP,
we would also define provider traits such as follows:
#![allow(unused)] fn main() { extern crate serde; use core::fmt; use serde::Serializer; pub trait ProvideSerialize<Context> { fn serialize<S: Serializer>(context: &Context, serializer: S) -> Result<S::Ok, S::Error>; } pub trait ProvideFormat<Context> { fn fmt(context: &Context, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error>; } }
As we can see above, we can define provider traits for any existing traits by replacing
the Self
type with an explicit Context
type. In this chapter, we would not be covering
the details on how to use CGP and provider traits to simplify formatting and serialization
implementations, as that is beyond the current scope. Suffice to say, as we go through
later chapters, it will become clearer on how having provider traits can impact us on
thinking about how to structure and implement modular code.
Linking Consumers with Providers
In the previous chapter, we learned about how provider traits allow multiple overlapping implementations to be defined. However, if everything is implemented only as provider traits, it would be much more tedious having to determine which provider to use, at every time when we need to use the trait. To overcome this, we would need have both provider traits and consumer traits, and have some ways to choose a provider when implementing a consumer trait.
Implementing Consumer Traits
The simplest way to link a consumer trait with a provider is by implementing the
consumer trait to call a chosen provider. Consider the StringFormatter
example
of the previous chapter, we would implement CanFormatString
for a Person
context as follows:
#![allow(unused)] fn main() { use core::fmt::{self, Display}; pub trait CanFormatString { fn format_string(&self) -> String; } pub trait StringFormatter<Context> { fn format_string(context: &Context) -> String; } #[derive(Debug)] pub struct Person { pub first_name: String, pub last_name: String, } impl Display for Person { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} {}", self.first_name, self.last_name) } } impl CanFormatString for Person { fn format_string(&self) -> String { FormatStringWithDisplay::format_string(self) } } let person = Person { first_name: "John".into(), last_name: "Smith".into() }; assert_eq!(person.format_string(), "John Smith"); pub struct FormatStringWithDisplay; impl<Context> StringFormatter<Context> for FormatStringWithDisplay where Context: Display, { fn format_string(context: &Context) -> String { format!("{}", context) } } }
To recap the previous chapter, we have a consumer trait CanFormatString
and a provider trait StringFormatter
. There are two example providers that
implemenent StringFormatter
- FormatStringWithDisplay
which formats strings
using Display
, and FormatStringWithDebug
which formats strings using Debug
.
In addition to that, we implement CanFormatString
for the Person
context
by forwarding the call to FormatStringWithDisplay
.
By doing so, we effectively "bind" the StringFormatter
provider for the
Person
context to FormatStringWithDisplay
. With that, any time a consumer
code calls person.format_string()
, it would automatically format the context
using Display
.
Thanks to the decoupling of providers and consumers, a context like Person
can freely choose between multiple providers, and link them with relative ease.
Similarly, the provider trait allows multiple context-generic providers such as
FormatStringWithDisplay
and FormatStringWithDebug
to co-exist.
Blanket Consumer Trait Implementation
In the previous section, we manually implemented CanFormatString
for Person
with an explicit call to FormatStringWithDisplay
. Although the implementation
is relatively short, it can become tedious if we make heavy use of provider traits,
which would require us to repeat the same pattern for every trait.
To simplify this further, we can make use of blanket implementations to
automatically delegate the implementation of all consumer traits to one
chosen provider. We would define the blanket implementation for CanFormatString
as follows:
#![allow(unused)] fn main() { pub trait HasComponents { type Components; } pub trait CanFormatString { fn format_string(&self) -> String; } pub trait StringFormatter<Context> { fn format_string(context: &Context) -> String; } impl<Context> CanFormatString for Context where Context: HasComponents, Context::Components: StringFormatter<Context>, { fn format_string(&self) -> String { Context::Components::format_string(self) } } }
First of all, we define a new HasComponents
trait that contains an associated
type Components
. The Components
type would be specified by a context to
choose a provider that it would use to forward all implementations of consumer
traits. Following that, we add a blanket implementation for CanFormatString
,
which would be implemented for any Context
that implements HasComponents
,
provided that Context::Components
implements StringFormatter<Context>
.
To explain in simpler terms - if a context has a provider that implements a provider trait for that context, then the consumer trait for that context is also automatically implemented.
With the new blanket implementation in place, we can now implement HasComponents
for the Person
context, and it would now help us to implement CanFormatString
for free:
#![allow(unused)] fn main() { use core::fmt::{self, Display}; #[derive(Debug)] pub struct Person { pub first_name: String, pub last_name: String, } impl Display for Person { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} {}", self.first_name, self.last_name) } } impl HasComponents for Person { type Components = FormatStringWithDisplay; } let person = Person { first_name: "John".into(), last_name: "Smith".into() }; assert_eq!(person.format_string(), "John Smith"); pub trait HasComponents { type Components; } pub trait CanFormatString { fn format_string(&self) -> String; } pub trait StringFormatter<Context> { fn format_string(context: &Context) -> String; } impl<Context> CanFormatString for Context where Context: HasComponents, Context::Components: StringFormatter<Context>, { fn format_string(&self) -> String { Context::Components::format_string(self) } } pub struct FormatStringWithDisplay; impl<Context> StringFormatter<Context> for FormatStringWithDisplay where Context: Display, { fn format_string(context: &Context) -> String { format!("{}", context) } } }
Compared to before, the implementation of HasComponents
is much shorter than
implementing CanFormatString
directly, since we only need to specify the provider
type without any function definition.
At the moment, because the Person
context only implements one consumer trait, we
can set FormatStringWithDisplay
directly as Person::Components
. However, if there
are other consumer traits that we would like to use with Person
, we would need to
define Person::Components
with a separate provider that implements multiple provider
traits. This will be covered in the next chapter, which we would talk about how to
link multiple providers of different provider traits together.
Component System
You may have noticed that the trait for specifying the provider for a context is called
HasComponents
instead of HasProviders
. This is to generalize the idea of a pair of
consumer trait and provider trait working together, forming a component.
In context-generic programming, we use the term component to refer to a consumer-provider
trait pair. The consumer trait and the provider trait are linked together through blanket
implementations and traits such as HasComponents
. These constructs working together to
form the basis for a component system for CGP.
Provider Delegation
In the previous chapter, we learned to make use of the HasComponents
trait
to define a blanket implementation for a consumer trait like CanFormatString
,
so that a context would automatically delegate the implementation to a provider
trait like StringFormatter
. However, because there can only be one Components
type defined for HasComponents
, this means that the given provider needs to
implement all provider traits that we would like to use for the context.
In this chapter, we will learn to combine multiple providers that each implements a distinct provider trait, and turn them into a single provider that implements multiple provider traits.
Implementing Multiple Providers
Consider that instead of just formatting a context as string, we also want to parse the context from string. In CGP, we would define two separate traits to handle the functionalities separately:
#![allow(unused)] fn main() { extern crate anyhow; use anyhow::Error; pub trait CanFormatToString { fn format_to_string(&self) -> Result<String, Error>; } pub trait CanParseFromString: Sized { fn parse_from_string(raw: &str) -> Result<Self, Error>; } }
Similar to the previous chapter, we define CanFormatToString
for formatting
a context into string, and CanParseFromString
for parsing a context from a
string. Notice that CanParseFromString
also has an additional Sized
constraint, as by default the Self
type in Rust traits do not implement Sized
,
to allow traits to be used in dyn
trait objects.
Compared to before, we also make the methods return a Result
to
handle errors during formatting and parsing. 1 2
Next, we also define the provider traits as follows:
#![allow(unused)] fn main() { extern crate anyhow; use anyhow::Error; pub trait HasComponents { type Components; } pub trait CanFormatToString { fn format_to_string(&self) -> Result<String, Error>; } pub trait CanParseFromString: Sized { fn parse_from_string(raw: &str) -> Result<Self, Error>; } pub trait StringFormatter<Context> { fn format_to_string(context: &Context) -> Result<String, Error>; } pub trait StringParser<Context> { fn parse_from_string(raw: &str) -> Result<Context, Error>; } impl<Context> CanFormatToString for Context where Context: HasComponents, Context::Components: StringFormatter<Context>, { fn format_to_string(&self) -> Result<String, Error> { Context::Components::format_to_string(self) } } impl<Context> CanParseFromString for Context where Context: HasComponents, Context::Components: StringParser<Context>, { fn parse_from_string(raw: &str) -> Result<Context, Error> { Context::Components::parse_from_string(raw) } } }
Similar to the previous chapter, we make use of blanket implementations
and HasComponents
to link the consumer traits CanFormatToString
and CanParseFromString
with their respective provider traits, StringFormatter
and StringParser
.
We can then implement context-generic providers for the given provider traits,
such as to format and parse the context as JSON if the context implements
Serialize
and Deserialize
:
#![allow(unused)] fn main() { extern crate anyhow; extern crate serde; extern crate serde_json; use anyhow::Error; pub trait StringFormatter<Context> { fn format_to_string(context: &Context) -> Result<String, Error>; } pub trait StringParser<Context> { fn parse_from_string(raw: &str) -> Result<Context, Error>; } use serde::{Serialize, Deserialize}; pub struct FormatAsJsonString; impl<Context> StringFormatter<Context> for FormatAsJsonString where Context: Serialize, { fn format_to_string(context: &Context) -> Result<String, Error> { Ok(serde_json::to_string(context)?) } } pub struct ParseFromJsonString; impl<Context> StringParser<Context> for ParseFromJsonString where Context: for<'a> Deserialize<'a>, { fn parse_from_string(json_str: &str) -> Result<Context, Error> { Ok(serde_json::from_str(json_str)?) } } }
The provider FormatAsJsonString
implements StringFormatter
for any
Context
type that implements Serialize
, and uses serde_json::to_string
to format the context as JSON. Similarly, the provider ParseFromJsonString
implements StringParser
for any Context
that implements Deserialize
,
and parse the context from a JSON string.
A proper introduction to error handling using CGP will be covered in
future chapters. But for now, we will use use
anyhow::Error
to handle errors in a more naive way.
There are more general terms the problem space of formatting and parsing a context, such as serialization or encoding. Instead of strings, a more general solution may use types such as bytes or buffers. Although it is possible to design a generalized solution for encoding in CGP, it would be too much to cover the topic in this chapter alone. As such, we use naive strings in this chapter so that we can focus on first understanding the basic concepts in CGP.
Linking Multiple Providers to a Concrete Context
With the providers implemented, we can now define a concrete context like
Person
, and link it with the given providers. However, since there are
multiple providers, we need to first define an aggregated provider
called PersonComponents
, which would implement both StringFormatter
and StringParser
by delegating the call to the actual providers.
#![allow(unused)] fn main() { extern crate anyhow; extern crate serde; extern crate serde_json; use anyhow::Error; use serde::{Serialize, Deserialize}; pub trait HasComponents { type Components; } pub trait CanFormatToString { fn format_to_string(&self) -> Result<String, Error>; } pub trait CanParseFromString: Sized { fn parse_from_string(raw: &str) -> Result<Self, Error>; } pub trait StringFormatter<Context> { fn format_to_string(context: &Context) -> Result<String, Error>; } pub trait StringParser<Context> { fn parse_from_string(raw: &str) -> Result<Context, Error>; } impl<Context> CanFormatToString for Context where Context: HasComponents, Context::Components: StringFormatter<Context>, { fn format_to_string(&self) -> Result<String, Error> { Context::Components::format_to_string(self) } } impl<Context> CanParseFromString for Context where Context: HasComponents, Context::Components: StringParser<Context>, { fn parse_from_string(raw: &str) -> Result<Context, Error> { Context::Components::parse_from_string(raw) } } pub struct FormatAsJsonString; impl<Context> StringFormatter<Context> for FormatAsJsonString where Context: Serialize, { fn format_to_string(context: &Context) -> Result<String, Error> { Ok(serde_json::to_string(context)?) } } pub struct ParseFromJsonString; impl<Context> StringParser<Context> for ParseFromJsonString where Context: for<'a> Deserialize<'a>, { fn parse_from_string(json_str: &str) -> Result<Context, Error> { Ok(serde_json::from_str(json_str)?) } } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] pub struct Person { pub first_name: String, pub last_name: String, } pub struct PersonComponents; impl HasComponents for Person { type Components = PersonComponents; } impl StringFormatter<Person> for PersonComponents { fn format_to_string(person: &Person) -> Result<String, Error> { FormatAsJsonString::format_to_string(person) } } impl StringParser<Person> for PersonComponents { fn parse_from_string(raw: &str) -> Result<Person, Error> { ParseFromJsonString::parse_from_string(raw) } } let person = Person { first_name: "John".into(), last_name: "Smith".into() }; let person_str = r#"{"first_name":"John","last_name":"Smith"}"#; assert_eq!( person.format_to_string().unwrap(), person_str ); assert_eq!( Person::parse_from_string(person_str).unwrap(), person ); }
We first define Person
struct with auto-derived implementations of
Serialize
and Deserialize
. We also auto-derive Debug
and Eq
for use in tests
later on.
We then define a dummy struct PersonComponents
,
which would be used to aggregate the providers for Person
. Compared to the
previous chapter, we implement HasComponents
for Person
with PersonComponents
as the provider.
We then implement the provider traits StringFormatter
and StringParser
for PersonComponents
, with the actual implementation forwarded to
FormatAsJsonString
and ParseFromJsonString
.
Inside the test that follows, we verify that the wiring indeed automatically
implements CanFormatToString
and CanParseFromString
for Person
,
with the JSON implementation used.
Blanket Provider Implementation
Although the previous example works, the boilerplate for forwarding multiple
implementations by PersonComponents
seems a bit tedious and redundant.
The main differences between the two implementation boilerplate is that
we want to choose FormatAsJsonString
as the provider for StringFormatter
,
and ParseFromJsonString
as the provider for StringParser
.
Similar to how we can use HasComponents
with blanket implementations to link
a consumer with a provider, we can reduce the boilerplate required by using
similar pattern to link a provider with another provider:
#![allow(unused)] fn main() { extern crate anyhow; use anyhow::Error; pub trait CanFormatToString { fn format_to_string(&self) -> Result<String, Error>; } pub trait CanParseFromString: Sized { fn parse_from_string(raw: &str) -> Result<Self, Error>; } pub trait StringFormatter<Context> { fn format_to_string(context: &Context) -> Result<String, Error>; } pub trait StringParser<Context> { fn parse_from_string(raw: &str) -> Result<Context, Error>; } pub trait DelegateComponent<Name> { type Delegate; } pub struct StringFormatterComponent; pub struct StringParserComponent; impl<Context, Component> StringFormatter<Context> for Component where Component: DelegateComponent<StringFormatterComponent>, Component::Delegate: StringFormatter<Context>, { fn format_to_string(context: &Context) -> Result<String, Error> { Component::Delegate::format_to_string(context) } } impl<Context, Component> StringParser<Context> for Component where Component: DelegateComponent<StringParserComponent>, Component::Delegate: StringParser<Context>, { fn parse_from_string(raw: &str) -> Result<Context, Error> { Component::Delegate::parse_from_string(raw) } } }
The DelegateComponent
is similar to the HasComponents
trait, but it is intended
to be implemented by providers instead of concrete contexts. It also has an extra
generic Name
type that is used to differentiate which component the provider
delegation is intended for.
To make use of the Name
parameter, we first need to assign names to the CGP
components that we have defined. We first define the dummy struct
StringFormatterComponent
to be used as the name for StringFormatter
,
and StringParserComponent
to be used as the name for StringParser
.
In general, we can choose any type as the component name. However by convention,
we choose to add a -Component
postfix to the name of the provider trait
to be used as the name of the component.
We then define a blanket implementation for StringFormatter
, which is implemented
for a provider type Component
with the following conditions: if the provider
implements DelegateComponent<StringFormatterComponent>
, and if the associated type
Delegate
also implements StringFormatter<Context>
, then Component
also
implements StringFormatter<Context>
by delegating the implementation to Delegate
.
Following the same pattern, we also define a blanket implementation for StringParser
.
The main difference here is that the name StringParserComponent
is used as the
type argument to DelegateComponent
. In other words, different blanket provider
implementations make use of different Name
types for DelegateComponent
, allowing
different Delegate
to be used depending on the Name
.
Using DelegateComponent
It may take a while to fully understand how the blanket implementations with
DelegateComponent
and HasComponents
work. But since the same pattern will
be used everywhere, it would hopefully become clear as we see more examples.
It would also help to see how the blanket implementation is used, by going
back to the example of implementing the concrete context Person
.
#![allow(unused)] fn main() { extern crate anyhow; extern crate serde; extern crate serde_json; use anyhow::Error; use serde::{Serialize, Deserialize}; pub trait HasComponents { type Components; } pub trait CanFormatToString { fn format_to_string(&self) -> Result<String, Error>; } pub trait CanParseFromString: Sized { fn parse_from_string(raw: &str) -> Result<Self, Error>; } pub trait StringFormatter<Context> { fn format_to_string(context: &Context) -> Result<String, Error>; } pub trait StringParser<Context> { fn parse_from_string(raw: &str) -> Result<Context, Error>; } impl<Context> CanFormatToString for Context where Context: HasComponents, Context::Components: StringFormatter<Context>, { fn format_to_string(&self) -> Result<String, Error> { Context::Components::format_to_string(self) } } impl<Context> CanParseFromString for Context where Context: HasComponents, Context::Components: StringParser<Context>, { fn parse_from_string(raw: &str) -> Result<Context, Error> { Context::Components::parse_from_string(raw) } } pub struct FormatAsJsonString; impl<Context> StringFormatter<Context> for FormatAsJsonString where Context: Serialize, { fn format_to_string(context: &Context) -> Result<String, Error> { Ok(serde_json::to_string(context)?) } } pub struct ParseFromJsonString; impl<Context> StringParser<Context> for ParseFromJsonString where Context: for<'a> Deserialize<'a>, { fn parse_from_string(json_str: &str) -> Result<Context, Error> { Ok(serde_json::from_str(json_str)?) } } pub trait DelegateComponent<Name> { type Delegate; } pub struct StringFormatterComponent; pub struct StringParserComponent; impl<Context, Component> StringFormatter<Context> for Component where Component: DelegateComponent<StringFormatterComponent>, Component::Delegate: StringFormatter<Context>, { fn format_to_string(context: &Context) -> Result<String, Error> { Component::Delegate::format_to_string(context) } } impl<Context, Component> StringParser<Context> for Component where Component: DelegateComponent<StringParserComponent>, Component::Delegate: StringParser<Context>, { fn parse_from_string(raw: &str) -> Result<Context, Error> { Component::Delegate::parse_from_string(raw) } } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] pub struct Person { pub first_name: String, pub last_name: String, } pub struct PersonComponents; impl HasComponents for Person { type Components = PersonComponents; } impl DelegateComponent<StringFormatterComponent> for PersonComponents { type Delegate = FormatAsJsonString; } impl DelegateComponent<StringParserComponent> for PersonComponents { type Delegate = ParseFromJsonString; } let person = Person { first_name: "John".into(), last_name: "Smith".into() }; let person_str = r#"{"first_name":"John","last_name":"Smith"}"#; assert_eq!( person.format_to_string().unwrap(), person_str ); assert_eq!( Person::parse_from_string(person_str).unwrap(), person ); }
Instead of implementing the provider traits, we now only need to implement
DelegateComponent<StringFormatterComponent>
and DelegateComponent<StringParserComponent>
for PersonComponents
. Rust's trait system would then automatically make use of
the blanket implementations to implement CanFormatToString
and CanParseFromString
for Person
.
As we will see in the next chapter, we can make use of macros to further simplify the component delegation, making it as simple as one line to implement such delegation.
Switching Provider Implementations
With the given examples, some readers may question why is there a need to define multiple providers for the JSON implementation, when we can just define one provider struct and implement both provider traits for it.
The use of two providers in this chapter is mainly used as demonstration on how to delegate and combine multiple providers. In practice, as the number of CGP components increase, we would also quickly run into the need have multiple provider implementations and choosing between different combination of providers.
Even with the simplified example here, we can demonstrate how a different provider
for StringFormatter
may be needed. Supposed that we want to format the context
as prettified JSON string, we can define a separate provider FormatAsPrettifiedJsonString
as follows:
#![allow(unused)] fn main() { extern crate anyhow; extern crate serde; extern crate serde_json; use anyhow::Error; use serde::{Serialize, Deserialize}; pub trait StringFormatter<Context> { fn format_to_string(context: &Context) -> Result<String, Error>; } pub struct FormatAsPrettifiedJsonString; impl<Context> StringFormatter<Context> for FormatAsPrettifiedJsonString where Context: Serialize, { fn format_to_string(context: &Context) -> Result<String, Error> { Ok(serde_json::to_string_pretty(context)?) } } }
In the StringFormatter
implementation for FormatAsPrettifiedJsonString
,
we use serde_json::to_string_pretty
instead of serde_json::to_string
to pretty format the JSON string. With CGP, both FormatAsPrettifiedJsonString
and FormatAsJsonString
can co-exist peacefully, and we can easily choose
which provider to use in each concrete context implementation.
#![allow(unused)] fn main() { extern crate anyhow; extern crate serde; extern crate serde_json; use anyhow::Error; use serde::{Serialize, Deserialize}; pub trait HasComponents { type Components; } pub trait CanFormatToString { fn format_to_string(&self) -> Result<String, Error>; } pub trait CanParseFromString: Sized { fn parse_from_string(raw: &str) -> Result<Self, Error>; } pub trait StringFormatter<Context> { fn format_to_string(context: &Context) -> Result<String, Error>; } pub trait StringParser<Context> { fn parse_from_string(raw: &str) -> Result<Context, Error>; } impl<Context> CanFormatToString for Context where Context: HasComponents, Context::Components: StringFormatter<Context>, { fn format_to_string(&self) -> Result<String, Error> { Context::Components::format_to_string(self) } } impl<Context> CanParseFromString for Context where Context: HasComponents, Context::Components: StringParser<Context>, { fn parse_from_string(raw: &str) -> Result<Context, Error> { Context::Components::parse_from_string(raw) } } pub struct FormatAsJsonString; impl<Context> StringFormatter<Context> for FormatAsJsonString where Context: Serialize, { fn format_to_string(context: &Context) -> Result<String, Error> { Ok(serde_json::to_string(context)?) } } pub struct FormatAsPrettifiedJsonString; impl<Context> StringFormatter<Context> for FormatAsPrettifiedJsonString where Context: Serialize, { fn format_to_string(context: &Context) -> Result<String, Error> { Ok(serde_json::to_string_pretty(context)?) } } pub struct ParseFromJsonString; impl<Context> StringParser<Context> for ParseFromJsonString where Context: for<'a> Deserialize<'a>, { fn parse_from_string(json_str: &str) -> Result<Context, Error> { Ok(serde_json::from_str(json_str)?) } } pub trait DelegateComponent<Name> { type Delegate; } pub struct StringFormatterComponent; pub struct StringParserComponent; impl<Context, Component> StringFormatter<Context> for Component where Component: DelegateComponent<StringFormatterComponent>, Component::Delegate: StringFormatter<Context>, { fn format_to_string(context: &Context) -> Result<String, Error> { Component::Delegate::format_to_string(context) } } impl<Context, Component> StringParser<Context> for Component where Component: DelegateComponent<StringParserComponent>, Component::Delegate: StringParser<Context>, { fn parse_from_string(raw: &str) -> Result<Context, Error> { Component::Delegate::parse_from_string(raw) } } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] pub struct Person { pub first_name: String, pub last_name: String, } pub struct PersonComponents; impl HasComponents for Person { type Components = PersonComponents; } impl DelegateComponent<StringFormatterComponent> for PersonComponents { type Delegate = FormatAsPrettifiedJsonString; } impl DelegateComponent<StringParserComponent> for PersonComponents { type Delegate = ParseFromJsonString; } let person = Person { first_name: "John".into(), last_name: "Smith".into() }; let person_str = r#"{ "first_name": "John", "last_name": "Smith" }"#; assert_eq!( person.format_to_string().unwrap(), person_str, ); assert_eq!( Person::parse_from_string(person_str).unwrap(), person ); }
Compared to before, the only line change is to set the Delegate
of
DelegateComponent<StringFormatterComponent>
to FormatAsPrettifiedJsonString
instead of FormatAsJsonString
. With that, we can now easily choose between
whether to pretty print a Person
context as JSON.
Beyond having a prettified implementation, it is also easy to think of other
kinds of generic implementations, such as using Debug
or Display
to format
strings, or use different encodings such as XML to format the string. With CGP,
we can define generalized component interfaces that are applicable to a wide
range of implementations. We can then make use of DelegateComponent
to
easily choose which implementation we want to use for different concrete contexts.
Component Macros
At this point, we have covered all basic building blocks of defining CGP components. In summary, a CGP component is consist of the following building blocks:
- A consumer trait.
- A provider trait.
- A component name type.
- A blanket implementation of the consumer trait using
HasComponents
. - A blanket implementation of the provider trait using
DelegateComponent
.
Syntactically, all CGP components follow the same pattern. The pattern is roughly as follows:
// Consumer trait
pub trait CanPerformAction<GenericA, GenericB, ...>:
ConstraintA + ConstraintB + ...
{
fn perform_action(
&self,
arg_a: ArgA,
arg_b: ArgB,
...
) -> Output;
}
// Provider trait
pub trait ActionPerformer<Context, GenericA, GenericB, ...>
where
Context: ConstraintA + ConstraintB + ...,
{
fn perform_action(
context: &Context,
arg_a: ArgA,
arg_b: ArgB,
...
) -> Output;
}
// Component name
pub struct ActionPerformerComponent;
// Blanket implementation for consumer trait
impl<Context, GenericA, GenericB, ...>
CanPerformAction<GenericA, GenericB, ...> for Context
where
Context: HasComponents + ConstraintA + ConstraintB + ...,
Context::Components: ActionPerformer<Context>,
{
fn perform_action(
&self,
arg_a: ArgA,
arg_b: ArgB,
...
) -> Output {
Context::Components::perform_action(self, arg_a, arg_b, ...)
}
}
// Blanket implementation for provider trait
impl<Context, Component, GenericA, GenericB, ...>
ActionPerformer<Context, GenericA, GenericB, ...>
for Component
where
Context: ConstraintA + ConstraintB + ...,
Component: DelegateComponent<ActionPerformerComponent>,
Component::Delegate: ActionPerformer<Context, GenericA, GenericB, ...>,
{
fn perform_action(
context: &Context,
arg_a: ArgA,
arg_b: ArgB,
...
) -> Output {
Component::Delegate::perform_action(context, arg_a, arg_b, ...)
}
}
derive_component
Macro
With the repetitive pattern, it makes sense that we should be able to
just define the consumer trait, and make use of Rust macros to generate
the remaining code. The author has published the cgp
Rust crate that provides the derive_component
macro that can be used for
this purpose. Using the macro, the same code as above can be significantly
simplified to the following:
use cgp::prelude::*;
#[derive_component(ActionPerformerComponent, ActionPerformer<Context>)]
pub trait CanPerformAction<GenericA, GenericB, ...>:
ConstraintA + ConstraintB + ...
{
fn perform_action(
&self,
arg_a: ArgA,
arg_b: ArgB,
...
) -> Output;
}
To use the macro, the bulk import statement use cgp::prelude::*
has to
be used to bring all CGP constructs into scope. This includes the
HasComponents
and DelegateComponent
traits, which are also provided
by the cgp
crate.
We then use derive_component
as an attribute proc macro, with two
arguments given to the macro. The first argument, ActionPerformerComponent
,
is used to define the name type. The second argument, ActionPerformer<Context>
,
is used as the name for the provider trait, as well as the generic type name
for the context.
delegate_components
Macro
In addition to the derive_component
macro, cgp
also provides the
delegate_components!
macro that can be used to automatically implement
DelegateComponent
for a provider type. The syntax is roughly as follows:
use cgp::prelude::*;
pub struct TargetProvider;
delegate_components! {
TargetProvider {
ComponentA: ProviderA,
ComponentB: ProviderB,
[
ComponentC1,
ComponentC2,
...
]: ProviderC,
}
}
The above code would be desugared into the following:
impl DelegateComponent<ComponentA> for TargetProvider {
type Delegate = ProviderA;
}
impl DelegateComponent<ComponentB> for TargetProvider {
type Delegate = ProviderB;
}
impl DelegateComponent<ComponentC1> for TargetProvider {
type Delegate = ProviderC;
}
impl DelegateComponent<ComponentC2> for TargetProvider {
type Delegate = ProviderC;
}
The delegate_components!
macro accepts an argument to an existing type,
TargetProvider
, which is expected to be defined outside of the macro.
It is followed by an open brace, and contain entries that look like
key-value pairs. For a key-value pair ComponentA: ProviderA
, the type
ComponentA
is used as the component name, and ProviderA
refers to
the provider implementation.
When multiple keys map to the same value, i.e. multiple components are
delegated to the same provider implementation, the array syntax can be
used to further simplify the mapping.
Example Use
To illustrate how derive_component
and delegate_components
can be
used, we revisit the code for CanFormatToString
, CanParseFromString
,
and PersonContext
from the previous chapter,
and look at how the macros can simplify the same code.
Following is the full code after simplification using cgp
:
#![allow(unused)] fn main() { extern crate anyhow; extern crate serde; extern crate serde_json; extern crate cgp; use cgp::prelude::*; use anyhow::Error; use serde::{Serialize, Deserialize}; // Component definitions #[derive_component(StringFormatterComponent, StringFormatter<Context>)] pub trait CanFormatToString { fn format_to_string(&self) -> Result<String, Error>; } #[derive_component(StringParserComponent, StringParser<Context>)] pub trait CanParseFromString: Sized { fn parse_from_string(raw: &str) -> Result<Self, Error>; } // Provider implementations pub struct FormatAsJsonString; impl<Context> StringFormatter<Context> for FormatAsJsonString where Context: Serialize, { fn format_to_string(context: &Context) -> Result<String, Error> { Ok(serde_json::to_string(context)?) } } pub struct ParseFromJsonString; impl<Context> StringParser<Context> for ParseFromJsonString where Context: for<'a> Deserialize<'a>, { fn parse_from_string(json_str: &str) -> Result<Context, Error> { Ok(serde_json::from_str(json_str)?) } } // Concrete context and wiring #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] pub struct Person { pub first_name: String, pub last_name: String, } pub struct PersonComponents; impl HasComponents for Person { type Components = PersonComponents; } delegate_components! { PersonComponents { StringFormatterComponent: FormatAsJsonString, StringParserComponent: ParseFromJsonString, } } let person = Person { first_name: "John".into(), last_name: "Smith".into() }; let person_str = r#"{"first_name":"John","last_name":"Smith"}"#; assert_eq!( person.format_to_string().unwrap(), person_str ); assert_eq!( Person::parse_from_string(person_str).unwrap(), person ); }
As we can see, the new code is significantly simpler and more readable than before.
Using derive_component
, we no longer need to explicitly define the provider
traits StringFormatter
and StringParser
, and the blanket implementations
can be omitted. We also make use of delegate_components!
on PersonComponents
to delegate StringFormatterComponent
to FormatAsJsonString
, and
StringParserComponent
to ParseFromJsonString
.
CGP Macros as Language Extension
The use of cgp
crate with its macros is essential in enabling the full power
of context-generic programming in Rust. Without it, programming with CGP would
become too verbose and full of boilerplate code.
On the other hand, the use of cgp
macros makes CGP code look much more like
programming in a domain-specific language (DSL) than in regular Rust.
In fact, one could argue that CGP acts as a language extension to the base
language Rust, and almost turn into its own programming language.
In a way, implementing CGP in Rust is slightly similar to implementing OOP in C. We could think of context-generic programming being as foundational as object-oriented programming, and may be integrated as a core language feature in future programming languages.
Perhaps one day, there might be an equivalent of C++ to replace CGP-on-Rust.
Or perhaps more ideally, the core constructs of CGP would one day directly
supported as a core language feature in Rust.
But until that happens, the cgp
crate serves as an experimental ground on
how context-generic programming can be done in Rust, and how it can help
build better Rust applications.
In the chapters that follow, we will make heavy use of cgp
and its
macros to dive further into the world of context-generic programming.
Debugging Techniques
By leveraging impl-side dependencies, CGP providers
are able to include additional dependencies that are not specified in the provider
trait. We have already seen this in action in the previous chapter, for example,
where the provider FormatAsJsonString
is able to require Context
to implement Serialize
, while that is not specified anywhere in the provider
trait StringFormatter
.
We have also went through how provider delegation can be done using
DelegateComponent
, which an aggregated provider like PersonComponents
can use to delegate the implementation of StringFormatter
to FormatAsJsonString
.
Within this delegation, we can also see that the requirement for Context
to implement Serialize
is not required in any part of the code.
In fact, because the provider constraints are not enforced in DelegateComponent
,
the delegation would always be successful, even if some provider constraints
are not satisfied. In other words, the impl-side provider constraints are
enforced lazily in CGP, and compile-time errors would only arise when we
try to use a consumer trait against a concrete context.
Unsatisfied Dependency Errors
To demonstrate how such error would arise, we would reuse the same example
Person
context as the previous chapter.
Consider if we made a mistake and forgot to implement Serialize
for Person
:
#![allow(unused)] fn main() { extern crate anyhow; extern crate serde; extern crate serde_json; extern crate cgp; use cgp::prelude::*; use anyhow::Error; use serde::{Serialize, Deserialize}; #[derive_component(StringFormatterComponent, StringFormatter<Context>)] pub trait CanFormatToString { fn format_to_string(&self) -> Result<String, Error>; } #[derive_component(StringParserComponent, StringParser<Context>)] pub trait CanParseFromString: Sized { fn parse_from_string(raw: &str) -> Result<Self, Error>; } pub struct FormatAsJsonString; impl<Context> StringFormatter<Context> for FormatAsJsonString where Context: Serialize, { fn format_to_string(context: &Context) -> Result<String, Error> { Ok(serde_json::to_string(context)?) } } pub struct ParseFromJsonString; impl<Context> StringParser<Context> for ParseFromJsonString where Context: for<'a> Deserialize<'a>, { fn parse_from_string(json_str: &str) -> Result<Context, Error> { Ok(serde_json::from_str(json_str)?) } } // Note: We forgot to derive Serialize here #[derive(Deserialize, Debug, Eq, PartialEq)] pub struct Person { pub first_name: String, pub last_name: String, } pub struct PersonComponents; impl HasComponents for Person { type Components = PersonComponents; } delegate_components! { PersonComponents { StringFormatterComponent: FormatAsJsonString, StringParserComponent: ParseFromJsonString, } } }
We know that Person
uses PersonComponents
to implement CanFormatToString
,
and PersonComponents
delegates the provider implementation to FormatAsJsonString
.
However, since FormatAsJsonString
requires Person
to implement Serialize
,
without it CanFormatToString
cannot be implemented on PersonContext
.
However, notice that the above code still compiles successfully. This is because we
have not yet try to use CanFormatToString
on person. We can try to add test code to
call format_to_string
, and check if it works:
#![allow(unused)] fn main() { extern crate anyhow; extern crate serde; extern crate serde_json; extern crate cgp; use cgp::prelude::*; use anyhow::Error; use serde::{Serialize, Deserialize}; #[derive_component(StringFormatterComponent, StringFormatter<Context>)] pub trait CanFormatToString { fn format_to_string(&self) -> Result<String, Error>; } #[derive_component(StringParserComponent, StringParser<Context>)] pub trait CanParseFromString: Sized { fn parse_from_string(raw: &str) -> Result<Self, Error>; } pub struct FormatAsJsonString; impl<Context> StringFormatter<Context> for FormatAsJsonString where Context: Serialize, { fn format_to_string(context: &Context) -> Result<String, Error> { Ok(serde_json::to_string(context)?) } } pub struct ParseFromJsonString; impl<Context> StringParser<Context> for ParseFromJsonString where Context: for<'a> Deserialize<'a>, { fn parse_from_string(json_str: &str) -> Result<Context, Error> { Ok(serde_json::from_str(json_str)?) } } // Note: We forgot to derive Serialize here #[derive(Deserialize, Debug, Eq, PartialEq)] pub struct Person { pub first_name: String, pub last_name: String, } pub struct PersonComponents; impl HasComponents for Person { type Components = PersonComponents; } delegate_components! { PersonComponents { StringFormatterComponent: FormatAsJsonString, StringParserComponent: ParseFromJsonString, } } let person = Person { first_name: "John".into(), last_name: "Smith".into() }; println!("{}", person.format_to_string().unwrap()); }
The first time we try to call the method, our code would fail with a compile error that looks like follows:
error[E0599]: the method `format_to_string` exists for struct `Person`, but its trait bounds were not satisfied
|
46 | pub struct Person {
| ----------------- method `format_to_string` not found for this struct because it doesn't satisfy `Person: CanFormatToString`
...
51 | pub struct PersonComponents;
| --------------------------- doesn't satisfy `PersonComponents: StringFormatter<Person>`
...
65 | println!("{}", person.format_to_string().unwrap());
| -------^^^^^^^^^^^^^^^^--
| | |
| | this is an associated function, not a method
| help: use associated function syntax instead: `Person::format_to_string()`
|
= note: found the following associated functions; to be used as methods, functions must have a `self` parameter
note: the candidate is defined in the trait `StringFormatter`
|
14 | fn format_to_string(&self) -> Result<String, Error>;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: trait bound `PersonComponents: StringFormatter<Person>` was not satisfied
|
12 | #[derive_component(StringFormatterComponent, StringFormatter<Context>)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: the trait `StringFormatter` must be implemented
|
13 | / pub trait CanFormatToString {
14 | | fn format_to_string(&self) -> Result<String, Error>;
15 | | }
| |_^
= help: items from traits can only be used if the trait is implemented and in scope
note: `CanFormatToString` defines an item `format_to_string`, perhaps you need to implement it
|
13 | pub trait CanFormatToString {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
= note: this error originates in the attribute macro `derive_component` (in Nightly builds, run with -Z macro-backtrace for more info)
Unfortunately, the error message returned from Rust is very confusing, and not
helpful at all in guiding us to the root cause. For an inexperience developer,
the main takeaway from the error message is just that CanFormatString
is
not implemented for Person
, but the developer is left entirely on their
own to find out how to fix it.
One main reason we get such obscured errors is because the implementation of
CanFormatString
is done through two indirect blanket implementations. As Rust
was not originally designed for blanket implementations to be used this way,
it does not follow through to explain why the blanket implementation is not
implemented.
Technically, there is no reason why the Rust compiler cannot be improved to show more detailed errors to make using CGP easier. However, improving the compiler will take time, and we need to present strong argument on why such improvement is needed, e.g. through this book. Until then, we need temporary workarounds to make it easier to debug CGP errors in the meanwhile.
Check Traits
We have learned that CGP lazily resolve dependencies and implements consumer traits on a concrete context only when they are actively used. However, when defining a concrete context, we would like to be able to eagerly check that the consumer traits are implemented, so that no confusing error should arise when the context is being used.
By convention, the best approach for implementing such checks is to define a check trait, which asserts that a concrete context implements all consumer traits that we intended to implement. the check trait would be defined as follows:
#![allow(unused)] fn main() { extern crate anyhow; extern crate serde; extern crate serde_json; extern crate cgp; use cgp::prelude::*; use anyhow::Error; use serde::{Serialize, Deserialize}; #[derive_component(StringFormatterComponent, StringFormatter<Context>)] pub trait CanFormatToString { fn format_to_string(&self) -> Result<String, Error>; } #[derive_component(StringParserComponent, StringParser<Context>)] pub trait CanParseFromString: Sized { fn parse_from_string(raw: &str) -> Result<Self, Error>; } pub struct FormatAsJsonString; impl<Context> StringFormatter<Context> for FormatAsJsonString where Context: Serialize, { fn format_to_string(context: &Context) -> Result<String, Error> { Ok(serde_json::to_string(context)?) } } pub struct ParseFromJsonString; impl<Context> StringParser<Context> for ParseFromJsonString where Context: for<'a> Deserialize<'a>, { fn parse_from_string(json_str: &str) -> Result<Context, Error> { Ok(serde_json::from_str(json_str)?) } } // Note: We forgot to derive Serialize here #[derive(Deserialize, Debug, Eq, PartialEq)] pub struct Person { pub first_name: String, pub last_name: String, } pub struct PersonComponents; impl HasComponents for Person { type Components = PersonComponents; } delegate_components! { PersonComponents { StringFormatterComponent: FormatAsJsonString, StringParserComponent: ParseFromJsonString, } } pub trait CanUsePerson: CanFormatToString + CanParseFromString {} impl CanUsePerson for Person {} }
By convention, a check trait has the name starts with CanUse
, followed by
the name of the concrete context. We list all the consumer traits that
the concrete context should implement as the super trait. The check trait
has an empty body, followed by a blanket implementation for the target
concrete context.
In the example above, we define the check trait CanUsePerson
, which is used
to check that the concrete context Person
implements CanFormatToString
and
CanParseFromString
. If we try to compile the check trait with the
same example code as before, we would get the following error message:
error[E0277]: the trait bound `FormatAsJsonString: StringFormatter<Person>` is not satisfied
|
69 | impl CanUsePerson for Person {}
| ^^^^^^ the trait `StringFormatter<Person>` is not implemented for `FormatAsJsonString`, which is required by `Person: CanFormatToString`
|
= help: the trait `StringFormatter<Context>` is implemented for `FormatAsJsonString`
note: required for `PersonComponents` to implement `StringFormatter<Person>`
|
12 | #[derive_component(StringFormatterComponent, StringFormatter<Context>)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: required for `Person` to implement `CanFormatToString`
|
12 | #[derive_component(StringFormatterComponent, StringFormatter<Context>)]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: required by a bound in `CanUsePerson`
|
64 | pub trait CanUsePerson:
| ------------ required by a bound in this trait
65 | CanFormatToString
| ^^^^^^^^^^^^^^^^^ required by this bound in `CanUsePerson`
= note: `CanUsePerson` is a "sealed trait", because to implement it you also need to implement `main::_doctest_main_check_traits_md_229_0::CanFormatToString`, which is not accessible; this is usually done to force you to use one of the provided types that already implement it
= help: the following type implements the trait:
Context
= note: this error originates in the attribute macro `derive_component` (in Nightly builds, run with -Z macro-backtrace for more info)
The error message is still pretty confusing, but it is slightly more informative
than the previous error. Here, we can see that the top of the error says that
StringFormatter<Person>
is not implemented for FormatAsJsonString
.
Although it does not point to the root cause, at least we are guided to look
into the implementation of FormatAsJsonString
to find out what went wrong there.
At the moment, there is no better way to simplify debugging further, and
we need to manually look into FormatAsJsonString
to check why it could not
implement StringFormatter
for the Person
context. Here, the important
thing to look for is the additional constraints that FormatAsJsonString
requires on the context, which in this case is Serialize
.
We can make use of the check trait to further track down all the indirect
dependencies of the providers that the concrete context uses. In this case,
we determine that Serialize
is needed for FormatAsJsonString
, and
Deserialize
is needed for ParseFromJsonString
. So we add them into
the super trait of CanUsePerson
as follows:
#![allow(unused)] fn main() { extern crate anyhow; extern crate serde; extern crate serde_json; extern crate cgp; use cgp::prelude::*; use anyhow::Error; use serde::{Serialize, Deserialize}; #[derive_component(StringFormatterComponent, StringFormatter<Context>)] pub trait CanFormatToString { fn format_to_string(&self) -> Result<String, Error>; } #[derive_component(StringParserComponent, StringParser<Context>)] pub trait CanParseFromString: Sized { fn parse_from_string(raw: &str) -> Result<Self, Error>; } pub struct FormatAsJsonString; impl<Context> StringFormatter<Context> for FormatAsJsonString where Context: Serialize, { fn format_to_string(context: &Context) -> Result<String, Error> { Ok(serde_json::to_string(context)?) } } pub struct ParseFromJsonString; impl<Context> StringParser<Context> for ParseFromJsonString where Context: for<'a> Deserialize<'a>, { fn parse_from_string(json_str: &str) -> Result<Context, Error> { Ok(serde_json::from_str(json_str)?) } } // Note: We forgot to derive Serialize here #[derive(Deserialize, Debug, Eq, PartialEq)] pub struct Person { pub first_name: String, pub last_name: String, } pub struct PersonComponents; impl HasComponents for Person { type Components = PersonComponents; } delegate_components! { PersonComponents { StringFormatterComponent: FormatAsJsonString, StringParserComponent: ParseFromJsonString, } } pub trait CanUsePerson: Serialize + for<'a> Deserialize<'a> + CanFormatToString + CanParseFromString {} impl CanUsePerson for Person {} }
When we try to compile CanUsePerson
again, we would see a different error
message at the top:
error[E0277]: the trait bound `Person: Serialize` is not satisfied
|
71 | impl CanUsePerson for Person {}
| ^^^^^^ the trait `Serialize` is not implemented for `Person`
|
= note: for local types consider adding `#[derive(serde::Serialize)]` to your `Person` type
= note: for types from other crates check whether the crate offers a `serde` feature flag
This tells us that we have forgotten to implement Serialize
for Person.
We can then take our action to properly fill in the missing dependencies.
Debugging Check Traits
Due to the need for check traits, the work for implementing a concrete context often involves wiring up the providers, and then checking that the providers and all their dependencies are implemented. As the number of components increase, the number of dependencies we need to check also increase accordingly.
When encountering errors in the check traits, it often helps to comment out a large
portion of the dependencies, to focus on resolving the errors arise from a specific
dependency. For example, in the check trait we can temporarily check for the
implementation of CanFormatToString
by commenting out all other constraints as follows:
pub trait CanUsePerson:
Sized
// + Serialize
// + for<'a> Deserialize<'a>
+ CanFormatToString
// + CanParseFromString
{}
We add a dummy constaint like Sized
in the beginning of the super traits for
CanUsePerson
, so that we can easily comment out individual lines and not worry
about whether it would lead to a dangling +
sign.
We can then pin point the error to a specific provider, and then continue
tracing the missing dependencies from there. We would then notice that
FormatAsJsonString
requires Serialize
, which we can then update the
commented code to:
pub trait CanUsePerson:
Sized
+ Serialize
// + for<'a> Deserialize<'a>
// + CanFormatToString
// + CanParseFromString
{}
This technique can hopefully help speed up the debugging process, and determine which dependency is missing.
Future Improvements
The need of manual debugging using check traits is probably one of the major blockers for spreading CGP for wider adoption. Although it is not technically an unsolvable problem, it is a matter of allocating sufficient time and resource to improve the error messages from Rust.
When the opportunity arise, we plan to eventually work on submitting pull requests for improving the error messages when the constraints from blanket implementations cannot be satisfied. This book will be updated once we get an experimental version of the Rust compiler working with improved error messages.
We also consider exploring the option of building a custom compiler plugin similar to Clippy, which can be used to explain CGP-related errors in more direct ways. Similarly, it should not be too challenging to build IDE extensions similar to Rust Analyzer, which can provide more help in fixing CGP-related errors.
Until improved tooling becomes available, we hope that the use of check traits for debugging is at least sufficient for early adopters. From this chapter onward, we are just starting to explore what can be done with the basic framework of CGP in place.