Skip to main content

Generic Types in Rust

Generics are a powerful concept allowing types to be "expected" or known without knowing what they are. Previously, you've encountered generics when dealing with the Option<T> enum, where T is a generic that can accept any type. They are placeholder types that optionally can use traits to define a set of expectations while still being abstract and generic.

Generics can be used in traits, methods, functions, enums, and structs.

Scenario: Why Generic Code is Useful

Let's assume that I wish to have a simple function that is meant to square two numbers, as we've previously done:

fn square(x: i32) -> i32 {
x * x
}

The above code is acceptable - however, there is a problem. What if other number types must be compared? After all, this same function could apply to f32, f64, u32, and so on.

Generics remove this redundancy by defining an abstract type, usually referred to as T, which allows this function to be compatible with many types.

Defining Generics: Functions

To convert the above function into a generic one, the following syntax must be applied:

fn square<T>(x: T) -> T {
x * x
}

The most glaring difference is the introduction of <T>. This syntax precedes the parameters list, defining what the generic is called and what properties it should entail regarding traits (more on that next!).

The type T in this context quite literally means any type, meaning the function can now be called as follows (note the use of :: to define the type):

let squared = square::<i32>(10); // 5

When we call this function, we replace the <T> with the type we wish to represent - in this case, i32. Taking a closer look at the function's signature, it essentially converts from T to i32 across the board:

fn square<i32>(square: i32) -> i32;

Now, this is possible for any number! With one caveat - not all types can utilize the * operator. For example, if you used a String, this function would panic, as it is impossible to compare Strings directly this way. The above code shouldn't work together, as the compiler is unsure what to expect since it can expect any type.

Adding Trait Bounds to Generics

Traits will be covered more in-depth in the next section. However, know that they can define certain behavior for generics to ensure the type is compatible with the function. This particular trait, Mul, is a trait that ensures that a type can be multiplied. By using the syntax T: Mul, we limit all possible types T could be to anything that implements Mul:

Output is an associated type, which will also be covered later.

fn square<T: Mul<Output = T> + Copy>(x: T) -> T {
x * x
}

This translates into our code being compatible with a whole host of various types that already implement this trait, Mul, by default.

// 32-Bit Signed Integer, note how you can also use ::<type> to define what type to expect.
let squared_i32 = square::<i32>(10);
// 32-Bit Unsigned Integer
let squared_u32: u32 = square(10);
// Floating Point Number
let squared_f32: f32 = square(10.0);

Defining Generics: Structs & Methods

As with functions, generics may be applied to structs, methods, and enums.

For structs, generics may be used to define abstract field types:

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

Multiple generics may be used, meaning the type of x must differ from the type of y. Generic labels usually follow this convention, but in theory, can be named anything:

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

Point can now be created with different types for x and y fields.

If we were to define some methods for Point, generics may also be used to further provide type dynamism:

impl<T, U> Point<T, U> {
fn x(&self) -> &T {
&self.x
}

fn y(&self) -> &U {
&self.y
}
}

Try it yourself!

What is happening here?

This example features two primary usages of Rust generics. The first illustrates the usage of generics within a function, which reduces the boilerplate for supporting multiple compatible types that want to utilize fn square. Generics may also be used as a part of a struct, as seen with Point. An associated type is also "hidden" in the first example, where the associated type Output is defined as part of the Mul trait when declaring the trait bound.