Rust has a rich and expressive type system that is fundamental to its guarantees of safety and performance. While everyday programming involves common types like structs, enums, and primitives, Rust offers several advanced type system features for solving more complex problems.
This article delves into some of these advanced types, including the newtype pattern, type aliases, the never type, dynamically sized types (DSTs), and function pointers.
1. The Newtype Pattern for Type Safety
The newtype pattern involves creating a new type by wrapping an existing type in a tuple struct. While it seems simple, it’s a powerful technique for creating distinct types that cannot be accidentally interchanged, even if they have the same underlying representation. This leverages Rust’s type system to enforce domain-specific invariants.
For example, you might have several i32 values representing different things, like UserID and ProductID. Using a raw i32 for both could lead to bugs where a product ID is used as a user ID.
// Using the newtype pattern to create distinct types.
struct UserID(i32);
struct ProductID(i32);
fn get_user_by_id(id: UserID) {
println!("Fetching user with ID: {}", id.0);
}
fn main() {
let user_id = UserID(100);
let product_id = ProductID(100);
get_user_by_id(user_id);
// This will not compile, preventing a logical error.
// get_user_by_id(product_id);
}
The newtype pattern has no runtime cost; the wrapper is compiled away, but the compile-time type safety remains.
2. Type Aliases with the type Keyword
A type alias is a synonym for another type. You can create one using the type keyword. Unlike the newtype pattern, a type alias does not create a new, distinct type. It simply provides a different name for an existing type.
This is most useful for reducing verbosity in complex type signatures.
// A very verbose type.
type Thunk = Box<dyn Fn() + Send + 'static>;
fn takes_long_type(f: Thunk) {
// ...
}
fn returns_long_type() -> Thunk {
Box::new(|| println!("hi"))
}
// Another example for a simpler type.
type Kilometers = i32;
fn main() {
let x: i32 = 5;
let y: Kilometers = 5;
// This is valid because Kilometers is just another name for i32.
println!("x + y = {}", x + y);
}
3. The Never Type (!)
The never type, written as !, is a special type that represents a computation that never returns a value. It is an “empty” type, meaning it can hold no values. Functions that never return are said to “diverge.”
Examples of diverging functions include:
panic!()- An endless
loop continueandbreakexpressions
The never type is useful because it can be coerced into any other type. This allows expressions like match arms to compile even when they don’t produce a value of the expected type.
fn guess(n: u32) -> u32 {
match n {
1..=100 => n,
// The panic! macro has the type `!`.
// Rust knows this arm will never return, so it can be coerced
// into the `u32` that the function expects.
_ => panic!("Number must be between 1 and 100"),
}
}
4. Dynamically Sized Types (DSTs) and the Sized Trait
Most types in Rust have a size that is known at compile time. However, some types, called dynamically sized types (or DSTs), do not. The most common example is str (a string slice). We can’t know the size of a str until runtime, because it could be any length.
Because their size is unknown, DSTs have some restrictions:
- You cannot have a variable or function argument of a DST type directly.
- You must always work with DSTs through a pointer, such as
&str,Box<str>, orRc<str>. These pointers store both the address of the data and its runtime size.
The Sized trait is automatically implemented for any type whose size is known at compile time. By default, all generic functions have a Sized bound on their type parameters.
// This function implicitly requires T to be Sized.
// fn generic<T>(t: T) { ... }
// It is equivalent to:
// fn generic<T: Sized>(t: T) { ... }
If you want to write a function that can accept a DST, you must relax this default bound using the ?Sized syntax.
// This function can now accept a reference to a Sized or !Sized type.
fn generic<T: ?Sized>(t: &T) {
// ...
}
fn main() {
let s: &str = "hello"; // &str is a pointer to a DST.
generic(s);
}
5. Function Pointers (fn)
In Rust, functions have their own type. The type fn (lowercase) is called a function pointer. It allows you to pass functions to other functions.
fn add_one(x: i32) -> i32 {
x + 1
}
// `f` is a function pointer that takes an i32 and returns an i32.
fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
f(arg) + f(arg)
}
fn main() {
let answer = do_twice(add_one, 5);
println!("The answer is: {}", answer); // Prints 12
}
Function pointers implement all three of the closure traits (Fn, FnMut, and FnOnce), so you can always pass a function pointer as an argument to a function that expects a closure. The main difference is that fn cannot capture variables from its environment, whereas closures can.