jeffa.io

Rust Guide: Generics Demystified Part 1



Rust Guide: Generics Demystified Part 1

Introduction

Rustaceans appreciate generics for three reasons:

  • Generics are compile-time abstractions. The dyn keyword, which generics can often replace, has runtime overhead.
  • Generics make code cleaner and more reusable. Thanks to trait bounds, code can describe what it can do when those bounds are met. Your code becomes exactly as flexible as you want it to be.
  • Generics will help you understand lifetimes. The Book explains lifetimes with imperative code (i.e. code in a function body). But generic lifetimes have declarative context. This gives them clear meaning when used in types, traits and implementations.

Let’s learn everything there is to know about generics. We’ll do this by learning incrementally so that each piece of the lesson is digested before we go back for more.

The Goal

Walking through a few simple examples can prepare anyone who has worked through The Book to use generics with confidence. Generics allow us to code with selective flexibility. Once the tools are understood, the concept of plugging in types where appropriate will come naturally.

Starting with a dead-simple example, we’ll do exactly what generic (or parametized) types are supposed to do by introducing type flexibility. Then we can gently layer on the complexity and learn why generics permeate into every aspect of the type system, why they need optional restrictions and how we can use this powerful tooling without turning our code into cryptic goop. By the end, we will have full control of Rust’s type system.

This guide will walk you through the process of writing a small library with plenty of code examples. Each part can be completed in less than thirty minutes. If you follow along, the code will compile after each section. Part one will cover the basics of types, functions and closures. Part two will dig into implementations and traits. Finally, part three will add const generics and parametized lifetimes. The resulting library will be simple but fun and easy to understand.

Part one mostly reviews information covered in The Book, but with some heplful observations. If you have questions, criticism or feedback, you can find contact information on the homepage.

The Basics

Type Definitions

Before getting into elaborate implementations that use multiple generics at every possible level, let’s focus on brass tacks.

struct Point {
    id: u64
}

This is familiar territory, u64 is a hard-coded (non-generic) type. It offers no flexibility but is simple and selective.

struct Point<Id> {
    id: Id
}

This Point can use any other type as the Id. The Id could even be Point or a vector of Points, which would look pretty absurd: Point<Vec<Point<()>>.

This flexibility can be excessive, which is how the where clause will help later on when we use an implementation. For now, we can keep the Point<Id> type private from the user while we expose public types with an Id that makes sense. We could use a trait bound in this type definition. But you will rarely see this done. The reasons for that will be covered in detail in part two when we write implementations and traits of our own.

pub type Point = inner::Point<u32>;
pub type BigPoint = inner::Point<u128>;

mod inner {
    struct Point<Id> {
        id: Id
    }
}

If the Id value is going to be randomly generated, larger types like u128 will allow for lower chances of collision when generating BigPoint instances in huge quantities. But for fewer Points, the u32 type will use just four bytes instead of the sixteen bytes used by each u128. This is a simplified example of how generics are useful when defining types.

We have now seen three levels of flexibility:

  • A hard-coded (non-generic) type.
  • A highly flexible open-ended generic type.
  • A generic type used privately with certain types plugged in for the public interface.

Functions and Closures

A function consists of a type definition (the function signature) and an execution context (the imperative code block). It is helpful to start with a simple function before moving on to implementations.

struct Line<P> {
    points: (P, P),
}

fn connect<P>(left: P, right: P) -> Line<P> {
    Line {
        points: (left, right)
    }
}

Here the type used in the points tuple is decided wherever connect is called, because that is where Line is invoked. If tight coupling is acceptable, we can specify that connect always deals with Lines and Points, while still allowing a generic type in the Point’s id field.

fn connect<Id>(left: Point<Id>, right: Point<Id>) -> Line<Point<Id>> {
    Line {
        points: (left, right)
    }
}

We still have a type parameter, but now the Point and Line types are tightly coupled in this function.

As with any function, the arguments could be written as a struct and the function body could be moved to a method. Instead of weighing in on the eternal struggle of functional vs object-oriented code aesthetics, we will simply observe that function signatures are type definitions. This is not a trivial observation. For now, it’s enough to simply point it out. Let’s move on to using a generic type for a closure.

/// This is the same Point we used to first demonstrate a generic type.
#[derive(Debug)]
struct Point<Id> {
    id: Id
}

/// This Line is different. We've restricted it to use the Point type but left
/// the Id flexible. Before, this tight coupling was done in the `connect`
// method.
#[derive(Debug)]
struct Line<Id> {
    points: (Point<Id>, Point<Id>),
}

/// This new type contains a closure. While Box is a type, this involves Fn, a
/// trait that uses generic types. We will write our own such trait later.
struct Connector<Id> {
    pub op: Box<dyn Fn(Point<Id>, Point<Id>) -> Line<Id>>,
}

fn main() {
    let connector: Connector<String> = Connector {
        op: Box::new(|left, right| Line {
            points: (left, right),
        }),
    };

    let point_a = Point {
        id: "A".to_string(),
    };
    let point_b = Point {
        id: "B".to_string(),
    };
    let line = (connector.op)(point_a, point_b);

    println!("{:?}", line);
}

Run this program in the playground.

Here we have built a tiny library that, with just a little more work, might do something interesting. If we iterate over pairs of Points we could allow the user to connect them while also recording, modifying or filtering them. Meanwhile, our library could handle how the iterating works. Or instead of iterating over pairs and returning Lines, we could select them in groups of whatever size the user wants and combine them into types that we provide. We could also let the user write their own Line, Square, Graph or whatever they want by having them plug it into a type parameter (i.e. generic type).

It is with this cliffhanger that we’ll stop with part one. The good stuff comes next.

Conclusion

It might seem that we have all we need with the tools we’ve demonstrated so far. It’s true that you can do a lot with these few simple tools. But Rust needs more syntax in order to feature the power of generics in all their glory. While part two will be more complex than this lesson, the language as a whole will become easier to understand.

If you have any questions or comments about this guide, contact information is on the homepage. I appreciate any and all feedback.

Go to part two.