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.