19.3 - Advanced Types
Using the Newtype Pattern for Type Safety and Abstraction
In the previous section, we discussed using the newtype pattern to wrap an existing type in a tuple.
The newtype pattern is useful in a few other scenarios too. If we create a Millisecond
type:
struct Millisecond(u32);
fn sleep(duration: Millisecond) {
// --snip--
}
This makes it very clear that sleep
expects a value in milliseconds (although in this particular example you'd be better off using std::time::Duration
. The newtype pattern can also be used to wrap a type and give it a different public API.
Creating Type Synonyms with Type Aliases
We can give an existing type a type alias:
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
This creates a new type called Kilometers
which is an alias for i32. You can now use these types interchangeably - if a function expects a Kilometers
you can pass in an i32 instead or vice versa.
The main use case for type aliases is to reduce the length of long type names. We can take code like this:
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// --snip--
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// --snip--
}
and turn it into:
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi"));
fn takes_long_type(f: Thunk) {
// --snip--
}
fn returns_long_type() -> Thunk {
// --snip--
}
A meaningful name for your alias can make your code much easier to read and write. Another example of this is in std::io
. Many functions here return a Result
with a std::io::Error
as the error type, so std:io
defines:
type Result<T> = std::result::Result<T, std::io::Error>;
which makes a lot of function signatures in this module much shorter and easier to read.
The Never Type that Never Returns
There's a special type named !
. This is the empty type or never type:
fn bar() -> ! {
// --snip--
}
Here this tells the compiler that the bar
function will never return. Way back in chapter 2 we wrote this code:
loop {
// --snip--
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
// --snip--
}
The arms of a match
are supposed to all be of the same type in order for it to compile. You can't have one arm evaluate to a u32
and another evaluate to a String
. Here though, we know that the Err(_)
arm isn't going to return anything - if we get here, we'll abort this run through the loop and continue. From a type perspective, the return value of continue
is !
, so here Rust knows it's safe to ignore this arm (or to put it another way, the !
type can be coerced to any other type).
The panic!
macro is another example of something that evaluates to the !
type:
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
A loop
without a break
is also of type !
.
Dynamically Sized Types and the Sized Trait
When we create a variable on the stack, Rust needs to know how much space to allocate for that variable at compile time. For example:
fn add(a: i32, b: i32) {
println!("{}", a + b);
}
When someone calls add
, Rust will need to allocate four bytes on the stack to hold a
, and another four to hold b
.
Consider a string, which holds a variable amount of data:
fn say_hello(name: &str) {
println!("Hello {name}");
}
Here name
isn't actually a str
but a &str
or a string slice. The actual data from the string is stored "somewhere else" in memory, but the name
variable itself is 16 bytes long on a 64-bit platform (two usize
s). This is because &str
is implemented as a pointer to the string data and a length value.
As a rule, to pass around dynamically sized types like a string, we need a pointer. This can be a Box
or an Rc
or a &
, but some kind of pointer. Another example of a dynamically sized type is a trait object, which is why when we pass one it's usually in a Box<dyn Trait>
. The size of the trait object itself is unknown, so we pass around a smart pointer to the trait object instead, allowing us to store the trait object on the heap.
Any type whose size is known at compile time automatically implements the Sized
trait. Generic functions implicitly get a trait bounds for Sized
:
// You write this:
fn my_generic_fn<T>(t: T) {
// --snip--
}
// But Rust implicitly turns this into:
fn my_generic_fn<T: Sized>(t: T) {
// --snip--
}
You can prevent this with this syntax:
// T doesn't have to be `Sized`.
fn generic<T: ?Sized>(t: &T) {
// --snip--
}
Note that in order to do this, we can't leave the t
parameter of type T
. We again need some kind of pointer, in this case we chose &T
.