Skip to content

Latest commit

 

History

History
258 lines (195 loc) · 5.16 KB

12_types.md

File metadata and controls

258 lines (195 loc) · 5.16 KB

Types

The type system has various patterns and advanced constructs to be aware of.

Casting

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 alias

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)
}

Newtype

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.

Conversion

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>.

Never

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 👋"),
    }
}

DSTs

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] and str

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,
{
    // ...
}