Introduction

Hi there, I'm Xi Xiao. This is my journey learning Rust.

How to read

I've organized related concpets together, but it shouldn't puzzle you much if you jump around and focus on whatever interests you most.

I love reading with a dark color theme. You can change themes by clicking the paint brush icon () in the top-left menu bar.

Need to find something specific? Press the search icon () or hit the S key on the keyboard to open an input box. As you type, matching content appears instantly.

Code blocks

Code blocks may contain icons for interacting purpose:

IconDescription
Copy the code
Execute and display the output
Toggle visibility of hidden lines
For editable code blocks, this undos changes you have made


Here are examples.
  1. A block that you can copy the code, or execute and see the output:
fn main() {
    println!("Hello, I'm the non-editable code block!");
}
  1. A code block that you can also edit and undo:
fn main() {
    println!("Click on me and edit!");
}
  1. A code block with a hidden line:
fn main() {
    println!("I have a hidden line, find it out!");
    // a hidden comment line here
}

In the sample code, Ferris will also help you distinguish code with different context:

FerrisMeaning
Ferris with a question markThis code does not compile!
Ferris throwing up their handsThis code panics!
Ferris with one claw up, shruggingThis code does not produce the desired behavior.

Source Code

The source files from which this book is generated can be found on GitHub.

That said, let's get on with it!

Traits

Types in Rust are powerful and versatile. Traits define shared behavior that multiple types can implement.

Default implementations

Traits in Rust are similar to interfaces in other languages but can also provide default implementations for methods.

Typically, an interface defines method signatures without any implementation. In Rust, however, you can supply default method implementations directly within a trait.

Hover over the code below and click " " to execute and see the result.

trait Greet {
    fn greet(&self) { // Default implementation
        println!("Hello from the default greeting!");
    }
}

struct Person;

impl Greet for Person {} // Uses the default implementation

fn main() {
    let person = Person;
    person.greet(); // Outputs: Hello from the default greeting!
}

We can, of course, override this default behavior like so:

trait Greet {
    fn greet(&self) {
        println!("Hello from the default greeting!");
    }
}

struct Person;
// --snip--

impl Greet for Person {
    fn greet(&self) { // Overwrite the default implementation
        println!("Hello from Person greeting!");
    }
}

fn main() {
    let person = Person;
    person.greet();  // Outputs: Hello from Person greeting!
}

Traits as Function Parameters and Return Types

Once the Greet trait is defined, it can be used as a type for function parameters and return values:

fn main() {
    let person = Person;
    do_greet(person);

    let returned = return_greet();
    returned.greet();
}

fn do_greet(greetable: impl Greet) { // Accepts any type implementing Greet
    greetable.greet();
}

fn return_greet() -> impl Greet { // Returns some type implementing Greet
    Person
}

// --snip--
struct Person;

impl Greet for Person {}

trait Greet {
    fn greet(&self) {
        println!("Hello, greeting!");
    }
}

When reading more and more Rust code, we see that experienced Rust developers frequently use standard library traits. Rust provides common traits as both guidelines and best practices. This not only helps us learn Rust but also provide great references when programming in other languages.

In the following section, let's have a look at the out-of-box common traits!

Common Traits

Let's briefly explore some standard library traits to understand their practical use.

  • Debug
  • Display
  • Default
  • Clone
  • Copy
  • From/Into
  • Eq and PartialEq
  • Ord and PartialOrd

These traits enable a rich set of tools that work seamlessly across many types. Let’s look at a few examples to illustrate their usefulness.

Debug

When we build a custom struct, like the Point below, we'd often like to display the content to the users. If we println! as below, it doesn't work. Click the "run" button in the code below and see what the compiler tells.

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let origin = Point { x: 0, y: 0 };
    println!("{}", origin); // not work
}

The compiler error implies that the Point needs to implement std::fmt::Display in order for the line println!("{}", origin) to execute. We will discuss Display trait in a minute. Now, we'd like to check the more common trait Debug.

The Debug trait enables us to inspect the content by by allowing types to be printed using the {:?} formatter in macros like println!.

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

fn main() {
    let origin = Point { x: 0, y: 0 };
    println!("{:?}", origin); // Output: Point { x: 0, y: 0 }

As shown in the 1st line, the easist way to implement Debug trait is to derive it explicitly with #[derive(Debug)], and then {:?} works now!

Display

Now it's Display. Unlike Debug, the Display trait is for user-facing output. Implementing it requires us to define how the type should look when printed.

use std::fmt;

struct Point {
    x: i32,
    y: i32,
}

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 3, y: 4 };
    println!("{}", p); // Output: (3, 4)
}

You might have noticed that there is no #[derive(Display)] here. This is because Rust’s standard library doesn’t provide such a macro, but there are external crate like derive_more to get this functionality.

Speaking of Debug v.s. Display, if the type is meant to be readable by users, implement Display. If it’s for developers, implement Debug. We can do both.

Default

The Default trait defines what it means to create a "default" value of a type. It is often used when initializing structures with default configurations.

#[derive(Debug,Default)]
struct Config {
    debug_mode: bool,
    max_connections: u32,
}

fn main() {
    let config = Config::default(); // All fields set to their default values
    println!("{:?}", config); // Let's print the content out
}

If you run the code above, the result is Config { debug_mode: false, max_connections: 0 }.

Let's take a close look at the above code.

We have derived Debug in order to prinln with {:?}. And we have also derived Default. Rust allows us to derive Default because both of the two fields (bool and u32) have implemented Default trait, with values false and 0 respectively.

Be aware that many Rust types do not implement Default. It is only implemented when it makes sense to define a "reasonable default value". For example, std::fs::File. Opening or creating a file requires a path — no default makes sense.

use std::fs::File;

#[derive(Debug, Default)]
struct Config {
    debug_mode: bool,
    max_connections: u32,
    file: File, // compiler complains here
}

fn main() {
    let config = Config::default();
    println!("{:?}", config);
}

Clone and Copy

From/Into

Eq and PartialEq

Ord and PartialOrd

Single Project to Workspace

As you build projects in Rust, it's natural to start simple: a binary (src/main.rs) or a library (src/lib.rs) sitting neatly in a single Cargo project. But as your ideas grow, your project can benefit from a little more structure.

In this chapter, we'll walk through the journey of evolving a simple Rust project into a workspace. We'll see why you might want to do it, what changes are involved, and what benefits you gain along the way. If you're curious about the "next step" in organizing your Rust code, this is for you!

The Starting Point: One Project, One Crate

Let's imagine you begin with a simple library and binary combined in one project:

my-project/
|- Cargo.toml
|- src/
   |- lib.rs
   |- main.rs

The Cargo.toml declares both a library and a binary:

[package]
name = "my-project"
version = "0.1.0"
edition = "2021"

[dependencies]

[lib]
path = "src/lib.rs"

[[bin]]
name = "my-project"
path = "src/main.rs"

You don't even need the [lib] and [[bin]] sections if you don't overwrite the default settings in the sample above as the name is identical to the project name.

This works beautifully for small programs. You can define reusable code in lib.rs, and build your CLI, server, or app in main.rs, calling into the library.

But what if you want to:

  • Add another binary (e.g., a CLI tool and a server)?
  • Create internal libraries that aren't part of the public API?

You surely can still put all logic into the lib crate but how about separating concerns more cleanly across crates? That's where workspaces shine!

Moving to a Workspace

A Cargo workspace is a way to manage multiple related crates together. Think of it like a "super-project" that coordinates building, testing, and managing dependencies across multiple packages.

Let's transform our project step-by-step.

Create a Cargo.toml for the workspace

Move the existing Cargo.toml into a new my-project/ sub-folder. Then, create a top-level Cargo.toml:

[workspace]
members = [
    "crates/my-project-lib",
    "crates/my-project-cli",
]

The members list tells Cargo which packages belong to the workspace.

Split the code into crates

We'll create two crates inside a new crates/ directory as implied in the above workspace Cargo.toml.

my-project/
|- Cargo.toml (workspace)
|- crates/
   |- my-project-lib/
      |- Cargo.toml
      |- src/
         |- lib.rs
   |- my-project-cli/
      |- Cargo.toml
      |- src/
         |- main.rs

my-project-lib will hold the reusable library code, while my-project-cli will be a binary crate depending on my-project-lib.

Update the crate dependencies

In crates/my-project-cli/Cargo.toml:

[package]
name = "my-project-cli"
version = "0.1.0"
edition = "2024"

[dependencies]
my-project-lib = { path = "../my-project-lib" }

Now your CLI crate can call into the library just like before. Spend some time to put the code and tests into the corresponding crate. It's a good brain excercise, with the help of cargo build --workspace, to define the clear crate bundary which might not be the case before.

Why Bother?

At first, moving to a workspace might feel like extra overhead. But it brings powerful benefits, even for relatively small projects:

  • Clear Separation of Concerns: Each crate focuses on a specific task. Your codebase becomes easier to understand and maintain.
  • Faster Builds: Cargo can rebuild only the crates that changed, rather than the entire project.
  • Multiple Binaries: You can easily add more binaries (tools, servers, utilities) alongside your main app.
  • Internal Libraries: Share code across multiple binaries without publishing it externally.
  • Testing in Isolation: You can run cargo test per-crate to get faster, more focused feedback.
  • Ready for Growth: When you eventually want to split parts into separate published crates (on crates.io) or keep internal libraries private, you're already halfway there.

In short, workspaces help your project scale without becoming messy.

A Natural Evolution

You don't have to start with a workspace when writing your first Rust project. But when your project grows just a little—adding a second binary, needing some internal shared code—workspaces offer a clean and powerful way to stay organized.

The best part? Moving to a workspace is an incremental change. You can migrate a project in stages, and Rust's tooling (Cargo) makes it smooth.

If you're curious, give it a try on your next project! You'll gain both clarity and flexibility. In this small CLI project of mine: jirun, I have transferred it into workspace style to prepare for growing :)!