Generics

Generics are an essential feature of Rust, allowing you to write reusable code that works with multiple types without sacrificing performance. They let you write code that works with a variety of types without duplicating code.

What are Generics?

Generics are a way to write code that accepts one or more type parameters, which can then be used within the code as actual types. This allows the code to work with different types, while still being type-safe and efficient.

In Rust, generics are similar to templates in C++ or generics in Java, TypeScript, or other languages.

Using Generics

To illustrate how generics work in Rust, let's start with a simple example. Suppose you have a function that takes two arguments and returns the larger of the two. Without generics, you'd need to write a separate function for each type you want to support, e.g., one for integers and one for floating-point numbers.

However, using generics, you can write a single function that works with any type that implements the PartialOrd trait. Here's an example:

fn max<T: PartialOrd>(x: T, y: T) -> T {
    if x > y {
        x
    } else {
        y
    }
}

fn main() {
    let a = 5;
    let b = 10;
    let c = 3.14;
    let d = 6.28;

    println!("Larger of {} and {}: {}", a, b, max(a, b));
    println!("Larger of {} and {}: {}", c, d, max(c, d));
}

In the max function definition, we introduce a generic type parameter T using angle brackets (<>). We also specify the trait bound PartialOrd for T using the colon syntax (:). This constraint ensures that the max function only works with types that implement the PartialOrd trait, which is necessary for comparing values using the > operator.

Tip: It can be hard to know what trait bound you need, especially when new to Rust. One of the things I like to do is leave it out entirely, then let the compiler tell me which trait bound it thinks is missing. This works a surprising amount of the time, especially for simple cases.

Now, the max function works with both integers and floating-point numbers. As an added bonus, you can call it with any two values of the same type that implement the PartialOrd trait. So it will even work for strings, or types that you don't even know about! Pretty neat and pretty powerful.

Generic Structs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer_point = Point { x: 5, y: 10 };
    let float_point = Point { x: 3.14, y: 6.28 };

    println!("Integer point: ({}, {})", integer_point.x, integer_point.y);
    println!("Floating point: ({}, {})", float_point.x, float_point
}

In the Point struct definition, we introduce a generic type parameter T using angle brackets (<>). This allows us to use the same Point struct with different types for the x and y coordinates.

Note that in this example, both coordinates must have the same type. If you want to allow different types for x and y, you can introduce multiple generic type parameters:

struct Point2<X, Y> {
    x: X,
    y: Y,
}

fn main() {
    let mixed_point = Point2 { x: 5, y: 6.28 };

    println!("Mixed point: ({}, {})", mixed_point.x, mixed_point.y);
}

Here we have left the types unbounded, but you would likely want some trait bounds for these generic parameters. PartialOrd, PartialEq, and Debug are common choices.

Generic Enums and Traits

You can use generics with enums and traits in a similar way as with structs and functions. Here's an example of a generic Result enum that can be used to represent the success or failure of a computation (in fact, this is how the standard library type is defined):

enum Result<T, E> {
    Ok(T),
    Err(E),
}

fn divide(x: f64, y: f64) -> Result<f64, String> {
    if y == 0.0 {
        Result::Err("Cannot divide by zero.".to_string())
    } else {
        Result::Ok(x / y)
    }
}

fn main() {
    let result = divide(5.0, 2.0);
    match result {
        Result::Ok(value) => println!("Result: {}", value),
        Result::Err(error) => println!("Error: {}", error),
    }

    let result = divide(5.0, 0.0);
    match result {
        Result::Ok(value) => println!("Result: {}", value),
        Result::Err(error) => println!("Error: {}", error),
    }
}

In this example, we define a generic Result enum with two type parameters: T for the success value and E for the error value. The Result enum has two variants: Ok(T) for success and Err(E) for failure.

We then define a divide function that returns a Result<f64, String>. The function takes two f64 arguments and either returns the result of the division or an error message if the divisor is zero.

In the main function, we call the divide function and pattern match on the returned Result to handle both the success and error cases.

This Result enum is a simplified version of the Result type that is part of the Rust standard library, which is used extensively for error handling.

Exercise: Write a generic divide function. (Hint: look up the std::ops::Div trait.)