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:
Icon | Description |
---|---|
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.
- 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!"); }
- A code block that you can also edit and undo:
fn main() { println!("Click on me and edit!"); }
- 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:
Ferris | Meaning |
---|---|
This code does not compile! | |
This code panics! | |
This 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 :)!