19.5 Advanced Types
19.5.1 Using the Newtype Pattern for Type Safety and Abstraction
In 19.2. Advanced Traits, we already used the newtype pattern to implement Display for Vec (more specifically, in 19.2.6, using the newtype pattern to implement an external trait on an external type).
In 19.2. Advanced Traits (19.2.2, Default Generic Parameters and Operator Overloading), we also wrote Millimeters and Meters structs to store millimeter and meter values separately. Because the two values cannot be added or subtracted directly, this avoids mixing units by mistake.
We can also use the newtype pattern to abstract other characteristics:
- A new type can expose a public API different from the API of its private inner type
- A new type can hide its internal implementation (as mentioned in 17.1. Rust’s Object-Oriented Programming Features, Encapsulation)
19.5.2 Type Aliases
Rust provides the ability to declare type aliases so that an existing type can be given another name, which is somewhat similar to generics.
To use a type alias, use the type keyword. For example:
#![allow(unused)]
fn main() {
type Kilometers = i32;
}
We call Kilometers a synonym for i32. You can use Kilometers just like i32:
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
- Because
Kilometersandi32are the same type, we can add values of the two types together.
The main use case for type synonyms is reducing repetition. For example, we might have a long type like this:
#![allow(unused)]
fn main() {
Box<dyn Fn() + Send + 'static>
}
Writing such a long type over and over again in function signatures and type annotations throughout a codebase can be tedious and error-prone. For example:
#![allow(unused)]
fn main() {
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// ...
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// ...
}
}
Type aliases make this code easier to manage by reducing repetition, and a meaningful name communicates intent better. We can rewrite the code above like this:
#![allow(unused)]
fn main() {
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi"));
fn takes_long_type(f: Thunk) {
// ...
}
fn returns_long_type() -> Thunk {
// ...
}
}
Type aliases are also often used with Result<T, E> to reduce repetition. For example:
#![allow(unused)]
fn main() {
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
}
I/O operations usually return Result<T, E> to handle failures. std::io::Error represents all possible I/O errors. Many functions in std::io return Result<T, E>, where E is std::io::Error.
Result<..., Error> repeats many times, so std::io uses a type alias:
#![allow(unused)]
fn main() {
type Result<T> = std::result::Result<T, std::io::Error>;
}
The Write trait method signatures then look like this:
#![allow(unused)]
fn main() {
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
}
Type aliases have two effects here:
- They make the code easier to write and give us a consistent interface across
std::io. - Because it is only an alias, it is still essentially another
Result<T, E>, which means we can use any methods that apply toResult<T, E>as well as special syntax such as the?operator (discussed in 9.3. Result Enum and Recoverable Errors Pt. 2, the?operator).
19.5.3 The Never Type
Rust has a special type called !. In type theory, it is known as the empty type because it has no values. We prefer to call it the never type because it appears in the return type position of functions.
For example:
#![allow(unused)]
fn main() {
fn bar() -> ! {
}
}
This code is interpreted as: “function bar never returns.” A function that never returns is called a diverging function.
So what is the never type used for? Let’s use a snippet from the number-guessing game in 2.4. Number Guessing Game Pt.4 - Repeated Prompting with Loop:
#![allow(unused)]
fn main() {
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
}
This works. But what if we write this instead?
#![allow(unused)]
fn main() {
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
This code fails because the two branches of match return different types. Rust is a strongly typed language, so it must know the exact type of a value. guess could be i32 or &str, but Rust requires guess to have only one type.
In other words, under this form, all branches of match must return the same type.
Now look back at the correct code: the Ok branch returns u32, and what type does continue in the Err branch return? If it were the unit type (), which means no return value, Rust would not be able to tell whether guess is u32 or ().
This is where the never type comes in: continue has return type !. In other words, when Rust checks the type of guess, it looks at both match branches. The first branch returns u32, and the second branch returns !. Because ! can never produce a value, Rust knows that guess is u32.
The never type works the same way for the panic! macro. Look at the definition of unwrap:
#![allow(unused)]
fn main() {
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
}
Rust sees that val has type T, while panic! has type !, so the overall return value of the match expression is T. This works because panic! does not return a value; it ends the program.
In fact, loop is also !, because an endless loop never ends, so it can never produce a return value. However, if we include a break, then that is no longer the case, because the loop terminates when it reaches break.
19.5.4 Dynamically Sized Types and the Sized Trait
Rust needs to know certain details about its types, such as how much space to allocate for a value of a particular type. That makes the concept of dynamically sized types a little confusing. They are sometimes called DSTs or unsized types. These types let us write code that works with values whose size is known only at runtime.
We can use str (not &str and not String) as an example of a dynamically sized type:
#![allow(unused)]
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
We cannot know how long a string is before runtime, which means we cannot create variables of type str, so the code above cannot work.
Rust needs to know how much memory to allocate for any value of a specific type, and all values of the same type must use the same amount of memory. If Rust allowed us to write the code above, then the two str values would have to occupy the same amount of space. But they have different lengths: s1 needs 12 bytes of storage, while s2 needs 15 bytes. That is why we cannot create variables that store dynamically sized types.
So what should we do? In general, changing the types of s1 and s2 to &str instead of str solves the problem:
#![allow(unused)]
fn main() {
let s1: &str = "Hello there!";
let s2: &str = "How's it going?";
}
The slice data structure stores only the slice’s starting position and length. So although &T is a single value containing a memory address, &str is two values (as discussed in 4.5. Slice):
- The address of the
str(usize) - The length of the
str(usize)
Therefore, we can know the size of an &str value at compile time: it is twice the size of usize. In other words, we always know the size of &str, no matter how long the string it refers to is.
In general, the best way to use dynamically sized types in Rust is to give them extra metadata to store their dynamic size information. The golden rule for dynamically sized types is that we must always place them behind some kind of pointer.
We can combine str with various pointers, such as Box<str> or Rc<str>. Traits are also dynamically sized types in practice. To work with dynamically sized types, Rust provides the Sized trait to determine whether a type’s size is known at compile time. Everything whose size is known at compile time automatically implements this trait. In addition, Rust implicitly adds the Sized trait to every generic function.
That means a generic function like this:
#![allow(unused)]
fn main() {
fn generic<T>(t: T) {
// ...
}
}
is actually written as:
#![allow(unused)]
fn main() {
fn generic<T: Sized>(t: T) {
// ...
}
}
By default, generic functions only work with types whose size is known at compile time. But we can relax that restriction with the special ?Sized syntax:
#![allow(unused)]
fn main() {
fn generic<T: ?Sized>(t: &T) {
// ...
}
}
?Sizedmeans “Tmay or may not implement theSizedtrait,” which meansTmay or may not be a dynamically sized type. This notation removes the default requirement that generic types must have a known size at compile time. The?Traitsyntax only applies to theSizedtrait and no other trait.- We switch the type of parameter
tfrom genericTto&T. Because the type may not implementSized, meaning it may be a dynamically sized type, we need to wrap the dynamically sized type in a pointer.
The best place to use dynamically sized types is with traits. Sometimes we want some data to implement certain traits or a specific lifetime, but we do not know the concrete type, so we can use a pointer-wrapped dynamic type. For example:
#![allow(unused)]
fn main() {
type Job = Box<dyn FnOnce() + Send + 'static>;
}
This example uses both a type alias and a pointer-wrapped dynamic type. Job can be any type that implements FnOnce(), Send, and the 'static lifetime.