The type system has various patterns and advanced constructs to be aware of.
Casting can be done either implicitly by ascribing, or explicitly via the as
keyword:
fn main() {
let int = 13;
let u: u8 = int;
let u = int as u8;
}
The as
keyword allows more coersion than ascribing:
fn main() {
let float = 13.5;
// let u: u8 = float; // nope 🙀
let u = float as u8; // ok
}
Type placeholder _
can be used in places where a type can be inferred:
fn main() {
let v: Vec<_> = (1..10).collect(); // Vec<_> is Vec<i32>
}
The as
syntax can also be used with the type placeholder _
, commonly used
with pointers:
fn main() {
let n = 1337;
let r = &n;
let r = r as *const _;
}
Type aliases can be specified using the type
keyword and used in-place of the
original type:
type ID = i32;
fn main() {
let i: i32 = 1337;
let id: ID = i;
}
They are mainly useful for creating an alias for more complex types to reduce duplication and simplify code:
type Thunk = Box<dyn Fn(i32) -> i32>;
fn wrap_add(f: Thunk, a: i32) -> Thunk {
Box::new(move |x| f(a) + a)
}
The newtype pattern (name from Haskell) is wrapping a single type in a struct. It is mainly useful for:
- implementing external traits on external types
- enforcing type safety on more abstract types
- hiding implementation details
There's no runtime penalty, the wrapper is removed at compile-time.
Avoiding the orphan rule by wrapping Vec<String>
and implementing Display
:
struct Wrapper(Vec<String>);
impl Display for Wrapper {
fn fmt(&self, f: &mut Formatter) -> Result {
write!(f, "Vec[{}]", self.0.join(", "))
}
}
Giving types more explicit names and implementing additional functionality on them has the additional benefit of type safety:
use std::ops::Add;
struct Meters(u32);
struct Millimeters(u32);
impl Meters {
fn to_millimeters(&self) -> Millimeters {
Millimeters(self.0 * 1000)
}
}
impl Add<Meters> for Millimeters {
type Output = Millimeters;
fn add(self, rhs: Meters) -> Millimeters {
Millimeters(self.0 + (rhs.0 * 1000))
}
}
Implementing units as u32
would make keeping track of what is what extremely
difficult.
Wrapping more abstract types can also help hide implementation details:
struct Person;
struct People(HashMap<u32, Person>);
impl People {
fn new() -> People {
People(HashMap::new())
}
fn add(&mut self, id: u32, person: Person) {
self.0.insert(id, person);
}
}
Consumers of this code do not need to know that People
is implemented as
a HashMap
, which allows restricting the public API and makes refactoring a lot
easier.
The From
and Into
traits are used for conversion of types from one into
another.
It is enough to implement the From
trait, as the Into
trait's implementation
uses From
trait bound in a blanket implementation:
struct Number {
value: i32,
}
impl From<i32> for Number {
fn from(value: i32) -> Self {
Number { value }
}
}
fn main() {
let a = 420;
let n = Number::from(a); // using From directly
let n: Number = a.into(); // blanket Into implementation
}
For conversions that can fail, TryFrom
and TryInto
traits exist that return
Result<T, E>
.
The !
stands for the never type for functions that never return, meaning
they either loop forever or exit the program:
fn loophole() -> ! {
loop {
println!("RIP ☠️");
}
}
fn int_or_bye(o: Option<i32>) -> i32 {
// Formally, the ! type can be coerced into any other type
match o {
Some(x) => x,
None => loophole(),
}
}
One of the main functions that exit the program is the panic!
macro:
fn int_or_bye(o: Option<i32>) -> i32 {
match o {
Some(x) => x,
None => panic!("bye 👋"),
}
}
Dynamically sized types are types whose size is not known at compile-time. The two major DSTs exposed by the language are:
- trait objects
dyn Trait
- references like
[T]
andstr
DSTs must exist behind a fat pointer, which contains information that complete the pointer with necessary information, like the vtable of a trait object, or a slice's size.
By default, generic parameters implicitly have the Sized
trait bound which
only allows statically sized types:
// fn sized<T>(a: T) {
// turns into:
fn sized<T: Sized>(a: T) {
// ...
}
Allowing DSTs is possible with specifying the ?Sized
trait bound, but only a
reference to this type can be passed:
fn sized<T: Display>(a: &T) {
println!("sized {}", a);
}
fn nonsized<T: ?Sized + Display>(a: &T) {
println!("nonsized {}", a);
}
fn main() {
nonsized("kek"); // ok
// sized("kek"); // nope!
}
A convention is to write the ?Sized
trait bound next to the generic type
definition even when using the where
syntax:
fn some_function<T: ?Sized, U>(t: &T, u: &U)
where
T: Display + Clone,
U: PartialOrd + Debug,
{
// ...
}