Generics
Generics serve as placeholders for specific data types, offering flexibility by allowing you to write code that can operate on a wide range of types without explicitly specifying each one. This enables the creation of reusable components that work with an undefined set of types that meet the component’s criteria. Generics are commonly used in function signatures, structs, enums, and other contexts to handle unspecified or “generic” data.
The compiler checks the source code for type references and uses something called monomorphization to create LLVM, assembly, and machine code binaries that name the concrete types for runtime. This results in no runtime penalty for using generics, because under the hood only concrete types are used. Using placeholders (generics) in source code helps reduce code duplication. For example, if we want to return a value, but depending on some internal logic the value could be of a concrete i32
or usize
type, we can define/use a generic type. We can also implement traits on our types to specify behaviors. Lastly, we can specify lifetimes which allows us to references that would otherwise not be available. Some of the most commonly encountered generics are the Vec<T>
, HashMap<K, V>
, Result<T, E>
, and Option<T>
types.
Defining Generics
Generics are defined in angled brackets <>
with a type identifier in UpperCamelCase. By convention, generic type identifiers are typically as short as one letter, as in the case of types such as Vec<T>
, HashMap<K, V>
, Result<T, E>
, and Option<T>
types. There can be only one concrete value type per declaration in a generic. For example if we define a generic type as type<T>
, it can only take one concrete type at a time. We could not substitute two different scalar types for T
, for example. This is cleanly illustrated in the Generics as structs section below. Its possible to declare any number of discrete types, but too many is likely an indicator that the code could be simplified.
Generic enums
One of the most common generic enums is the Option<T>
type. It is generic over some type T
and has two variants, Some(T)
and None
. The Some
variant is generic over type T
, which is to say that the Some
variant can hold whatever is passed to the Option<T>
type.
enum Option<T> { Some(T), None,}
Another super common generic enum is the Result<T, E>
type. This type is generic over types T
and E
where T
is the result data, and E
is an error.
enum Result<T, E> { Ok(T), Err(E),}
Generic structs
Generic struct types are declared similarly with the type in angled brackets after the struct name. Because we have no
struct Point<T> { x: T, y: T,}
fn generic_struct_function() { let integer = Point { x: 5, y: 10 }; let float = Point { x: 1.0, y: 4.0 }; let mixed = Point { x: 5, y 4.0 }; //Cannot mix concrete types!}
In the last line of the generic_struct_function()
example we see that it is not possible to mix concrete types for a given generic type. We could redefine the struct to take an integer and a float, but this still requires all elements to be of the same concrete types.
struct Point<T, U> { x: T, //All concrete x values must be of the same type y: U, //All concrete y values must be of the same type}
fn generic_struct_function() { let mixed_1 = Point { x: 5, y 4.0 }; let mixed_1 = Point { x: 3, y 10.0 };}
Generic method implementations
It is possible to define methods for generic structs. Lets revisit the first Point
example. Say we want to retrieve a value from a generic Point
type. We would start by declaring an impl
block that names the struct we’re associating with the angle bracket syntax.
//Defines generic Point<T> typestruct Point<T> { x: T, y: T,}
//Implements generic Point<T> type with a single method that returns the x value of the typeimpl<T> Point<T> { fn get_x(&self) -> &T { &self.x }}
//Creates a Point<T> instance with concrete values,//binds the instance to a variable p, and//then retrieves/prints the x value via the get_x() methodpub fn generic_struct_function() { let p = Point {x: 5, y: 10}; println!("p.x = {}", p.get_x());}
Its legal to use different type identifiers in the implementation declaration. For example, instead of using implt<T> Point<T>
we could just as easily write impl<P> Point<P>
, but convention (and readability) encourage the use of the same identifier when that is what is intended. This is because we can use the impl
block to impose specific limits on types we pass to our generic struct methods. For example, the following implementation block can be swapped out with the above code and work the same. This implementation dictates that only i32
type values are accepted for our get_x()
method. It is possible to write multiple implementation blocks for different types. This makes it possible to write methods for specific types for a given generic struct.
impl Point<i32> { fn get_x(&self) -> &i32 { &self.x }}
Additionally, the generic type identifiers used in method signatures dont correspond directly with the struct they’re implementing. As we already learned generics can be defined for types AND functions because functions can return types. In the following example the mixup<X2, Y2>()
method is its own generic type (like a variant) that takes two struct instances of the defined Point<X1, Y1>
type. The method signature defines two new generic types X2 and Y2 as placeholders. The body creates a new Point
instance using the first struct for the x
value and the second struct for the y
value.
//The struct has two distinct types X1 and Y1struct Point<X1, Y1> { x: X1, y: Y1,}//The implementation block mirrors the struct with X1 and Y1impl<X1, Y1> Point<X1, Y1> { //The method is a generic that defines two new generic types X2 and Y2 //The method takes self, which could fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> { Point { x: self.x, y: other.y, } }}pub fn calling_code() { let p1 = Point { x: 5, y: 10.4 }; let p2 = Point { x: "Hello", y: 'c' }; let p3 = p1.mixup(p2);...
This code is merely illustrative, as the method mixup<X2, Y2>()
could easily have been written without the extra generic type declarations.
fn mixup(self, other: BookPoint<X1, Y1>) -> BookPoint<X1, Y1> { BookPoint { x: self.x, y: other.y, } }
Common Generic Types
Option<T>
Rust is designed to be memory safe and includes protection against null pointer dereferencing. In fact, Rust does not use the concept of a null value in a traditional sense. In Rust there is no null value. In situations where there may be the possibility of a null value, we can write a function to take an Option<T>
type. This concept is expressed with the Option
type. Option
is actually an enum that is used to represent either the presence or absence of a value. The Option
type has two variants Some
and None
, all of which are included in the standard prelude.
let a: Option<i32> = Some(5i32); //Explicitly states the value and typelet b = Some(23u8); //Also explicitly states the type, but its less verboselet c = Some('P'); //Infers the Option typelet d: Option<String> = None; //Cannot infer Option type, must be explicitprintln!("{} {} {}", a.unwrap(), b.unwrap(), c.unwrap());if let Some(rando_char) = c { //Uses if let syntax for searching for one variant println!("The first letter of my name is {rando_char}");};if let None = d { //Uses a more succinct format for if let syntax println!("No value was found");};
5 23 PThe first letter of my name is PNo value was found
Because Option
is itself a type, the values an instance holds must be converted to its base type before those values can be used. This paradigm provides confidence in the code because any type T
will always hold a value in Rust. If there is a scenario that the absence of a value may be present, we can ensure that case is accounted for. For example the following code will not compile.
let x: i32 = 12;let y: Option<i32> = Some(23);let sum = x + y;
error[E0277]: cannot add `Option<i32>` to `i32` --> src/bin/enums.rs:68:17 |68 | let sum = x + y; | ^ no implementation for `i32 + Option<i32>` | = help: the trait `Add<Option<i32>>` is not implemented for `i32` = help: the following other types implement trait `Add<Rhs>`: <i32 as Add> <i32 as Add<&i32>> <&'a i32 as Add<i32>> <&i32 as Add<&i32>>
However, if we unwrap the value to convert it to its base type it works just fine.
let x: i32 = 12;let y: Option<i32> = Some(23);let z: i32 = y.unwrap();let sum: i32 = x + z;