DEV Community

Cover image for 30 Days of Rust - Day 30
johnnylarner
johnnylarner

Posted on

30 Days of Rust - Day 30

This is it, the final blog. It's long and arduous, so take some time to read it. Don't worry, I'll still be posting blogs, but probably not on the features of the Rust language - I think we all need a break from that for now.

Yesterday's questions answered

No questions to answer

Today's open questions

*No open questions

Associated types vs generics

We've already looked at how generics are a way to improve reusability of functions or traits in Rust. Associated types offer a different approach to reusability. There may be instances where a certain trait need only be implemented once for a given type. This is when an associate type definition on a trait may be more useful. Consider this example from the Rust book:

use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}
Enter fullscreen mode Exit fullscreen mode

Note how in the implementation of Add we declare the type Output. What's we're actually doing is defining the associate type of the Add trait. Without this, our code won't compile. The associate type serves as a contract in this function. As per the trait definition, our associated type serves as the return type of our add function. Here's the trait declaration:

trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}
Enter fullscreen mode Exit fullscreen mode

What's interesting about the Add trait is that it also has default associate type. This is represented by the Rhs alias input argument.

Default types in this context provide flexibility to library authors: often most users of a library will use a trait in one way. This typical usage pattern can be expressed through the default type of a trait. By making this a parameter, however, you can give power users the chance to use the library in a way you may not have foreseen.

Fully qualified syntax for duplicated method and associated functions

Rust, like Python, provides no limitations on defining multiple methods of the same name on an object. That's probably because no programming language intended on its users to do that (except when overloading methods, of course).

In Rust, however, there is actually a relatively common situation where a struct may have multiple methods or associated functions of the same name. When implementing a trait from an external library, we may be forced to use a method name that we have already defined.

Syntax for method calls

Remember back to when we talked about multithreading, we briefly talked about the Send (and Sync) trait. Imagine you want code that implements not only Send from the standard library, but also from a third party library that instantiates threads in a different way. Suddenly you have two versions of the send method.

When calling these methods, we have to use fully qualified syntax. This consists of the trait and the method name:

let thread_struct = MyCustomThread::new();

StdLibrarySendTrait::send(&thread_struct);
ExternalLibrarySendTrait::send(&thread_struct);
Enter fullscreen mode Exit fullscreen mode

Syntax for associated functions

As a struct method contains a reference to the struct as its first argument, when we call the method using fully qualified syntax, Rust knows which implementation block is being called as it knows the type of self:


impl ExternalLibrarySendTrait for MyCustomThread {
    fn send(&self) {
        // Do something
    }
}

impl ExternalLibrarySendTrait for SomeOtherType {
    fn send(&self) {
        // Do something
    }
}

ExternalLibrarySendTrait::send(&thread_struct);
Enter fullscreen mode Exit fullscreen mode

This is different for associate functions which don't take self:

trait Animal {
    fn baby_name() -> String;
}

struct Dog;
struct Cat;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

impl Animal for Cat {
    fn baby_name() -> String {
        String::from("kitten")
    }
}


Dog::baby_name() // No problems
Animal::baby_name() // Cat or Dog?

Enter fullscreen mode Exit fullscreen mode

You can see here how the fully qualified syntax for methods isn't enough here (also, the code won't compile). That's actually because we didn't use fully fully qualified syntax. Before we could skip the type aliasing because it's implicit in the &self argument. Here is how we use fully qualified syntax which will get the above code to compile:

<Dog as Animal>::baby_name() // A puppy
<Cat as Animal>::baby_name() // A kitten
Enter fullscreen mode Exit fullscreen mode

We could use this syntax for methods, too, but it's overly verbose.

Supertraits: declaring trait dependencies

When defining a trait we can declare an optional supertrait which must be implemented if that trait is to work as expected. Using a struct that does not implement the supertrait will cause you code to not compile:

trait PrettyPrint: fmt::Display {
    // Do something
}
Enter fullscreen mode Exit fullscreen mode

This code declares a trait that can only be implemented on structs that implement Display.

Supertraits can help reduce code duplication at the cost of increasing code coupling and complexity.

Type wizardry

Until now we've barely touched on the type keyword. This keyword is used to declare type aliases:

type Kilometer = i32;
Enter fullscreen mode Exit fullscreen mode

What's important to understand is that Kilometer is still an i32:

let a: Kilometer = 1;
let b = 1;

asserteq!(a + b, 2);
Enter fullscreen mode Exit fullscreen mode

Often we see the newtype pattern in Rust. This is popular because of Rust's trait implementation rule:

A trait can only be implemented for a type if the trait or the type is local to your code.

We can implement the above code using the new type pattern:

struct Kilometer(i32);

let a = Kilometer(1);
let b = 1;

asserteq!(a + b, 2);
Enter fullscreen mode Exit fullscreen mode

This code won't compile, because the Kilometer type doesn't implement the Add trait. i32 does implement the trait, which is is proof that the type alias really is an alias around i32.

When to alias and newtype?

Aliasing makes sense when:

  • You have a long type (nested Result or Options, for example) that can be shortened
  • A composite type where one part is always the same but the other should be treated as a generic
  • Giving a meaningful name to an existing type (like i32) can make your code easier to read.

Newtyping makes sense when:

  • You need to implement external traits
  • You want to use a public API to control access to internal code

The type you never knew about

Functions that return ! return the so-called never type. The never type is a Rust alias for the empty type. The point of a never type is that it expresses a function that never returns.

Functions that do this yield a result that can be coerced into any other type. This is useful when using match statements, as these must always return a single type. Keywords and macros such as continue, panic! and loop return the never type.

Sized types

A short note on sized types. Generic functions by default can only take sized types. A type is sized when its size is known at compile time. Sized types by default implement the Sized trait.

If you want to use a dynamically sized type with generics you need to use the ?Trait notation:

fn generic<T: ?Sized>(t: &T) {}
Enter fullscreen mode Exit fullscreen mode

Note that we also expect to receive a reference to the dynamically sized type as these are also stored on the heap behind a pointer.

Passing and return functions

Functions can easily passed to functions using the fn type. This is different from the trait restraints used to indicate that a closure should be passed as an argument. As something of type fn implements all three Fn traits, you can always pass functions where closures are accepted.

If you want to return a closure from a function, you have to wrap it in a Box as a closure's size is determined at runtime.

Last but not least: Macros

The most advanced Rust feature is the macro. These are functions that use Rust code to generate more Rust code. This pattern is generally known as metaprogramming.

Rust has different kinds of macros. This have different implementation detail, and each type is suited to a different use case.

macro_rules! macros

The most common type of macro in Rust is the declarative macro. This is created by using the macro_rules! macro. In a declarative macro, you can match Rust source code to patterns and generate different kind of code based on which kind of pattern is matched. This makes it possible for these kind of macros to take an undefined number of arguments.

Procedural macros

The remaining three types of macros are procedural. These macros must take Rust source code as an input and return it.

The first of these is the derive macro. To create a derive macro, you need to derive from the proc_macro_derive macro of the standard library. Derive macros only work
on structs and enums.

Derive macros have a number of restrictions around packaging. When developing such macros, developers are encouraged to include them as a dependency of the main library being developed. Then the macro can simply be reexported as part of the main library for users to import.

Attribute macros are similar to derive macros, only that they are more flexible as they can be used for functions and other data types as well. They derive from the proc_macro_attribute macro.

Finally, you have function-like macros. Though procedural in nature, these macros can be used inline like a macro_rules! macro and can take an unspecified number of arguments. They derive from proc_macro.

Top comments (0)