Generic Types, Traits, and Lifetimes
Every programming language has tools for effectively handling the duplication of concepts. In Oxide, one such tool is generics: abstract stand-ins for concrete types or other properties. We can express the behavior of generics or how they relate to other generics without knowing what will be in their place when compiling and running the code.
Functions can take parameters of some generic type, instead of a concrete type
like Int or String, in the same way they take parameters with unknown
values to run the same code on multiple concrete values. In fact, we already
used generics in Chapter 6 with Option<T> (written as T? in Oxide), in
Chapter 8 with Vec<T> and HashMap<K, V>, and in Chapter 9 with
Result<T, E>. In this chapter, you will explore how to define your own types,
functions, and methods with generics!
First, we will review how to extract a function to reduce code duplication. We will then use the same technique to make a generic function from two functions that differ only in the types of their parameters. We will also explain how to use generic types in struct and enum definitions.
Then, you will learn how to use traits to define behavior in a generic way. You can combine traits with generic types to constrain a generic type to accept only those types that have a particular behavior, as opposed to just any type.
Finally, we will discuss lifetimes: a variety of generics that give the compiler information about how references relate to each other. Lifetimes allow us to give the compiler enough information about borrowed values so that it can ensure that references will be valid in more situations than it could without our help.
Oxide Syntax vs. Rust
Since Oxide compiles to Rust, the generic syntax is largely the same:
| Feature | Rust | Oxide |
|---|---|---|
| Generic function | fn foo<T>() {} | fn foo<T>() {} |
| Generic struct | struct Point<T> {} | struct Point<T> {} |
| Generic enum | enum Option<T> {} | enum Option<T> {} |
| Trait bound | fn foo<T: Display>() {} | fn foo<T: Display>() {} |
| Where clause | where T: Clone | where T: Clone |
| Lifetime | fn foo<'a>(x: &'a T) {} | fn foo<'a>(x: &'a T) {} |
| Trait impl | impl Trait for Type {} | extension Type: Trait {} |
| Method modifiers | &self, &mut self | Implicit, with mutating |
The key difference in Oxide is the extension syntax for implementing
traits, which reads more naturally than Rust's impl Trait for Type.
Chapter Structure
We'll take the same approach that the Rust Book does:
- First, we'll see how to extract functions to reduce duplication
- Then we'll learn about generic data types for functions, structs, enums, and methods
- Next, we'll explore traits to define shared behavior
- We'll use trait bounds to constrain generics
- Finally, we'll tackle lifetimes and how they interact with generics
Let's dive in!
Removing Duplication by Extracting a Function
Generics allow us to replace specific types with a placeholder that represents multiple types to remove code duplication. Before diving into generics syntax, let's first look at how to remove duplication in a way that doesn't involve generic types by extracting a function that replaces specific values with a placeholder that represents multiple values. Then, we will apply the same technique to extract a generic function! By looking at how to recognize duplicated code you can extract into a function, you will start to recognize duplicated code that can use generics.
We will begin with a short program that finds the largest number in a list:
fn main() {
let numberList = vec![34, 50, 25, 100, 65]
var largest = &numberList[0]
for number in &numberList {
if number > largest {
largest = number
}
}
println!("The largest number is \(largest)")
}
We store a list of integers in the variable numberList and place a reference
to the first number in the list in a variable named largest. We then iterate
through all the numbers in the list, and if the current number is greater than
the number stored in largest, we replace the reference in that variable.
However, if the current number is less than or equal to the largest number seen
so far, the variable doesn't change, and the code moves on to the next number
in the list. After considering all the numbers in the list, largest should
refer to the largest number, which in this case is 100.
We have now been tasked with finding the largest number in two different lists of numbers. To do so, we can choose to duplicate the code and use the same logic at two different places in the program:
fn main() {
let numberList = vec![34, 50, 25, 100, 65]
var largest = &numberList[0]
for number in &numberList {
if number > largest {
largest = number
}
}
println!("The largest number is \(largest)")
let numberList = vec![102, 34, 6000, 89, 54, 2, 43, 8]
var largest = &numberList[0]
for number in &numberList {
if number > largest {
largest = number
}
}
println!("The largest number is \(largest)")
}
Although this code works, duplicating code is tedious and error-prone. We also have to remember to update the code in multiple places when we want to change it.
To eliminate this duplication, we will create an abstraction by defining a function that operates on any list of integers passed in as a parameter. This solution makes our code clearer and lets us express the concept of finding the largest number in a list abstractly.
We extract the code that finds the largest number into a function named
largest. Then, we call the function to find the largest number in the two
lists:
fn largest(list: &[Int]): &Int {
var largest = &list[0]
for item in list {
if item > largest {
largest = item
}
}
largest
}
fn main() {
let numberList = vec![34, 50, 25, 100, 65]
let result = largest(&numberList)
println!("The largest number is \(result)")
let numberList = vec![102, 34, 6000, 89, 54, 2, 43, 8]
let result = largest(&numberList)
println!("The largest number is \(result)")
}
The largest function has a parameter called list, which represents any
concrete slice of Int values we might pass into the function. As a result,
when we call the function, the code runs on the specific values that we pass
in.
In summary, here are the steps we took to change the code:
- Identify duplicate code.
- Extract the duplicate code into the body of the function, and specify the inputs and return values of that code in the function signature.
- Update the two instances of duplicated code to call the function instead.
Next, we will use these same steps with generics to reduce code duplication. In
the same way that the function body can operate on an abstract list instead
of specific values, generics allow code to operate on abstract types.
For example, say we had two functions: one that finds the largest item in a
slice of Int values and one that finds the largest item in a slice of Char
values. How would we eliminate that duplication? Let's find out!