A new programming paradigm for Rust

Modular component system

Expressive ways to write code

Type-Safe composition

No-Std friendly

Zero-Cost abstraction

Explore More ⇩
Overview

Introduction

Context-generic programming (CGP) is a new programming paradigm for Rust that allows strongly-typed components to be implemented and composed in a modular, generic, and type-safe way.

Modular Component System

CGP makes use of Rust's trait system to define generic component interfaces that decouple code that consumes an interface from code that implements an interface. This is done by having provider traits that are used for implementing a component interface, in addition to consumer traits which are used for consuming a component interface.

The separation of provider traits from consumer traits allows multiple context-generic provider implementations to be defined, bypassing Rust's trait system's original restriction that forbids overlapping implementations.

Expressive Ways to Write Code

With CGP, one can easily write abstract programs that is generic over a context, together with all its associated types and methods. CGP allows such generic code to be written without needing to explicitly specify a long list generic parameters in the type signatures.

CGP also provides powerful macros for defining component interfaces, as well as providing simple ways to wire up component implementations to be used with a concrete context.

CGP allows Rust code to be written with the same level of expressiveness, if not more, as other popular programming paradigms, including object-oriented programming and dynamic-typed programming.

Type-Safe Composition

CGP makes use of Rust's strong type system to help ensure that all wiring of components is type-safe, catching any missing dependencies as compile-time errors. CGP works fully within safe Rust, and does not make use of any dynamic-typing techniques, e.g. dyn traits, Any, or reflection. As a result, developers can ensure that no CGP-specific errors can happen during application runtime.

No-Std Friendly

CGP makes it possible to build fully-abstract programs that can be defined with zero concrete dependencies (aside from other abstract CGP components). What this means is that dependencies including I/O, runtime, cryptographic operations, encoding schemes, can all be abstracted away from the core logic of the application.

This allows the application core logic to be instantiated with specialized dependencies in no-std environments, such as on embedded systems, operating system kernels, sandboxed environments like Wasm, and symbolic execution environments like Kani.

Zero-Cost Abstraction

Since all CGP features work only at compile-time, it provides the same zero-cost abstraction advantage as Rust. Applications do not have to sacrifice any runtime overhead for using CGP in the code base.

Current Status

As of end of 2024, CGP is still in early-stage development, with many rough edges in terms of documentation, tooling, debugging techniques, community support, and ecosystem.

As a result, you are advised to proceed at your own risk on using CGP in any serious project. Note that the current risk of CGP is not technical, but rather the limited support you may get when encoutering any challenge or difficulty in learning or using CGP.

Currently, the target audience for CGP are primarily early adopters and contributors, preferrably with strong background in functional programming and type-level programming.

Hello World Example

We will demonstrate various concepts of CGP with a simple hello world example.

Greeter Component

First, we would import cgp and define a greeter component as follows:

use cgp::prelude::*;

#[derive_component(GreeterComponent, Greeter<Context>)]
pub trait CanGreet {
    fn greet(&self);
}

The cgp crate provides all common constructs inside the prelude module, which should be imported in many cases. The first CGP construct we use is the derive_component macro, which generates additional constructs for the greeter component. The macro target, CanGreet, is a consumer trait that is used similar to regular Rust traits, but is not for implementation.

The first macro argument, GreeterComponent, is used as the name of the greeter component we defined. The second argument is used to define a provider trait called Greeter, which is used for implementing the greet component. Greeter has similar structure as the CanGreet, but with the implicit Self type replaced with the generic type Context.

Hello Greeter

With the greeter component defined, we would implement a hello greeter provider as follows:

pub struct GreetHello;

impl<Context> Greeter<Context> for GreetHello
where
    Context: HasField<symbol!("name"), Field: Display>,
{
    fn greet(context: &Context) {
        println!(
            "Hello, {}!",
            context.get_field(PhantomData::<symbol!("name")>)
        );
    }
}

The provider GreetHello is defined as a struct, and implements the provider trait Greeter. It is implemented as a context-generic provider that can work with any Context type, but with additional constraints (or dependencies) imposed on the context.

In this example case, the constraint HasField<symbol!("name"), Field: Display> means that GreetHello expects Context to be a struct with a field named name, with the field type being any type that implements Display.

The trait HasField is a CGP getter trait for accessing fields in a struct. The symbol! macro is used to convert any string literal into types, so that they can be used as type argument. The associated type Field is implemented as the type of the field in the struct.

The HasField trait provides a get_field method, which can be used to access a field value. The type PhantomData::<symbol!("name")> is passed to get_field to help infer which field we want to read, in case if there are more than one field in scope.

Notice that with the separation of provider trait from consumer trait, multiple providers like GreetHello can all have generic implementation over any Context, without causing any issue of overlapping implementation that is usually imposed by Rust's trait system.

Additionally, the provider GreetHello can require additional constraints from Context, without those constraints bein present in the trait bound of CanGreet. This concept is sometimes known as dependency injection, as extra dependencies are "injected" into the provider through the context.

Compared to other languages, CGP can not only inject methods into a provider, but also types, as we seen with the Field associated type in HasField.

Person Context

Next, we will define a concrete context Person, and wire it up to use GreetHello to implement CanGreet:

#[derive(HasField)]
pub struct Person {
    pub name: String,
}

pub struct PersonComponents;

impl HasComponents for Person {
    type Components = PersonComponents;
}

delegate_components! {
    PersonComponents {
        GreeterComponent: GreetHello,
    }
}

The Person context is defined to be a struct containing a name field, which is of type String. The CGP macro derive(HasField) is used to automatically implement Person: HasField<symbol!("name"), Field = String>, so that it can be used by GreetHello.

Additionally, we also define an empty struct PersonComponents, which is used to wire up all the providers for Person. We implement the CGP trait HasComponents for Person, which sets PersonComponents as its aggregated provider.

We use the CGP macro delegate_components to wire up the delegation of providers for PersonComponent. The macro allows multiple components to be listed in the body, in the form of ComponentName: Provider. In this example, we only have one entry, which is to use GreetHello as the provider for GreeterComponent. Notice that this is where we use the component name GreeterComponent, which was defined earlier by derive_component.

With the expressive mapping of components to provider inside delegate_components!, we can easily switch the implementation of Greeter to another provider by making just one line of code change.

Note that CGP allows component wiring to be done lazily. This means that any error such as unsatisfied dependencies will only be resolved when we try to use the provider.

Calling Greet

Now that the wiring has been done, we can try to construct a Person and then call greet on it:

let person = Person {
    name: "Alice".into(),
};

// prints "Hello, Alice!"
person.greet();

If we try to build and run the above code, we will see that the code compiles successfully, and the line "Hello, Alice!" is greeted on the terminal.

The method greet is called from the consumer trait CanGreet, which is implemented by Person via PersonComponents, which implements Greeter via delegation of GreeterComponent to GreetHello, which implements Greeter given that Person implements HasField<symbol!("name"), Field: Display>. That is a lot of indirection going on!

Hopefully by the end of this tutorial, you have gotten a sense of how it is like to program in CGP. There are a lot more to cover on how such wiring is done behind the scene by CGP, and what else we can do with CGP. You can continue and find out more by reading the book Context-Generic Programming Patterns.

Problems Solved

Here are some example common problems in Rust that CGP helps to solve.

Error Handling

Instead of choosing a specific error crate like anyhow or eyre, the CGP traits HasErrorType and CanRaiseError can be used to decouple the application core logic from error handling. Concrete applications can freely choose specific error library, as well as suitable strategies such as whether to include stack traces inside the error.

Async Runtime

Instead of choosing a specific runtime crate like tokio or async-std, CGP allows application core logic to depend on an abstract runtime context that provide features that only the application requires.

Compared to monolithic runtime traits, an abstract runtime context in CGP does not require comprehensive or up front design of all possible runtime features application may need. As a result, CGP makes it easy to switch between concrete runtime implementations, depending on which runtime feature the application actually uses.

Overlapping Implementations

A common frustration among Rust programmers is the restrictions on potentially overlapping implementations of traits. A common workaround for the limitation is to use newtype wrappers. However, the wrapping can become complicated, when there are multiple composite types that need to be extended.

As Rust requires a crate to own either the type or the trait for a trait implementation, this often places significant burden on the author that defines a new type to implement all possible common traits their users may need. This often leads to type definitions accompanied by overly bloated implementations of traits such as Eq, Clone, TryFrom, Hash, and Serialize. But even with great care, the library could still get requests from users to implement one of the less common traits that only the owner of the type can implement.

With the introduction of provider traits, CGP removes the restrictions on overlapping implementations altogether. Both owner and non-owners of a type can define a custom implementation for the type. When multiple provider implementations are available, users can choose one of them, and easily wire up the provider using CGP constructs.

CGP also prefer the use of abstract types over newtype wrappers. For example, a type like f64 can be used directly to for both Context::Distance and Context::Weight, with the associated types still treated as different types inside the abstract code. CGP also makes it possible for specialized providers to be implemented, even if the crate do not own the primitive type f64 or the provider trait.

Dynamic Dispatch

A common attempt for newcomers to support polymorphism in Rust code is to use dynamic dispatch in the for of dyn Trait objects. However, this severely limits what can be done in the code to a limited subset of object-safe features in Rust. Very often, this limitation can be infectious to the entire code base, and require non-trivial workaround on non-object-safe constructs such as Clone.

Even when dynamic dispatch is not used, many Rust programmers also resort to ad-hoc polymorphism, by defining enums to represent all possible variants of types that may be used in the application. This leads to many match expressions scattered across the code base, making it challenging to decouple the code for each branch. Furthermore, this approach makes it very difficult to add new variants to the enum, as all branches have to be updated, even when the variant is only used in a small part of the code.

CGP provides multiple ways to solve the dynamic dispatch problem, by leaving the "assembly" of the collection of variants to the concrete context. Meanwhile, the core application logic can be written to be generic over the context, together with the assocaited type that represents the abstract enum. CGP also enables powerful data-generic pattern that allows providers of each variant to be implemented separately, and then be combined to work with enums that contain any combination of the variants.

Monolithic Traits

Even without CGP, Rust' trait system already provides powerful ways for programmers to build abstractions that would otherwise not be possible in other mainstream languages. One of the best practices is similar to CGP, which is to write abstract code that is generic over a context type, except that there is an implicit trait bound that is always tied to the generic context.

Unlike CGP, the trait in this pattern is often designed as a monolithic trait that contains all dependencies that the core application may need. This is because without CGP, an abstract caller would have to also include all the trait bounds that are specified by the generic functions it calls. This means that any extra generic trait bound would easily propagate to the entire code base. And when that happens, developers would just combine all trait bounds into one monolithic trait for convenience sake.

Monolithic traits can easily become the bottleneck that prevents large projects from scaling further. It is not uncommon for monolithic traits to be bloated with dozens, if not hundreds, of methods and types. When that happens, it becomes increasingly difficult to introduce new implementations to such monolithic trait. With the current practices in Rust, it is also challenging to decouple or break down such monolithic trait to multiple smaller traits.

CGP offers significant improvement over this original design pattern, and makes it possible to write abstract Rust code without the risk of introducing a giant monolithic trait. CGP makes it possible to to break monolithic traits down to many small traits, which in fact, could and should be as small as one method or type per trait. This is made possible thanks to the dependency injection pattern used by CGP, which allows implementations only introduce the minimal trait bounds they need directly within the body of the implementation.

Getting Started

The best way to get started is to start reading the book Context-Generic Programming Patterns. You can also learn about how CGP works by looking at real world projects such as Hermes SDK.

Also check out the Resources page to find out more resources for learning CGP.

Contribution

We are looking for any contributor who can help promote CGP to the wider Rust ecosystem. The core concepts and paradigms are stable enough for use in production, but we need contribution on improving documentation and tooling.

You can also help promote CGP by writing tutorials, give feedback, ask questions, and share about CGP on social media.

Acknowledgement

CGP is invented by Soares Chen, with learnings and inspirations taken from many related programming languages and paradigms, particularly Haskell.

The development of CGP would not have been possible without strong support from my employer, Informal Systems. In particular, CGP was first introduced and evolved from the Hermes SDK project, which uses CGP to build a highly modular relayer for inter-blockchain communication. (p.s. we are also hiring Rust engineers to work on Hermes SDK and CGP!)