Skip to main content

Command Palette

Search for a command to run...

Generics

The Chapter 10

Published
•6 min read•View as Markdown
T
Dev cum DevRel trying to figure out things.

In Chapter 8: Option, we explored how to handle values that might be present or absent using the Option enum. Now, let's learn about Generics, which let us write code that works with many different data types without having to write separate code for each type.

The "Box of Holding" Analogy

Imagine you have a box. You can put all sorts of things in the box: toys, books, tools. Generics are like creating a "smart box" that can hold any type of item you want. You don't need a separate "toy box," a "book box," and a "tool box." One "smart box" works for everything!

Central Use Case: You want to write a function that finds the largest item in a list. You want this function to work for lists of numbers, lists of strings, or lists of anything that can be compared. Generics allow you to write one find_largest function that works for all these different types!

Key Concepts

Let's break down the key things to understand about generics:

  1. Type Parameters: Generics use type parameters, which are like placeholders for actual data types. We usually use the letter T as a type parameter, but you can use any valid name (like Item, Value, etc.).

  2. Generic Functions: Functions can be generic, meaning they can take arguments of generic types and/or return values of generic types.

  3. Generic Structs: Structs can also be generic. This means that the fields of the struct can be of generic types.

  4. <> Syntax: We use angle brackets <> to declare type parameters. For example, fn my_function<T>(arg: T) declares a generic function named my_function that takes one argument of type T.

Generics in Action: A Simple Function

Let's start with a simple example: a function that returns the same value it receives. Sounds pointless, but it's a good way to show generics in action!

fn identity<T>(value: T) -> T {
    value
}

Explanation:

  • fn identity<T>(value: T) -> T: This defines a generic function named identity.

    • <T>: This declares T as a type parameter. It means "this function works with some type T".

    • value: T: This means the function takes one argument named value, and its type is T (whatever type T is).

    • -> T: This means the function returns a value of type T.

  • value: The function simply returns the value that was passed in.

Now, let's use this function with different data types:

fn main() {
    let number = identity(5);
    let text = identity(String::from("hello"));

    println!("Number: {}", number);
    println!("Text: {}", text);
}

Output:

Number: 5
Text: hello

Explanation:

  • let number = identity(5);: We call identity with the integer 5. Rust automatically figures out that T is i32 in this case.

  • let text = identity(String::from("hello"));: We call identity with a String. Rust figures out that T is String in this case.

  • The same identity function works for both i32 and String! That's the power of generics.

Generics in Action: A Struct

Let's create a generic struct:

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

Explanation:

  • struct Point<T>: This defines a generic struct named Point.

    • <T>: This declares T as a type parameter.
  • x: T, y: T: This means that the x and y fields of the Point struct will both have the same type T.

Now, let's create instances of the Point struct with different types:

fn main() {
    let integer_point = Point { x: 10, y: 20 };
    let float_point = Point { x: 1.5, y: 2.5 };

    //You need to specify the type, or rust will throw an error since "" doesn't
    //have a concrete type
    let string_point: Point<&str> = Point { x: "hi", y: "world"};

    //This won't work as the point can't support multiple types at once
    //let string_point: Point<String, i32> = Point {x: "hi".to_string(), y: 1};

    println!("Integer Point x: {}", integer_point.x);
    println!("Float Point x: {}", float_point.x);
    println!("String Point y: {}", string_point.y);
}

Output:

Integer Point x: 10
Float Point x: 1.5
String Point y: world

Explanation:

  • let integer_point = Point { x: 10, y: 20 };: We create a Point where T is i32.

  • let float_point = Point { x: 1.5, y: 2.5 };: We create a Point where T is f64.

  • The same Point struct works for both integers and floating-point numbers!

Multiple Type Parameters

You can use multiple type parameters in a generic struct or function:

struct Rectangle<T, U> {
    width: T,
    height: U,
}

Explanation:

  • struct Rectangle<T, U>: This defines a generic struct named Rectangle with two type parameters: T and U.

  • width: T, height: U: The width field has type T, and the height field has type U. This means width and height can have different types.

Generics in Action: The find_largest Function

Let's revisit our central use case: writing a function that finds the largest item in a list. To do this, the type T must implement the PartialOrd trait, which allows for comparison.

fn find_largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

Explanation:

  • fn find_largest<T: PartialOrd>(list: &[T]) -> &T: This defines a generic function named find_largest.

    • T: PartialOrd: This is a trait bound. It means that the type T must implement the PartialOrd trait, which allows values of type T to be compared using operators like >.

    • list: &[T]: The function takes a slice of T values as input.

    • -> &T: The function returns a reference to the largest value in the list.

  • The function iterates through the list, comparing each item to the current largest value.

  • If item is greater than largest, we update largest to be item.

  • Finally, we return a reference to the largest value.

Now, let's use this function with different types of lists:

fn main() {
    let numbers = [1, 5, 2, 8, 3];
    let largest_number = find_largest(&numbers);
    println!("Largest number: {}", largest_number);

    let strings = ["apple", "banana", "cherry", "date"];
    let largest_string = find_largest(&strings);
    println!("Largest string: {}", largest_string);
}

Output:

Largest number: 8
Largest string: date

Explanation:

  • We call find_largest with a list of integers and a list of strings.

  • The same find_largest function works for both types because both i32 and &str implement the PartialOrd trait.

Internal Implementation (Simplified)

When you use generics, Rust performs a process called monomorphization at compile time. This means that for each concrete type you use with a generic function or struct, Rust creates a separate version of that function or struct specialized for that type.

Let's see a simplified sequence diagram of how this works:

Explanation:

  1. You write generic code in your source code.

  2. The Rust compiler sees that you're using find_largest with i32 and String.

  3. The compiler creates two separate versions of the function: find_largest_i32 and find_largest_String. These functions are no longer generic; they're specific to i32 and String, respectively.

  4. The compiler compiles these specialized functions into your executable.

This means that generics have no runtime cost. The code is just as efficient as if you had written separate functions for each type.

Conclusion

Generics are a powerful tool in Rust that allows you to write flexible, reusable, and efficient code that works with multiple data types. By using type parameters and trait bounds, you can create generic functions and structs that can be adapted to a wide range of situations.

In the next chapter, we'll explore Traits, which define shared behavior that different types can implement.

Beginner's Guide to Rust

Part 12 of 14

In this series, we'll dive deep into Rust programming

Up next

Traits

The Chapter 11