Timeline
Timeline
2025-10-22
init
This article introduces advanced features of the Rust language, focusing on the implementation and application of functional language features such as closures and iterators. It elaborates on the definition of closures, type inference, and patterns for implementing memoization and lazy evaluation through structs, and analyzes the mechanism by which closures capture environment variables based on the three traits Fn, FnMut, and FnOnce, as well as the role of the move keyword.
Functional Language Features: Iterators and Closures
Closures
Rust’sclosures(closures) are anonymous functions that can be saved in a variable or passed as arguments to other functions
- Anonymous functions
- Saved as variables, passed as arguments
- You can create a closure in one place and then call it in another context to perform computation
- Can capture values from the scope in which they are defined
Filename: src/main.rs
A function to replace an assumed computation that takes about two seconds
1 | use std::thread; |
Filename: src/main.rs
The business logic of the program, which takes input and calls simulated_expensive_calculation function to print out the workout plan
1 | fn generate_workout(intensity: u32, random_number: u32) { |
File name: src/main.rs
The main function contains simulated user input and simulated random number input for the generate_workout function
1 | fn main() { |
Definition of a closure
1 | let expensive_closure = |num| { |
- The closure definition is the part after the = in the assignment to expensive_closure. The closure definition starts with a pair of vertical bars (|), within which the closure’s parameters are specified;
- Ifthere is more than one parameter, they can be separated by commas, for example |param1, param2|。
- After the parameters come the curly braces that hold the closure body —if the closure body is only one line, the curly braces can be omitted. After the curly braces, the closure ends, and a semicolon is needed for the let statement. Because the last line of the closure body has no semicolon (just like a function body), the return value of the last line of the closure body (num) serves as the return value when the closure is called.
Note:
This let statement means that expensive_closure contains the definition of an anonymous functiondefinition, not the invocation of the anonymous functionReturn value。
Refactoring code
File name: src/main.rs
1 | fn generate_workout(intensity: u32, random_number: u32) { |
Type inference of closures
- Closures do not require annotation of parameter and return value types
- Closures are usually short and work in a narrow context; the compiler can often infer the types
- Types can be added manually
1 | let expensive_closure = |num: u32| -> u32 { |
Comparison with functions
1 | fn add_one_v1 (x: u32) -> u32 { x + 1 }//Function |
src/main.rs
1 | let example_closure = |x| x; |
When this closure executes the second line of code, the compiler can determine that the closure’s type is String, and an error will occur when executing the third line
1 | Compiling rust_programming v0.1.0 (/home/zhaohang/repository/rust_programming) |
Generic parameter closure
In the above code, the slow computation closure is still called more times than necessary. One way to solve this problem is to save the result into a variable for reuse wherever multiple results of the slow computation closure are needed throughout the code, so that the variable can be used instead of calling the closure again. However, this would result in many places where the result variable is repeatedly saved.
Fortunately, there is another available solution. You cancreate a struct that stores a closure and the result of calling the closure.This struct will only execute the closure when the result is needed and will cache the result value, so the rest of the code does not have to be responsible for saving the result and can reuse the value. You may have seen this pattern called*memoization* or***lazy evaluation****(lazy evaluation)*。
How to make a struct hold a closure
- The definition of a struct needs to know the types of all fields, meaning it needs to specify the type of the closure.
- Each closure instance has its own unique anonymous type, even if two closures have exactly the same signature.
- So you need to use: generics and trait bounds
Fn Trait
- provided by the standard library
- All closures implement at least one of the following traits:
- Fn
- FnMut
- FnOnce
Note:Functions also implement all three of theseFntrait. If you don’t need to capture values from the environment, you can use a function that implements the Fn trait instead of a closure.
1 | struct Cacher<T> |
The struct Cacher has a field calculation of generic type T.
The trait bound on T specifies that T is a closure using Fn.
Any closure we want to store in the calculation field of a Cacher instance must have a u32 parameter (specified by the content in parentheses after Fn) and must return a u32 (specified by the content after ->).
The value field is of type Option
Refactoring the Code
1 | fn generate_workout(intensity: u32, random_number: u32) { |
Limitations of the Cacher Implementation
- The first problem is that the Cacher instance assumes that for any arg parameter value of the value method, it will always return the same value.
Solution:
You can use a HashMap instead of a single value:
key: the arg parameter
value: the result of executing the closure
- The second problem is that it can only accept a single u32 parameter and a u32 return value.
Solution:
Introduce two or more generic parameters.
Closures capture their environment
- They can capture their environment and access variables from the scope where they are defined, whereas ordinary functions cannot
1 | fn main() { |
- ClosureIncurs memory overhead
In Rust, a closure is a compiler-generated anonymous struct whose fields are the captured variables.
1 | let x = 10; |
Capturing variables requires storage space
- If it captures avalue, the closure stores that value in its own struct.
- If it captures areference, the closure struct stores a pointer (the reference itself also takes up space).
Overhead size
- Small variables (e.g., i32, bool): Almost no extra overhead; they can be stored directly in the closure’s struct.
- Large variables (e.g., String, Vec, HashMap):
- If
movecaptured, the closure will copy or move the entire object (heap memory may be bound to the closure). - If only captured by reference, the closure stores only a pointer, but the reference’s lifetime must be valid.
- If
How closures capture values from their environment
is the same as the three ways functions receive parameters:
Taking ownership:FnOnce
- FnOnce consumes the variables captured from the surrounding scope; the scope around the closure is called itsenvironment,environment. To consume the captured variables, the closure must take ownership and move them into the closure when it is defined. The ‘Once’ part of its name indicates that the closure cannot take ownership of the same variable multiple times, so it can only be called once.
Mutable borrowing:FnMut
- FnMut takes mutable borrows of values so it can change its environment
Immutable borrowing:Fn
- Fn takes immutable borrows from its environment
When creating a closure, Rust infers which trait to use based on how the closure uses values from its environment:
- All closures implement FnOnce
- Closures that don’t move captured variables implement FnMut
- Closures that don’t need mutable access to captured variables implement Fn
There is actually a hierarchy: everything that implements Fn also implements FnMut, and everything that implements FnMut also implements FnOnce
The move keyword
Before the parameter listUsing the move keyword forces the closure to take ownership of the values it uses from its environment
- Whenpassing a closure to a new thread to move data so it belongs to the new threadthis technique is most useful
Example
1 | fn main() { |
x is moved into the closure because the closure is defined with the move keyword. The closure then takes ownership of x, and main is no longer allowed to use x in the println! statement. Removing the println! fixes the issue.
Best practices
When specifying one of the Fn trait bounds, start with Fn; based on the closure body’s situation, if FnOnce or FnMut is needed, the compiler will tell you.
Iterators
The iterator pattern allows you to perform some processing on a sequence of items.Iterators(iterator) is responsible for the logic of iterating over each item in a sequence and determining when the sequence ends. When using iterators, we don’t need to reimplement this logic.
In Rust,iterators are lazy, meaning they have no effect until you call methods that consume the iterator.
1 | let v1 = vec![1, 2, 3]; |
Iterator trait
- All iterators implement this trait
- defined in the standard library
The definition of this trait looks like this:
1 | pub trait Iterator { |
type Item and Self::Item, which define the trait’sassociated type(associated type)。
This code shows that implementing the Iterator trait requires bothDefine aItemtype, and this Item type is used as thenextreturn type of the method. In other words, the Item type will be the type of elements returned by the iterator.
The Iterator trait only requires implementing one method: next
next:
- which returns one item of the iteration each time
- the result is wrapped in Some
- when the iteration is over, it returns None
you can call the next method directly on the iterator
1 |
|
- v1_iter needs to be mutable: calling the next method on the iterator changes the state inside the iterator that tracks the sequence position. In other words, the codeconsumes(consumes) or uses the iterator. Each call to next consumes one item from the iterator.
- When using a for loop, there is no need to make v1_iter mutable because the for loop takes ownership of v1_takes ownership of iter and makes v1_iter mutable behind the scenes.
Several Iterator Methods
- iterMethod: oncreating an iterator over immutable references(immutable references to elements)
- into_iterMethod: creates aniterator that takes ownership
- iter_mutMethod:iterating over mutable references
Methods that consume the iterator
- In the standard library, the Iterator trait has some methods with default implementations
- some of these methods call the next method
one of the reasons why the next method must be implemented when implementing the Iterator trait
- those that call next are called "consuming adaptors”
because calling them consumes the iterator
An example of a consuming adapter is the sum method. This method takes ownership of the iterator and repeatedly calls next to traverse the iterator, thereby consuming it. As it iterates through each item, it adds each item to a running total and returns the total when iteration is complete.
Filename: src/lib.rs
1 |
|
Methods that produce other iterators
Another category of methods defined in the Iterator trait are callediterator adaptors(iterator adaptors),
- They allow you to change the current iterator into a different type of iterator.
- Multiple iterator adaptors can be chained together.
- However, because all iterators are lazy, you must call a consuming adapter method to get results from iterator adaptor calls.
Filename: src/main.rs
1 | let v1: Vec<i32> = vec![1, 2, 3]; |
The map method uses a closure to call each element and produce a new iterator. The closure here creates a new iterator where each element in the vector is incremented by 1.
However, this code produces a warning:
= note: iterators are lazy and do nothing unless consumed
The code actually does nothing; the specified closure is never called. The warning reminds us why:Iterator adaptors are lazy, and here we need to consume the iterator.
Filename: src/main.rs
1 | let v1: Vec<i32> = vec![1, 2, 3]; |
The underscore in the second line of code actually lets the compiler infer its type.
The collect method is aconsuming adapter, which collects the results into a collection type.
Because map takes a closure, you can specify any operation you want to perform on each element being iterated. This is an excellent example of how to use closures to customize behavior while reusing the iteration behavior provided by the Iterator trait.
Using Closures to Capture the Environment
The filter method
- The filter method on an iterator takes a closure that takes each item from the iterator and returns a boolean.
- If the closure returns true, the value will be included in the new iterator provided by filter.
- If the closure returns false, the value will not be included in the resulting iterator.
Filename: src/lib.rs
1 |
|
shoes_in_The my_size function takes ownership of a vector of shoes and a shoe size as parameters. It returns a vector containing only shoes of the specified size.
shoes_in_my_Inside the size function body, it calls into_iter to create an iterator that takes ownership of the vector. Then it calls filter to adapt this iterator into a new iterator that only contains elements for which the closure returns true.
The closure captures the shoe_size variable from the environment and uses its value to compare with the size of each shoe, retaining only shoes of the specified size. Finally, it calls collect to gather the values returned by the iterator adapter into a vector and returns it.
Creating Custom Iterators
The only method required in the Iterator trait definition is the next method. Once it is defined, you can use all other methods provided by the Iterator trait with default implementations to create custom iterators!
1 | struct Counter { |
By implementing the Iterator trait by defining the next method, we can now use any methods from the Iterator trait that have default implementations defined in the standard library, because they all rely on the functionality of the next method.
For example, for some reason we want to take the values produced by a Counter instance and pair them with values from another Counter instanceskipping the first valueproduced after that, and pair them,multiply each pair of values together,,and only keep the results that are divisible by three.,Then add all the retained results together, which can be done as shown in the test in Listing 13-23:
Filename: src/lib.rs
1 |
|
Listing 13-23: Using various methods on a custom Counter iterator, noting that Counter itself is an Iterator
Note that zip produces only four pairs of values; the theoretical fifth pair (5, None) is never produced because zip returns None when either input iterator returns None.
All these method calls are possible because we specified how the next method works, and the standard library provides default implementations for other methods that call next.
Improving the I/O Project
Using Iterators and Removing clone
Filename: src/lib.rs
1 | impl Config { |
Using the iterator returned by env::args directly
Filename: src/main.rs
1 | fn main() { |
src/lib.rs
1 | use std::env; |
src/main.rs
1 | use std::env; |
Performance Comparison: Loops vs. Iterators
Iterators are Rust’szero-cost abstractions(zero-cost abstractions), meaning that abstractions do not introduce runtime overhead, which aligns with what Bjarne Stroustrup (the designer and implementer of C++) defined in “Foundations of C++” (2012) aszero-overhead(zero-overhead) is identical:
In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.
- Bjarne Stroustrup “Foundations of C++”
Overall, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.
- Bjarne Stroustrup “Foundations of C++”
Cargo and crates.io
Customizing Builds with Release Profiles
release profile
- are predefined
- customizable
- Each profile configuration is independent of the others
Cargo’s two main profiles
- dev profile: used for development cargo build
- release profile: used for release cargo build --release
Custom profiles
Add [profile.xxxx] sections in Cargo.toml to override subsets of default configurations
File name: Cargo.toml
1 | [profile.dev] |
The opt-level setting controls how much optimization Rust applies to your code. This configuration value ranges from 0 to 3. Higher optimization levels require more compilation time, so if you are developing and compiling frequently, you may prefer faster compilation at the cost of some code performance. This is why the default opt-level for dev is 0.
Documentation comments
- Generate HTML documentation
- Display documentation comments for public APIs: How to use the API
- Use ///
- Supports Markdown
- Place before the item being described
Generate documentation
Run the rustdoc tool
1 | cargo doc |
Place the generated documentation under target/doc
Generate documentation and browse
1 | cargo doc --open |
Common sections
#Examples
Other common sections
1 | Panics: 函数可能发生panic的场景 |
Documentation comments as tests
Run cargo test: treat example code in documentation comments as tests
Filename: src/lib.rs
1 | /// Adds one to the number given. |
Try running cargo test on the documentation for the add_one function as in the example; you should see a section like this in the test results:
1 | Doc-tests my_crate |
Now try changing the function or example to make the assert_eq! in the example panic. Run cargo test again, and we will see that the documentation test catches that the example is no longer in sync with the code!
Add documentation comments to the item containing the comments
- Symbol: //!
- This type of comment typically describes crates and modules
Crate root (by convention src/lib.rs)
Within a module, document the crate or module as a whole
Example:
Filename: src/lib.rs
1 | //! # My Crate |
Use pub use to re-export a convenient public API
The file structure you use during development may not be convenient for users. Your structure might be a hierarchical one with multiple levels, but this is not convenient for users. This is because people who want to use types defined deep in the hierarchy may have difficulty finding those types. They might also get annoyed having to use use my_crate::some_module::another_module::UsefulType; instead of use my_crate::UsefulType; to use the type.
Usepub useRe-export items to make the public structure different from the private structure
src/lib.rs
1 | //! # Art |
Filename: src/main.rs
1 | use art::kinds::PrimaryColor; |
To remove the internal organization of the crate from the public API, we can take the art crate from the example and add pub use statements to re-export items to the top-level structure, as shown in Listing 14-5:
Filename: src/lib.rs
1 | //! # Art |
Use
Filename: src/main.rs
1 | use art::mix; |
Publishing a Crate
With a unique name, version number, author information added by cargo new when creating a new project, description, and chosen license, the_Cargo.toml_file of a project ready to publish might look like this:
Filename: Cargo.toml
1 | [package] |
Cargo’s documentation describes other metadata that can be specified, which can help your crate be more easily discovered and used!
Publishing:
1 | $ cargo publish |
Once a crate is published, it is permanent: the version cannot be overwritten, and the code cannot be deleted
- Purpose: projects that depend on that version can continue to work correctly
Publish a new version of an existing crate
Modify the version and republish
Use cargo yank to remove a version from Crates.io
- Cannot delete previous versions of a crate
Yanking a version prevents new projects from starting to depend on that version, but all existing projects with that dependency can still download and depend on it. Essentially, yanking means that all projects with_Cargo.lock_dependencies will not be broken, while any newly created_Cargo.lock_will not be able to use the yanked version.
To yank a crate, run cargo yank and specify the version you want to yank:
1 | $ cargo yank --vers 1.0.1 |
You can also undo a yank and allow projects to start depending on a version again by adding --undo to the command:
1 | $ cargo yank --vers 1.0.1 --undo |
Yankingdoes notdelete any code. For example, the yank feature is not intended to delete accidentally uploaded secrets. If this happens, reset those secrets immediately.
Cargo Workspaces
- Cargo workspace: helps manage multiple interrelated crates that need to be developed together
- A Cargo workspace is a set of packages that share the same Cargo.lock and output directory
Create a workspace
To run a binary crate in the top-level_add_directory, you can use the -p argument and the package name to run cargo run specifying the package we want to use in the workspace:
1 | $ cargo run -p adder |
This runs the code in_adder/src/main.rs_, which depends on the add_one crate
Installing Binary Crates from CRATES.IO
- Command: cargo install
- Source: https://crates.io
- Limitation: Only crates with a binary target can be installed
Binary target: is a runnable program
- Generated by crates that have src/main.rs or other files designated as binaries
Usually: The README contains a description of the crate:
- Has a library target
- Has a library target
- Both
cargo install
Binaries installed by cargo install are stored in the bin folder of the root directory
Extending cargo with custom commands
- Cargo is designed to be extensible with subcommands
- Example: if a binary in $PATH is named cargo-something, you can run it like a subcommand:
1 | $ cargo something |
- Custom commands like this can be listed with: cargo --list
- Advantage: you can install extensions with cargo install and run them like built-in tools
Smart pointers
- Pointer (pointer) is a general concept for a variable that contains a memory address.
This address refers to, or “points at,” some other data.
- The most common pointer in Rust is thereference(reference)。
References are indicated by the & symbol and borrow the value they point to. They have no special capabilities other than referring to data, and they alsohave no overhead, so it is the most widely used.
- Smart pointers(smart pointers) are a class of data structures that behave like pointers but also have additional metadata and capabilities.
Other differences between references and smart pointers
- References: only borrow data
- Smart pointers: often own the data they point to
Examples of smart pointers:
- String and Vec
- both own a memory region and allow users to operate on it
- also have metadata (such as capacity, etc.)
- provide additional functionality or guarantees (String guarantees its data is valid UTF-8 encoding)
Implementation of smart pointers
**Smart pointers are usually implemented using structs,**and implement the Deref and Drop traits
The Deref trait allows instances of the smart pointer struct to be used like references
The Drop trait lets you customize what happens when a smart pointer instance goes out of scope.
Using BoxPoints to data on the heap
Box
Is the simplest smart pointer: - Allows you to store data on the heap (rather than the stack)
- The stack holds a pointer to the heap data
- No performance overhead
- No other extra features
Box
Implements the Deref trait and the Drop trait
Often used in scenarios like:
- When you have a type whose size is unknown at compile time and you want to use a value of that type in a context that requires an exact size
- When you have a large amount of data and want to transfer ownership while ensuring the data isn’t copied
- When you want to own a value and only care that it implements a specific trait, rather than its concrete type
1 | fn main() { |
Using Box to enable recursive types
- At compile time, Rust needs to know how much space a type takes up
- The size of recursive types cannot be determined at compile time.
- But the size of the Box type is fixed.
- Using Box in recursive types solves the above problem.
- Cons List in Functional Languages
Cons List
_cons list_is a data structure originating from the Lisp programming language and its dialects. In Lisp, the cons function (short for “construct function”) takes two arguments to construct a new list, typically a single value and another list.
The concept of the cons function relates to more common functional programming terminology; “cons_x_onto_y_” usually means constructing a new container by placing_x_'s elements at the beginning of the new container, followed by the elements of container_y_.
Each item in a cons list contains two elements: the value of the current item and the next item. The last item contains a value called Nil and has no next item. A cons list is produced by recursively calling the cons function. The canonical name for the base case of recursion is Nil, which signals the end of the list.
The canonical name for the base case of recursion is Nil, which signals the end of the list. Note that this is different from the concept of “null” or “nil”, which represent invalid or missing values.
Cons List is not a common collection in Rust.
1 | use crate::List::{Cons,Nil}; |
Runtime Error
1 | Compiling my_box v0.1.0 (C:\Users\cauchy\Desktop\rust\my_box) |
Calculating the Size of Non-Recursive Types
Message enum:
1 | enum Message { |
When Rust needs to know how much space to allocate for a Message value, it can examine each variant and find that
- Message::Quit requires no space,
- Message::Move needs enough space to store two i32 values, and so on.
- Because an enum only uses one of its variants at a time, the space needed for a Message value is equal to the space needed to store its largest variant.
In contrast, what happens when the Rust compiler checks a recursive type like the List example above? The compiler tries to calculate how much memory is needed to store a List enum and starts checking the Cons variant. The space needed for Cons equals the size of an i32 plus the size of a List. To calculate how much memory List needs, it checks its variants, starting with Cons. The Cons variant stores an i32 value and a List value, and this calculation goes on infinitely.
Using BoxGiving recursive types a known size
- Because Box
is a pointer, we always know how much space it needs
The size of a pointer does not change based on the amount of data it points to.
- Box
- Only provides indirection and heap memory allocation
- No other extra features
- No performance overhead
- Suitable for scenarios requiring indirection, such as a Cons List
- Implemented the Deref trait and Drop trait
Dref Trait
- Implementing the Deref trait allows us to**customize the behavior of the dereference operator ***
- By implementing Deref, smart pointers canbe treated like references
Dereference operator
Filename: src/main.rs
1 | fn main() { |
Treating Boxas a reference
Filename: src/main.rs
1 | fn main() { |
Defining our own smart pointer
Filename: src/main.rs
1 | struct MyBox<T>(T); |
The compilation error we get is:
1 | $ cargo run |
MyBox
Treating a type like a reference by implementing the Deref trait
The Deref trait in the standard library requires us to implement a deref method:
This method borrows self
returns a reference to the internal data
Filename: src/main.rs
1 | use std::ops::Deref; |
When we in the example code
1 | fn main() { |
enter *y, Rust actually runs the following code under the hood:
*(y.deref())
Implicit Deref Coercion for functions and methods
- Deref Coercion is a convenience feature provided forfunctions and methodsa convenience feature provided
- Assuming T implements the Deref trait: Deref Coercion can convert a reference to T into a reference to the type resulting from Deref on T
- When passing a reference of some type to a function or method, but its type does not match the defined parameter type:
- Deref Coercion will automatically occur
- The compiler will make a series of calls to deref to convert it into the required parameter type
- It is done at compile time, with no additional performance cost
Filename: src/main.rs
1 | fn hello(name: &str) { |
- Here, we call the hello function with &m, which is a MyBox
value reference - Because in the example, on MyBox
we implemented the Deref trait, Rust can turn &MyBox into &String via deref calls. - The standard library provides a Deref implementation on String that returns a string slice, which can be seen in the Deref API documentation. Rust calls deref again to turn &String into &str, which matches the definition of the hello function.
If Rust did not implement deref coercion, to use &MyBox
Filename: src/main.rs
1 | fn main() { |
Dereferencing and Mutability
- You can use the DerefMut trait to overload the * operator for mutable references
- Rust performs deref coercion when the type and trait meet the following three conditions:
- When T: Deref<Target=U>, allows &T to be converted to &U
- When T: DerefMut<Target=U>, allows &mut T to be converted to &mut U
- When T: Deref<Target=U>, allows &mut T to be converted to &U
| Situation | Condition | Conversion |
|---|---|---|
| 1 | T: Deref<Target=U> | &TAutomatic change&U |
| 2 | T: DerefMut<Target=U> | &mut TAutomatic change&mut U |
| 3 | T: Deref<Target=U> | &mut TMutable referenceDowngradeto immutable reference&U |
Example:
1 | fn greet(name: &str) { |
Drop Trait
Implementing the Drop trait allows us to customizethe action that occurs when a value is about to go out of scope
For example: releasing files, network resources, etc.
Any type can implement the Drop trait
The Drop trait only requires you to implement the drop method
- Parameter: a mutable reference to self
The Drop trait is in the prelude
Filename: src/main.rs
1 | struct CustomSmartPointer { |
When running this program, the following output appears:
1 | $ cargo run |
Using std::mem::drop to drop a value early
It is difficult to directly disable automatic drop functionality, and it is unnecessary
- The purpose of the Drop trait is to perform automatic cleanup logic
Rust does not allow manually calling the drop method of the Drop trait
Butyou can call the standard library’s std::mem::drop function (in the prelude) to drop a value early
Filename: src/main.rs
1 | fn main() { |
Running this code will print the following:
1 | $ cargo run |
We also don’t need to worry about accidentally cleaning up values still in use, which would cause a compiler error: the ownership system ensures references are always valid, and also ensures that drop is only called once when the value is no longer used.
RcReference-counted smart pointer
Sometimes a value has multiple owners
To support multiple ownership: Rt
reference counting
Tracks references to the value
0 references: the value can be cleaned up
Data that needs to be allocated on the heap, whichis read (read-only) by multiple parts of the program, butat compile time it cannot be determined which part will finish using the data last
NoteRc
can only be used in single-threaded scenarios ;
We want to create two lists that share ownership of a third list, and the concept will look as shown in the figure:
Filename: src/main.rs
Cannot use two Box
1 | enum List { |
Error:
1 | error[E0382]: use of moved value: `a` |
We modify the definition of List to use Rc, which increases the reference count from 1 to 2 and allows a and b to share the Rc
's data ownership. Creating c also clones a, increasing the reference count from 2 to 3. Each call to Rc::clone, the Rc
's data reference count increases, and the data will not be cleaned up until there are zero references.
Filename: src/main.rs
1 | enum List { |
Data structure relationship:
1 | a (Rc) |
You can also call a.clone() instead of Rc::clone(&a), but here the Rust convention is to use Rc::clone.
- The implementation of Rc::clone does not perform a deep copy of all data like most types’ clone implementations do.
- Rc::clone only increments the reference count, which doesn’t take much time. Deep copying can take a long time。
Cloning Rcwill increase the reference count
Filename: src/main.rs
Rc::strong_count gets the reference count
1 | fn main() { |
This code will print:
1 | $ cargo run |
We can see that the Rc in ahas an initial reference count of 1, and each time clone is called, the count increases by 1. When c goes out of scope, the count decreases by 1. There is no need to call a function to decrease the count like calling Rc::clone to increase it; the implementation of the Drop trait automatically decreases the reference count when the Rc
What we cannot see from this example is that at the end of main, when b and then a go out of scope, the count here will be 0, and the Rcis fully cleaned up. Using Rc
- Rc
Throughimmutable references, Rc allows read-only sharing of data between multiple parts of the program. - If Rc
also allowed multiple mutable references, it would violate one of the borrowing rules discussed in Chapter 4: multiple mutable borrows to the same location can cause data races and inconsistencies.
RefCelland interior mutability
interior mutability
- interior mutability is one of Rust’s design patterns
- it allows you to mutate data even when there are immutable references to it
the data structure uses unsafe code to bypass Rust’s normal mutability and borrowing rules
- Unlike Rc
, RefCell the type represents sole ownership of the data it holds
Recall the borrowing rules:
- At any given time, you can have either one mutable reference or any number of immutable references
- References must always be valid
RefCellCompared to Boxthe difference
| Box | RefCell |
|---|---|
| compile-timeenforces the borrowing rules on code | only atruntimecheck borrowing rules |
| otherwise an error occurs | otherwise triggers panic |
Comparison of borrowing rules checked at different stages
| Compile time | Runtime |
|---|---|
| expose problems early | problem exposure delayed, even to production environment |
| no runtime overhead | some performance loss due to reference counting |
| best choice for most scenarios | implement certain specific memory-safe scenarios (modifying own data in immutable environment) |
| is Rust’s default behavior |
- Similar to Rc
, can only be used insingle-threadedscenarios
Choosing Box,Rc,RefCellbased on
| Box | Rc | RefCell | |
|---|---|---|---|
| Owner of the same data | One | Multiple | One |
| Mutability, borrow checking | Mutable, immutable borrows (compile-time check) | Immutable borrows (compile-time check) | Mutable, immutable borrows (runtime check) |
Interior mutability: mutably borrowing an immutable value
A corollary of the borrowing rules is that when you have an immutable value, you cannot borrow it mutably. For example, the following code will not compile:
1 | fn main() { |
If you try to compile it, you will get the following error:
1 | $ cargo run |
Here is a scenario we want to test:
We are writing a library that tracks the difference between a value and a maximum, and sends messages based on how close the current value is to the maximum. For example, this library could be used to track the number of API calls a user is allowed.
The library only provides the functionality of tracking the difference from the maximum and determining what messages to send in which situations. The program that uses this library is expected to provide the mechanism for actually sending messages: the program can choose to log a message, send an email, send a text message, etc. The library itself doesn’t need to know these details; it just needs to implement the Messenger trait it provides. Listing 15-20 shows the library code:
Filename: src/lib.rs
1 | pub trait Messenger { |
An important part of this code is the Messenger trait, which has a method send that takes animmutable referenceto self and a text message. This trait is the interface that mock objects need to implement so that the mock can be used just like a real object. Another important part is that we need to test the behavior of LimitTracker’s set_value method. We can change the value passed to the value parameter, but set_value does not return any value that we can assert on. That is, if we create a LimitTracker with a value that implements the Messenger trait and a specific max, when we pass different value values, the message sender should be told to send the appropriate messages.
The mock object we need is one where calling send does not actually send an email or message, but only records that the message was notified to be sent. We can create a new mock object instance, use it to create a LimitTracker, call LimitTracker’s set_value method, and then check whether the mock object has the messages we expect. Listing 15-21 shows an attempt at such a mock object implementation, but the borrow checker does not allow it:
However, this test has a problem:
1 | $ cargo test |
We cannot modify MockMessenger to record messages becausethe send method takes an immutable reference to selfWe also cannot follow the error message’s suggestion to use &mut self instead, because then the signature of send would not match the signature defined in the Messenger trait (you can try this change and see what error message appears).
This is exactly where interior mutability comes into play! We will store sent_messages via RefCell, and then send will be able to modify sent_messages and store the messages.
Filename: src/lib.rs
1 |
|
Using RefCellTracking borrows at runtime
Two methods (safe interface)
The borrow method: returns the smart pointer Ref
, which implements Deref The borrow_mut method: returns RefMut
, which implements Deref
RefCell
It records how many active Ref and RefMut smart pointers Each call to borrow: immutable borrow count +1
Any Ref
When its value goes out of scope and is dropped: immutable borrow count -1 Each call to borrow_mut: mutable borrow count +1
Any RefMut
When its value goes out of scope and is dropped: mutable borrow count -1
Rust uses this count to maintain the borrowing rules: at any given time, you can have either multiple immutable borrows or one mutable borrow
Combining Rc and RefCell to have multiple owners of mutable data
Filename: src/main.rs
1 |
|
When we print a, b, and c, we can see that they all have the modified value 15 instead of 5:
1 | $ cargo run |
Other types that enable interior mutability
- Cell
: accessing data through copying - Mutex
: used to implement the interior mutability pattern in cross-thread scenarios
Circular references causing memory leaks
Rust’s memory safety guarantees make it difficult to accidentally create memory that is never cleaned up (known asMemory Leak(memory leak)), but it is not impossible. Unlike rejecting data races at compile time, Rust does not guarantee complete avoidance of memory leaks, meaning memory leaks are considered memory-safe in Rust. This can be seen through Rc
Filename: src/main.rs
1 | use crate::List::{Cons, Nil}; |
If you keep the last println! line commented and run the code, you will get the following output:
1 | $ cargo run |
If you uncomment the last println! and run the program, Rust will try to print the cycle of a pointing to b pointing to a until stack overflow. This is because:
In Rust,#[derive(Debug)]for enums (like linked lists) generates a recursive printing logic: Note:nextalso callsDebug→ then prints itsnext→ recursive call, and this recursive call has no termination condition, thus eventually causing stack overflow
1 | impl Debug for List { |
Solution to Prevent Memory Leaks
Rely on the developer to ensure, not on Rust
Restructure data: some references express ownership, some do not
In a reference cycle, some parts have ownership relationships, others do not
Only ownership relationships affect value cleanup
Avoid reference cycles: turn Rcinto Weak
Rc::clone increments the strong
count of an Rc_instance, and an Rc instance is only cleaned up when its strong_count reaches 0 Rc
An instance can create a Weak Reference to its value by calling Rc::downgrade The return type is Weak
(smart pointer) Calling Rc::downgrade increments weak_count by 1
Rc
Use weak_count to track how many Weak A weak_count of 0 does not affect Rc
Cleanup of the instance
Strong VS Weak
- Strong Reference is about how to share Rc
Ownership of the instance - Weak Reference does not express the above meaning
- Using Weak Reference does not create circular references:
When the number of Strong References is 0, the Weak Reference is automatically broken
- Before using Weak
, ensure that the value it points to still exists:
On Weak
Creating a tree data structure: Node with child nodes
File name: src/main.rs
1 | use std::cell::RefCell; |
Here we clone the Rc in leaf
Add a reference from child to parent
To make child nodes aware of their parent, we need to add a parent field to the Node struct definition. The question is what type parent should be. We know it cannot contain Rc
Now let’s think about this relationship in a different way:
- A parent node should own its children
- If a parent node is dropped, its children should also be dropped
- Howevera child node should not own its parent
- If a child node is dropped, its parent should still exist。
This is exactly a case for weak references!
So parent uses Weak
Filename: src/main.rs
1 | use std::cell::RefCell; |
Creating a leaf node is similar to how a leaf node was created in Listing 15-27, except the parent field is different: leaf starts without a parent, so we create a new empty Weak reference instance.
At this point, when trying to use the upgrade method to get a reference to leaf’s parent node, we get a None value, as shown in the first println! output:
1 | leaf parent = None |
When creating the branch node, it also creates a new Weak
When printing leaf’s parent again, this time we get a Some value containing branch: now leaf can access its parent! When printing leaf, we also avoid the cycle that would eventually cause a stack overflow as in Listing 15-26: Weak
1 | leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) }, |
The absence of infinite output indicates that this code does not create a reference cycle. This can also be seen from observing the results of Rc::strong_count and Rc::weak_count calls.
Visualizing changes in strong_count and weak_countLet’s observe Rc
Example: Creating a branch in an inner scope and checking its strong and weak reference counts
Filename: src/main.rs
1 | fn main() { |
Once leaf is created, its Rc
When the inner scope ends, branch goes out of scope, and the Rc
If you try to access leaf’s parent after the inner scope ends, you get None again. At the end of the program, the Rc in leaf
All this logic for managing counts and values is built into Rc
Fearless Concurrency
- Concurrent programming(Concurrent programming), meaning different parts of a program execute independently,
- Parallel programming(parallel programming) means different parts of a program execute simultaneously
Using Threads to Run Code Simultaneously
In most modern operating systems, the code of an executed program runs in aprocess(process), and the operating system manages multiple processes. Inside a program, there can also be multiple independent parts running simultaneously. The ability to run these independent parts is calledthreads(threads)。
Splitting computations in a program into multiple threads can improve performance because the program can perform multiple tasks simultaneously, but this also adds complexity. Since threads run concurrently, the order of execution of code in different threads cannot be guaranteed in advance. This can lead to issues such as:
- Race conditions, where multiple threads access data or resources in an inconsistent order
- Deadlocks, where two threads wait for each other to stop using the resources they own, preventing them from continuing
- Bugs that only occur under specific conditions and are difficult to reproduce and fix reliably
Programming languages have several different approaches to implementing threads.
- Many operating systems provide APIs for creating new threads. This model, where a programming language calls the OS API to create threads, is sometimes called_1:1_, where one OS thread corresponds to one language thread.The Rust standard library only provides a 1:1 threading implementation; it requires a small runtime (meaning Rust does not need an additional thread scheduler or complex mechanisms; it only needs to track thread handles, call OS APIs when creating and destroying threads, with no extra user-space scheduling).
- Some crates implement other threading models with different trade-offs, namely language-implemented threads (green threads): the M:N model. This requires a larger runtime
Creating a new thread with spawn
To create a new thread, you need to call thethread::spawnfunction and pass a closure containing the code you want to run in the new thread
Filename: src/main.rs
1 | use std::thread; |
When the main thread ends, the new thread also ends, regardless of whether it has finished executing.
Waiting for all threads to finish using join Handle
- The return type of thread::spawn is JoinHandle.
- JoinHandle is an owned value.
- When the join method is called on it, it blocks the execution of the currently running thread until the threads represented by the handle terminate.
Filename: src/main.rs
1 | use std::thread; |
Calling join on the handle blocks the current thread until the thread represented by the handle finishes.Blocking(Blocking) a thread means preventing that thread from performing work or exiting. Because we placed the join call after the main thread’s for loop,
Using move closures
- Move closures are often used with the thread::spawn function, allowing you to use data from other threads.
- When creating a thread, transfer ownership of a value from one thread to another.
Example: Attempting to use a vector created in the main thread from another thread
Filename: src/main.rs
1 | use std::thread; |
The closure uses v, so it captures v and makes it part of the closure’s environment. Because thread::spawn runs this closure in a new thread, v can be accessed in the new thread. However, when compiling this example, you get the following error:
1 | $ cargo run |
Rust willinferHow to capture v, becauseprintln! only needs a reference to v, the closure tries to borrow v. However, there is a problem:Rust doesn’t know how long the new thread will run, so it cannot know whether the reference to v will always be valid。
Listing 16-4 shows a scenario where a reference to v is likely no longer valid:
Filename: src/main.rs
1 | use std::thread; |
By adding the move keyword before the closure, we force the closure to take ownership of the values it uses, rather than letting Rust infer that it should borrow the values. The following shows the modification to the code, which compiles and runs as we expect:
Example: Using the move keyword to force taking ownership of the values it uses
Filename: src/main.rs
1 | use std::thread; |
Using Message Passing to Transfer Data Between Threads
An increasingly popular approach to ensuring safe concurrency ismessage passing(message passing), where threads or actors communicate by sending messages containing data. This idea comes from the slogan in [Go programming language documentation]: “Do not communicate by sharing memory; instead, share memory by communicating.”
- Threads (or Actors) communicate by sending messages (data) to each other
- Rust: Channel (provided by the standard library)
Channel
- Channel contains:transmitter,receiver
- Call the transmitter’s method to send data
- The receiver will check and receive the arriving data
- If either the transmitter or the receiver is dropped, the Channel is ‘closed’
Create a Channel
- Usempsc::channelfunction to create a Channel
- mpsc stands formultiple producer,single consumer(multiple producers, single consumer)
- Returns a tuple: the elements are the transmitter and receiver respectively
1 | pub fn channel<T>() -> (Sender<T>, Receiver<T>) |
Let’s move the transmitter to a new thread and send a string, so the new thread can communicate with the main thread
Example: move tx to a new thread and send ‘hi’
1 | use std::sync::mpsc; |
Here, thread::spawn is used again to create a new thread, and move is used to transfer tx into the closure so that the new thread owns tx. The new thread needs to own the sending end of the channel in order to send messages through it.
The sending end of the channel has a send method that takes the value to be placed into the channel. The send method returns a Result<T, E> type, soif the receiving end has already been dropped, there is no destination for the value, so the send operation will return an error. In this example, calling unwrap on an error causes a panic. However, for a real program, it needs to be handled properly
The recv method of the receiving end
- The receiving end of the channel has two useful methods: recv and try_recv.
- Here, we use recv, which is the abbreviation for_receive_receive. This method willblock the main thread’s execution until a value is received from the channel. Once a value is sent, recv returns it in a Result<T, E>. When the sending end of the channel is closed, recv returns an error indicating that no more values will arrive.
- try_recv does not block, instead it immediately returns a Result<T, E>: an Ok value contains available information, while an Err value indicates that there is no message at the moment. If a thread has other work to do while waiting for messages, using try_recv is useful: you can write a loop that frequently calls try_recv, processing messages when available, and otherwise doing other work for a while before checking again.
Channels and Ownership Transfer
Now let’s do an experiment to see how channels and ownership work together to avoid problems: we’ll try sending the val value through a channel in a new threadand thenuse it again. Try compiling the code in the following example and see why this is not allowed:
Example: Attempting to use the val reference after it has been sent through the channel
Filename: src/main.rs
1 | use std::sync::mpsc; |
Here we try to print val after sending it through the channel via tx.send. Allowing this would be a bad idea:
Once the value is sent to another thread, that thread might modify or discard it before we use it again. Possible modifications to the value by other threads could cause errors or unexpected results due to inconsistent or nonexistent data。
However, when trying to compile the code in Example 16-9, Rust gives an error:
1 | $ cargo run |
Our concurrency mistake causes a compile-time error. The send function takes ownership of its parameter and moves the value so that it belongs to the receiver. This prevents accidentally using the value again after sending; the ownership system checks that everything is in order.
Sending multiple values and observing the receiver’s waiting
Example: Sending multiple messages and pausing for a while after each send
Filename: src/main.rs
1 | use std::sync::mpsc; |
This time, in the new thread, we have a vector of strings that we want to send to the main thread. We iterate over them, sending each string individually and pausing for one second by calling the thread::sleep function with a Duration value.
In the main thread, we no longer explicitly call the recv function: instead, we treat rx as an iterator. For each received value, we print it out. When the channel is closed, the iterator will also end.
When running the code in Example 16-10, you will see the following output, with each line pausing for one second:
1 | Got: hi |
Since there is no pause or wait code in the for loop of the main thread, it can be said that the main thread is waiting to receive values from the newly created thread.
Creating Multiple Producers by Cloning the Sender
Example: Sending Multiple Messages from Multiple Producers
Filename: src/main.rs
1 | // --snip-- |
This time, before creating the new thread, we called the clone method on the sending end of the channel. This gives us asender handle that can be passed to the first newly created thread. We will pass the original channel sender to the second newly created thread. This results in two threads, each sending different messages to the receiving end of the channel.
If you run this code, youmightsee output like this:
1 | Got: hi |
Although you might see these values appear in a different order; this depends on your system. This is what makes concurrency both interesting and difficult. If you experiment with thread::sleep, providing different values in different threads, you will find their execution becomes even more non-deterministic, producing different output each time.
Shared-State Concurrency
- In a way, channels in any programming language are similar to single ownership, because once a value is sent into a channel, you can no longer use that value.
- Shared memory is similar to multiple ownership: multiple threads can access the same memory location simultaneously
A mutex allows only one thread to access data at a time
mutex(mutex) is_mutual exclusion_an abbreviation, meaning that at any given time, it only allows one thread to access certain data. To access the data in the mutex, a thread must first acquire the mutex’slock(lock) to indicate its desire to access the data. A lock is a data structure that is part of the mutex, which records who has exclusive access to the data. Therefore, we describe the mutex as protecting its data through a locking systemprotecting(guarding) its data.
Mutexes are notoriously difficult to use because you have to remember:
- Attempt to acquire the lock before using the data.
- After processing the data protected by the mutex, you must unlock the data so that other threads can acquire the lock.。
In Rust, thanks to the type system and ownership, we won’t make mistakes with locking and unlocking.
Mutex API
As an example of how to use a mutex, let’s start by using a mutex in a single-threaded context, as shown in the example:
Example: Exploring the Mutex API in a single-threaded context for simplicity
Filename: src/main.rs
1 | use std::sync::Mutex; |
Like many types, we use the associated function new to create a Mutex
Once the lock is acquired, the return value (here, num)can be treated as a mutable reference to its internal data. The type system ensures that we acquire the lock before using the value in m: Mutex
Mutex
Sharing a Mutex Between Threads
Now let’s try using a Mutex
Listing: A program that spawns 10 threads, each of which increments a counter via a Mutex
Filename: src/main.rs
1 | use std::sync::Mutex; |
Here we create a counter variable to hold a Mutex
In the main thread, we collect all the join handles and call their join methods to ensure all threads finish. At that point, the main thread will acquire the lock and print the result of the program.
Compilation fails:
1 | $ cargo run |
The error message indicates that the counter value was moved in the previous iteration of the loop. So Rust tells uscannot movecounterownership of the lock into multiple threads。
Multiple threads and multiple ownership
By using the smart pointer Rc
Example: Attempting to use Rc
Filename: src/main.rs
1 | use std::rc::Rc; |
Compile again and… a different error appears!
1 | $ cargo run |
The first error line indicates Rc<Mutex cannot be sent between threads safely. The compiler also tells us the reasonthe trait Sendis not implemented forRc<Mutex
Unfortunately,Rc
Atomic Reference Counting Arc
Arc
Why aren’t all primitive types atomic? Why don’t all types in the standard library use Arc by default
The reason is thatthread safety comes with a performance penalty, and we want to pay that cost only when necessary. If you’re only operating on values within a single thread, the guarantees provided by atomicity are unnecessary, and the code can run faster.
Example: Using Arc
Filename: src/main.rs
1 | use std::sync::{Arc, Mutex}; |
This prints:
1 | Result: 10 |
Similarity between RefCell/Rc and Mutex/Arc
- Because counter is immutable, but we can get a mutable reference to its inner value; this means Mutex
provides interior mutability, like the Cell family of types. Just as we use RefCell to mutate the contents of an Rc , similarly we can use Mutex to mutate the contents of an Arc . - Rust cannot prevent all logical errors with Mutex
. Recall that using Rc carries the risk of creating reference cycles, where two Rc values reference each other, causing memory leaks. Similarly, Mutex also has the risk of causingdeadlock(deadlock. This occurs when an operation needs to lock two resources and two threads each hold one lock, causing them to wait for each other indefinitely.
Extensible Concurrency with the Sync and Send Traits
One interesting aspect of Rust’s concurrency model is that the language itself knows verylittleabout concurrency. Almost everything we’ve discussed so far is part of the standard library, not the language itself. Since the language doesn’t need to provide concurrency-related infrastructure, concurrency solutions are not limited by the standard library or the language: we can write our own or use concurrency features written by others.
However, there are two concurrency concepts that areembedded in the language:the Sync and Send traits from std::marker.
Allowing Ownership Transfer Between Threads with Send
- The Send marker trait indicates that ownership of values of types implementing Send can be transferred between threads.
- Almost all Rust types are Send,
- but there are some exceptions, includingRc
: This cannot be Send,
because if you clone an Rc
- Rust’s type system and trait bounds ensure that you can never accidentally send an unsafe Rc
across threads. When you try to do so, you get the error: the trait Sendis not implemented forRc<Mutex<i32>>. However, when usingArc, which is marked asSend,there is no problem. - Any type composed entirely of Send types is automatically marked as Send.Almost all primitive types are Send, except for raw pointers.。
Sync allows multi-threaded access
- The Sync marker trait indicates that a type implementing Sync cansafely have references to its value across multiple threads。
- In other words,for any type T, T is Sync if &T (an immutable reference to T) is Send., which means its reference can be safely sent to another thread.
- Similar to Send, primitive types are Sync, and types composed entirely of Sync types are also Sync.
- The smart pointer Rc
is also not Sync, for the same reason it is not Send. RefCell and Cell family of types are not Sync. RefCell 's runtime borrow checking is also not thread-safe. - Mutex
is Sync , as discussed in the section “Sharing a Mutex Between Threads”, it can be used to share access across multiple threads.
Manually implementing Send and Sync is unsafe
- It is usually not necessary to manually implement the Send and Sync traits, because types composed of Send and Sync types are automatically Send and Sync.
- Because they are marker traits, they don’t even require implementing any methods. They are only used to enforce concurrency-related invariants.
- Manually implementing these marker traits involves writing unsafe Rust code,
The important thing now is to be careful when creating new concurrent types composed of parts that are not Send and Sync, to ensure their safety guarantees are maintained.“The Rustonomicon”There is more information about these guarantees and how to maintain them in the documentation.
Object-Oriented Features of Rust
Characteristics of Object-Oriented Languages
- Objects Contain Data and Behavior
Under this definition, Rust is object-oriented: structs and enums contain data, and impl blocks provide methods on structs and enums. Although structs and enums with methods are notcalledobjects, they provide the same functionality as objects,
- Encapsulation Hides Implementation Details
Encapsulation(encapsulation) idea: the implementation details of an object are not accessible to code using that object. Therefore, the only way to interact with an object is through its public API; code using the object cannot reach into the object’s internals and directly change data or behavior. Encapsulation enables changing and refactoring an object’s internals without needing to change the code that uses the object.
In Rust, the pub keyword can be used to decide that modules, types, functions, and methods are public, while by default everything else is private.
Example:
For example, we can define a struct AveragedCollection that holds a vector of i32 values. The struct can also have a field that stores the average of all values in the vector. This way, anyone who wants to know the average of the vector in the struct can obtain it at any time without having to compute it themselves. In other words, AveragedCollection caches the average result for us. The following example shows the definition of the AveragedCollection struct:
Example: The AveragedCollection struct maintains a list of integers and the average of all elements in the collection.
Filename: src/lib.rs
1 | pub struct AveragedCollection { |
Note,**The struct itself is marked as pub so that other code can use this struct, but the fields within the struct remain private.**This is very important because we want to ensure that when a variable is added to or removed from the list, the average is also updated simultaneously. This can be achieved by implementing add, remove, and average methods on the struct, as shown in the example:
Example: Implementing the public methods add, remove, and average on the AveragedCollection struct
Filename: src/lib.rs
1 | impl AveragedCollection { |
The public methods add, remove, and average are the only ways to modify an instance of AveragedCollection. When using the add method to add an element to the list or the remove method to delete one, the implementations of these methods also call the private update_average method to update the average field.
list and average are private, so there is no other way for external code to directly add or remove elements from the list; otherwise, changes to the list could cause the average field to become out of sync. The average method returns the value of the average field, allowing external code to only read the average but not modify it.
Because we have encapsulated the implementation details of AveragedCollection, we can easily change aspects such as the data structure in the future. For example, we could use a HashSet
If encapsulation is considered a necessary aspect for a language to be object-oriented, then Rust meets this requirement. Using pub or not in different parts of the code can encapsulate implementation details.
- Inheritance, as a Type System and Code Sharing
Inheritance(_Inheritance_is a mechanism provided by many programming languages, where an object can be defined to inherit from another object’s definition, allowing it to obtain the parent object’s data and behavior without redefining them.
**If a language must have inheritance to be called object-oriented, then Rust is not object-oriented.**It is not possible to define a struct that inherits the fields and methods of a parent struct. However, if you are accustomed to using inheritance in your programming toolbox, Rust offers other solutions depending on why you originally considered inheritance.
There are two main reasons for choosing inheritance.
- The first is for code reuse: once a specific behavior is implemented for one type, inheritance allows that implementation to be reused for a different type. In contrast, Rust code can share implementations using default trait methods,
- The second reason for using inheritance relates to the type system: it allows a subtype to be used where a parent type is expected. This is also calledpolymorphism(polymorphism), which means that if multiple objects share certain properties, they can be used interchangeably.
In recent years, inheritance has fallen out of favor as a language design solution in many languages because it often carries the risk of sharing more code than necessary.**Subclasses should not always share all the characteristics of their parent class, but inheritance always does so. This can make program design less flexible and introduce meaningless method calls on subclasses,**or the possibility of errors because a method is not actually applicable to the subclass. Some languages also only allow a subclass to inherit from one parent class, further limiting design flexibility.
Trait objects for values of different types
The limitation of a vector being able to store only elements of the same type. In our earlier example, we provided an alternative by defining a SpreadsheetCell enum to store integer, floating-point, and text members. This means you can store different types of data in each cell while still having a vector representing a row of cells. This is perfectly feasible when the set of types you expect to use interchangeably is fixed at compile time.
However, sometimes we want library users to be able to extend the set of valid types in specific situations.
To demonstrate how to achieve this, here we will create an example of a graphical user interface (GUI) tool that draws items to the screen by iterating over a list and calling the draw method on each item — a common technique for GUI tools. We will create a library crate called gui that contains the structure of a GUI library. This GUI library includes some types for developers to use, such as Button or TextField. On top of this, users of gui want to create custom types that can be drawn on the screen: for example, one programmer might add Image, another might add SelectBox.
This example will not implement a full-featured GUI library, but it will show how the various parts fit together. When writing the library, we cannot know and define all the types that other programmers might want to create. What we do know is that gui needs to keep track of a collection of values of different types and needs to be able to call the draw method on each of them. We don’t need to know exactly what happens when draw is called, as long as the value has that method available for us to call.
In languages with inheritance, you can define a class named Component that has a draw method on it. Other classes, such as Button, Image, and SelectBox, would derive from Component and thus inherit the draw method. Each of them can override the draw method to define their own behavior, but the framework treats all these types as instances of Component and calls draw on them.
However, Rust does not have inheritance, so we need to find another way.
Defining a Trait for Common Behavior
To implement the behavior expected by the GUI, let’s define a Draw trait that contains a method named draw. Then we can define a vector that holdstrait objects. A trait object points to an instance of a type that implements our specified trait, along with a table used to look up the trait methods of that type at runtime. We create a trait object by specifying some kind of pointer, such as a & reference or a Box
Rust deliberately does not call structs and enums “objects” to distinguish them from objects in other languages. In a struct or enum, the data in the struct fields and the behavior in impl blocks are separate, unlike other languages where data and behavior are combined into a concept called an object.
Trait objects combine data and behavior, so in that sensethey aremore similar to objects in other languages. However, trait objects differ from traditional objects because you cannot add data to a trait object. Trait objects are not as general as objects in other languages: their specific purpose is to allow abstraction over common behavior.
The following example shows how to define a trait named Draw with a draw method:
Filename: src/lib.rs
1 | pub trait Draw { |
Next, we define a struct Screen that holds a vector named components. The type of this vector is Box
Filename: src/lib.rs
1 | pub struct Screen { |
Example: A definition of a Screen struct with a field components that contains a vector of trait objects implementing the Draw trait
On the Screen struct, we will define a run method that calls the draw method on each component in its components, as shown in the example:
Filename: src/lib.rs
1 | impl Screen { |
This is different from defining a struct that uses generic type parameters with trait bounds.A generic type parameter can only be substituted with one concrete type at a time, whereastrait objects allow for multiple concrete types at runtime. For example, the Screen struct can be defined to use generics and trait bounds, as shown in the example:
Example: An alternative implementation of the Screen struct, where the run method uses generics and trait bounds
Filename: src/lib.rs
1 | pub trait Draw { |
Thisrestrictsthe elements stored in the Vec in a Screen instance to be of the same type, meaning it must have a list of components that are all Button types or all TextField types. If you only needa homogeneous (same type) collection, it tends to use generics and trait bounds, because their definitions are monomorphized with concrete types at compile time.
1 | pub trait Draw { |
On the other hand, by using trait objects, a Screen instance can hold a Vec of smart pointers that can contain both Button and TextField.
Implementing the Trait
Now let’s add some types that implement the Draw trait. We’ll provide the Button type. Actually implementing a GUI library is beyond the scope, so the draw method body won’t have any meaningful implementation. To imagine what this implementation looks like, a Button struct might have width, height, and label fields, as shown in the example:
Filename: src/lib.rs
Example: A Button struct implementing the Draw trait
1 | pub struct Button { |
The width, height, and label fields on Button will differ from other components, such as TextField which might have width, height, label, and placeholder fields. Each type we want to draw on the screen will use different code to implement the draw method of the Draw trait to define how to draw that specific type, like the Button type here (which doesn’t include any actual GUI code, as that’s beyond the scope of this chapter). Besides implementing the Draw trait, Button might also have another impl block containing methods for how button clicks respond. Such methods are not applicable to types like TextField.
If some library user decides to implement a struct SelectBox with width, height, and options fields, and also implements the Draw trait for it, as shown in the example:
Example: Implementing the Draw trait on the SelectBox struct in another crate using gui
Filename: src/main.rs
1 | use gui::Draw; |
The library user can now create a Screen instance in their main function. At this point, they can add components by putting SelectBox and Button into Box
Example: Using trait objects to store values of different types that implement the same trait
Filename: src/main.rs
1 | use gui::{Button, Screen}; |
When writing the library, we don’t know who might add the SelectBox type at some point, but the Screen implementation can operate on and draw this new type because SelectBox implements the Draw trait, meaning it implements the draw method.
This concept — caring only about the information a value reflects rather than its specific type — is similar to what is calledduck typing(duck typing) in dynamically typed languages: if it walks like a duck and quacks like a duck, then it must be a duck! In the example’s implementation of run on Screen, run does not need to know the specific types of each component.It does not check whether a component is an instance ofButtonorSelectBoxinstance of。By specifyingBox
The advantage of using trait objects and Rust’s type system to perform duck-typing-like operations is that there is no need to check at runtime whether a value implements a particular method or worry about errors if a value doesn’t implement a method when called. Rust will not compile code if the value does not implement the trait required by the trait object.
For example, the example shows what happens when creating a Screen that uses String as its component:
Example: Attempting to use a type that does not implement the trait for the trait object
Filename: src/main.rs
1 | use gui::Screen; |
We get this error because String does not implement the rust_gui::Draw trait:
1 | $ cargo run |
This tells us that either we are passing a type to Screen that we didn’t intend to and should provide a different type, or we should implement Draw on String so that Screen can call draw on it.
Trait objects perform dynamic dispatch
- The monomorphization process performed by the compiler when using trait bounds on generics: the compiler generates non-generic implementations of functions and methods for each concrete type that replaces the generic type parameter. The code produced by monomorphization is executed withstatic dispatch(static dispatch)。
- Static dispatch occurs when the compiler knows at compile time which method is being called.
- Dynamic dispatch (_dynamic dispatch_The compiler cannot know at compile time which method is being called.
- In the case of dynamic dispatch, the code generated by the compiler determines which method is called only at runtime.
When using trait objects, Rust must use dynamic dispatch. The compiler cannot know all the types that might be used with the trait object code, so it also doesn’t know which method implementation of which type to call. For this reason, Rust uses pointers in the trait object at runtime to know which method needs to be called.Dynamic dispatch also prevents the compiler from selectively inlining method code, which correspondinglydisables some optimizations. Although we do gain additional flexibility in writing examples and the code that supports them, there are still trade-offs to consider.
Trait objects require type safety
Only object-safe traits can be implemented as trait objects (dyn). 。
There are some complex rules for making a trait object-safe, but in practice, only two rules are relevant.
A trait is object-safe if all methods defined in it satisfy the following rules:
- The return type is not Self.
- There are no generic type parameters.
When we use trait objects, we are actually doingdynamic dispatch. Rust finds the corresponding method implementation at runtime through avtablemethod table. To achieve this, the methods of a trait must be able tofully determine the method signature(including return type information, etc.) at compile time, and cannot rely on unknown type information.
However, some trait methods depend onSelfor generics, making it impossible for the compiler to guarantee that dynamic dispatch is safe.
An example of a non-object-safe trait is the standard library’sClonetrait. The declaration of the clone method in the Clone trait is as follows:
1 | pub trait Clone { |
The String type implements the Clone trait. When we call the clone method on an instance of String, we get an instance of the String type. Similarly, if we call the clone method on a Vec
When we try to compile code that violates the object safety rules of trait objects, we receive a compiler hint. For example, if we want to implement a Screen struct that holds a type implementing the Clone trait instead of the Draw trait, as shown below
1 | pub struct Screen { |
We will receive the following error:
1 | $ cargo build |
This error means we cannot use this trait for trait objects.
1 | // Make the code work using at least two methods |
First modification method
1 | trait MyTrait { |
Second modification method
1 | trait MyTrait { |
Implementation of an Object-Oriented Design Pattern
- State Pattern(state pattern) is an object-oriented design pattern. The key to this pattern is that a value has some internal state, represented as a series ofstate objects, and the value’s behavior changes with its internal state
- State object sharing functionality: In Rust, structs and traits are used instead of objects and inheritance. Each state object is responsible for its own behavior and when it should transition to another state. The value holding a state object is unaware of the behaviors of different states or when state transitions occur.
- Using the state pattern means that when the business requirements of the program change, there is no need to modify the code that holds the state or uses the value. We only need to update the code within a state object to change its rules, or add more state objects.
1 | trait State { |
Patterns and Pattern Matching
Patterns:
- Patterns are a special syntax in Rust for matching against the structure of complex and simple types
- Using patterns in conjunction with match expressions and other constructs allows for better control of the program’s control flow
- Patterns consist of (some combination of) the following elements:
- Literals
- Destructured arrays, enums, structs, and tuples
- variable
- wildcard
- placeholder
arm of match
match VALUE
requirement of expressions: exhaustive (covering all possibilities)
a special pattern: _ (underscore): it matches nothing, does not bind to a variable, often used for the last arm of match, or to ignore certain values
conditional if let expression
the if let expression is mainly a concise way to equivalently replace a match with only one arm
if let can optionally have an else, including:
- else if
- else if let
but, if let does not check for exhaustiveness:
1 | fn main() { |
while let conditional loop
- as long as the pattern continues to match the condition, it allows the while loop to keep running
1 | fn main() { |
pattern matching in for loops
- the for loop is the most common loop in Rust
- in a for loop, the pattern is the value immediately following the for keyword
1 | fn main() { |
iter().enumerate() returns a tuple
Pattern matching in let statements
- let statements are also patterns
- let PARTTERN = EXPRESSION
1 | let a = 5; |
Function parameters
- Function parameters can also be patterns
1 | fn print_coordinates(&(x,y): &(i32,i32)) { |
Refutability: whether a pattern can fail to match
- Two forms of patterns: refutable and irrefutable
- Patterns that match any possible value: irrefutable, e.g., let x = 4;
- Patterns that may fail to match for some possible values: refutable, e.g., if let Some(x) = a_value
- Function parameters, let statements, and for loops only accept irrefutable patterns
- if let and while let accept both refutable and irrefutable patterns
1 | fn main() { |
Output
1 | error[E0005]: refutable pattern in local binding: `None` not covered |
Cannot match because the pattern does not cover the None case
Modification
1 | fn main() { |
In match, all arms except the last are refutable; the last arm is irrefutable because it must match all remaining cases
Match literal values
1 | fn main(){ |
Match named variables
- Named variables are irrefutable patterns that can match any value
1 | fn main(){ |
In the second arm of this matchy is a new variable, existing in the scope of that arm
Match a mutable reference
When using the pattern &mut V to match a mutable reference, you need to be extra careful because the matched Vis a value, andnot a mutable reference
1 | fn main() { |
Multiple patterns
- In a match expression, using the | syntax (meaning OR) allows matching multiple patterns
1 | fn main(){ |
Use …= to match a range of values
1 | fn main(){ |
Both numbers and characters are acceptable
Destructuring to decompose values
- Patterns can be used to destructure structs, enums, and tuples to reference different parts of these type values
Destructuring assignment
1 | fn main() { |
Destructuring tuples
1 | fn main() { |
Destructuring structs
1 |
|
Destructuring enums
Note: The following code will trigger copy or move
1 | enum Message{ |
Run the following code:
1 |
|
Error:
1 | error[E0382]: borrow of partially moved value: `msg` |
Can match references
1 | enum Message { |
Destructuring nested structs and enums
1 | enum Color{ |
Destructuring structs and tuples
1 | struct Point{ |
Ignoring Values in Patterns
- There are several ways to ignore an entire value or parts of a value in a pattern:
_ Ignoring the Entire Value
1 | fn foo(_:i32,y:i32){ |
Ignoring Parts of a Value with Nested _
1 | fn main(){ |
Ignoring Unused Variables by Starting Their Names with _
1 |
|
In Pattern Matching_s is a new variable, and pattern matching moves ownership of s to_s, accessing s afterward will cause an error
1 | error[E0382]: borrow of partially moved value: `s` |
Using _
1 | fn main(){ |
_ does not bind, so no ownership is moved
… (Ignoring the Remaining Parts of a Value)
1 | struct Point{ |
A comma must be added,
1 | fn main() { |
Using Match Guards to Provide Extra Conditions
- A match guard is an additional if condition after the match arm pattern; the pattern must also satisfy this condition to match
- Match guards are suitable for more complex scenarios
1 | fn main(){ |
@ bindings
- The @ symbol allows us to create a variable that holds a value while testing whether that value matches a pattern
It is essentially like an equals sign
1 | enum Message{ |
1 | struct Point { |
Use cases:
The following code will cause an error
1 | enum Message { |
Fix the error
1 | enum Message { |
unsafe Rust
- There is a second language hidden within, whichdoes not enforce memory safety guarantees: unsafe Rust
It is the same as normal Rust, but provides additional superpowers
- Reasons for the existence of Unsafe Rust:
- Static analysis is conservative; using unsafe Rust is like telling the compiler: I know what I’m doing and I accept the associated risks
- Computer hardware itself is inherently unsafe, and Rust needs to be capable of low-level systems programming
unsafe superpowers
- Use the unsafe keyword to switch to unsafe Rust, opening a block that contains unsafe code
- Four actions performed in unsafe Rust (unsafe superpowers):
- Dereference a raw pointer
- Call an unsafe function or method
- Access or modify a mutable static variable
- Implement an unsafe trait
- Note:
unsafe doesnot turn off the borrow checker or disable other safety checks
Any memory safety-related errors must stay within the unsafe block
Isolate unsafe code as much as possible, preferably encapsulating it in a safe abstraction that provides a safe API
Dereference a raw pointer
- Raw pointers
*Mutable: mut T
Immutable: const T, meaningAfter dereferencing a pointer, you cannot directly assign to it*
Note: The * here is not a dereference operator; it is part of the type name
- Unlike references, raw pointers:
- Allow ignoring borrowing rules by having both mutable and immutable pointers or multiple mutable pointers to the same location
- Cannot guarantee pointing to valid memory
- Are allowed to be null
- Do not implement any automatic cleanup
- Forgo guaranteed safety in exchange for better performance or the ability to interface with other languages or hardware
1 | fn main(){ |
Raw pointers can be created in safe code blocks, but cannot be dereferenced
1 | fn main(){ |
Why use raw pointers?
- Interfacing with C language
- Building safe abstractions that the borrow checker cannot understand
Calling unsafe functions or methods
- Unsafe functions or methods: prefixed with the unsafe keyword in their definition
- Before calling, some conditions must be manually satisfied (mainly by reading the documentation), because Rust cannot verify these conditions.
- The call must be made within an unsafe block.
1 | unsafe fn dangerous(){} |
Creating a safe abstraction for unsafe code.
- A function containing unsafe code does not mean the entire function needs to be marked as unsafe.
- Wrapping unsafe code in a safe function is a common abstraction.
1 | use std::vec; |
Error reporting.
1 | error[E0499]: cannot borrow `*slice` as mutable more than once at a time |
Using unsafe code.
1 | use std::slice; |
Using extern functions to call external code.
- The extern keyword: simplifies the process of creating and using Foreign Function Interfaces (FFI).
- Foreign Function Interface (FFI): It allows one programming language to define functions that can be called by other programming languages.
- Functions declared in an extern block are always unsafe in Rust code. Because other languages do not enforce Rust’s rules and Rust cannot check them, ensuring their safety is the programmer’s responsibility.
1 | extern "C"{//"C" specifies the Application Binary Interface (ABI) used by the external function. |
- Application Binary Interface (ABI): Defines how functions are called at the assembly level.
- The “C” ABI is the most common ABI, and it follows the C language’s ABI.
Calling Rust Functions from Other Languages
- You can use extern to create interfaces through which other languages can call Rust functions
- Add theextern keyword before fn and specify the ABI
- Also need toadd the #[no_mangle] annotation to prevent Rust from changing its name during compilation
1 |
|
Accessing or Modifying a Mutable Static Variable
- Rust supports global variables, but the ownership mechanism may cause certain issues, such as data races
- In Rust, global variables are called static variables
1 | static HELLO_WORLD: &str = "Hello, world!"; |
- Static variables are similar to constants.
- Static variable names typically use SCREAMING_SNAKE_CASE notation.
- Static variables can only store references with a 'static lifetime, meaning the Rust compiler can infer their lifetime without explicit annotation.
- AccessImmutable static variablesare safe.
Difference between static variables and constants:
- The value in a static variablehas a fixed memory address. Using this value always accesses the same address. Constants, on the other hand, allowcopying their data。
- Static variables can be mutable.Accessing and modifying mutable static variables is unsafe.
1 | static mut COUNTER: u32 = 0; |
Any code that reads or writes COUNTER must be within an unsafe block. This code compiles and prints COUNTER: 3 as expected because it is single-threaded.Having multiple threads access COUNTER could lead to data races。
Having globally accessible mutable data makes it difficult to guarantee the absence of data races, which is why Rust considers mutable static variables unsafe. Whenever possible, prefer using smart pointers so that the compiler can detect whether data access between different threads is safe.
Implementing an unsafe trait
- A trait is unsafe when at least one of its methods contains invariants that the compiler cannot verify.
- You can declare a trait as unsafe by adding the unsafe keyword before the trait, and the implementation of the trait must also be marked as unsafe
1 | unsafe trait Foo { |
The Sync and Send marker traits are automatically implemented by the compiler for types composed entirely of Send and Sync types. If you implement a type that contains some types that are not Send or Sync, such as raw pointers, and you want to mark this type as Send or Sync, you must use unsafe. Rust cannot verify that our type guarantees safe cross-thread sending or multi-thread access, so we need to check it ourselves and indicate it through unsafe.
Accessing fields of a union
The last operation that applies only to unsafe is accessingunionfields. A union is similar to a struct, but only one declared field can be used at a time in an instance. Unions are primarily used to interact with unions in C code. Accessing fields of a union is unsafe becauseRust cannot guarantee the type of data currently stored in the union instance. You can refer to (https://doc.rust-lang.org/reference/items/unions.html) for more information about unions.
When to use unsafe code
Using unsafe to perform one of these five operations (superpowers) is fine, and doesn’t even require deep thought, but making unsafe code correct is not easy because the compiler cannot help guarantee memory safety. When there is a reason to use unsafe code, it is acceptable to do so, and using explicit unsafe annotations makes it easier to trace the source of problems when errors occur.
Advanced traits
Using associated types in trait definitions to specify placeholder types
- An associated type is a type placeholder in a trait that can be used in the method signatures of the trait:
You can define a trait that includes certain types without needing to know what those types are before implementation
1 | pub trait Iterator { |
Difference between associated types and generics
| Generics | Associated Type |
|---|---|
| Annotate the type each time a trait is implemented | No need to annotate the type |
| A trait can be implemented multiple times for a type (with different generic parameters) | Cannot implement a trait multiple times for a single type |
1 | pub trait Iterator { |
Associated types are mainly used to improve code readability, for example the following code:
1 | pub trait CacheableItem: Clone + Default + fmt::Debug + Decodable + Encodable { |
Compared to AsRef<[u8]> + Clone + fmt::Debug + Eq + Hash, using Address can greatly reduce the boilerplate code required for other types when implementing this trait.
Example: Using associated types:
1 | struct Container(i32, i32); |
Implement
1 | struct Container(i32, i32); |
Default generic parameters and operator overloading
- You can specify a default concrete type for a generic when using generic parameters.
- Syntax: <PlaceholderType=ConcreteType>
- This is commonly used in operator overloading.
- Rust does not allow creating your own operators or overloading arbitrary operators.
- Butyou can overload some corresponding operators by implementing the traits listed in std::ops.
1 | use std::ops::Add; |
Here, the default generic parameter self of add is used.
1 | use std::ops::Add; |
Here, the generic parameter is specified.
Main use cases for default generic parameters
- Extending a type without breaking existing code.
- Allowing customization in specific scenarios that most users do not need.
Fully Qualified Syntax
1 | trait Pilot { |
Parameterless form
1 | trait Animal { |
Here, baby_name has no parameters, so the compiler doesn’t know which Dog is calling it
- Fully qualified syntax:
::function(receiver_if_method,netx_arg,…) ; - Can be used anywhere you call a function or method
- Allows omitting parts that can be inferred from other context
- This syntax is only needed when Rust cannot distinguish which specific implementation you intend to call
1 | trait Animal { |
Using supertraits to require a trait to include functionality from other traits
- Need to use functionality from other traits within a trait
- Require that the depended-upon trait is also implemented
- The indirectly depended-upon trait is the supertrait of the current trait
1 | use std::fmt::{self, write}; |
Using the newtype pattern to implement external traits on external types
- Orphan rule: You can only implement a trait for a type if either the trait or the type is defined in the local crate
- This rule can be bypassed using the newtype pattern
- Create a new type using a tuple struct
(Example)
1 | use std::fmt; |
Advanced Types
Using the Newtype Pattern for Type Safety and Abstraction
- The newtype pattern can:
- Statically ensure that values are not confused and indicate the units of a value
- Provide abstraction for certain details of a type
- Hide internal implementation details through lightweight wrapping
Creating Type Synonyms with Type Aliases
- Rust provides type aliases: — to create another name (synonym) for an existing type — not a separate type — uses the type keyword
- Main use: reduce code repetition
- Similar to C’s typedef
1 | fn takes_long_type(f: Box<dyn Fn()+Send+'static>){ |
Using Type Aliases
1 | type Thunk = Box<dyn Fn()+Send+'static>; |
Using Type Aliases
1 | use std::io::Error; |
The Never Type
There is a special type called !:
- It has no values, known in jargon as the empty type.
- We tend to call it the never type, as it serves as the return type for functions that never return.
Functions that never return are also called diverging functions.
1 | fn bar() -> !{ |
A match expression requires all arms to return the same type, and continue returns the never type, which can be safely coerced to the type corresponding to num.
1 | impl<T> Option<T>{ |
panic! returns the never type.
Dynamic Sizes and the Sized Trait
Rust needs to determine at compile time how much space to allocate for a value of a specific type.
The concept of Dynamically Sized Types (DST):
- Writing code that uses values whose size can only be determined at runtime.
str is a dynamically sized type.(Note: not &str): the length of the string can only be determined at runtime.
The following code does not work:
1 | let s1:str = "Hello therel"; |
Because they are all the same type and should require the same space, but here the common space is not determined at declaration.
Solution: Use the &str string slice type
Rust’s general approach to dynamically sized types
- Carrying some extra metadata to store the size of dynamic information
- When using a dynamically sized type, its value is always placed behind some kind of pointer
Another dynamically sized type: trait
- Everytrait is a dynamically sized type, and can be referenced by its name
- To use a trait as a trait object, it must be placed behind some kind of pointer
- For example, &dyn Trait or Box
(Rc ) behind
- For example, &dyn Trait or Box
Sized trait
- To handle dynamically sized types, Rust provides a Sized trait to determine whether a type’s size is known at compile time—Types whose size can be computed at compile time automatically implement this trait
- Rust also implicitly adds a Sized constraint to every generic function
1 | fn generic<T>(t:T){ |
- By default, generic functions can only be used with types whose size is known at compile time; this restriction can be lifted with special syntax
?Sized trait bound
1 | fn generic<T: ?Sized>(t:&T){ |
- T may or may not be Sized
- This syntax can only be used on Sized, not on other traits
Advanced functions and closures
Function pointers
- Functions can be passed to other functions
- Functions are coerced to the fn type when passed
- The fn type isfunction pointer
1 | fn add_one(x:i32)->i32{ |
Difference between function pointers and closures
fn is a type, not a trait
- You can directly specify fn as the parameter type without declaring a generic parameter constrained by the Fn trait
Function pointers implement all three closure traits (Fn, FnMut, FnOnce):
- You can always pass a function pointer as an argument to a parameter that accepts a closure
- Therefore, it is preferable to write functions with generics that use closure traits: they can accept both closures and regular functions
In some scenarios, only want to accept fn but not closures
- Interacting with external code that does not support closures: C functions
1 | fn main(){ |
Returning closures
- Closures are expressed using traits, and a closure cannot be returned directly from a function; a concrete type that implements the trait can be returned instead
1 | // fn returns_closure() -> Fn(i32) -> i32 { // return type size is not fixed |
Macros
Reference:
Official documentation:
The Rust Programming Language
The Little Book of Rust Macros
Asynchronous programming
Reference:
Asynchronous Programming in Rust
The Rust Programming Language Bible
Formatted Output
Positional Parameters
1 | fn main() { |
Named Parameters
1 | fn main() { |
String Alignment
By default, strings are padded with spaces
1 | fn main() { |
Left-aligned, right-aligned, padded with specified characters
1 | fn main() { |
We can also use 0 to pad numbers
1 | fn main() { |
Precision
Floating-point Precision
1 | fn main() { |
String Length
1 | fn main() { |
Binary, Octal, Hexadecimal
1 | fn main() { |
Capturing Values from the Environment
1 | fn get_person() -> String { |
Exponent, Pointer Address, Escape
1 | fn main() { |
Other
error handling:
unreachable!()
This is a standard macro to mark paths that the program should not enter. If the program enters these paths, it will panic and return the error message “‘internal error: entered unreachable code’”.
1 | fn main() { |
We can also set a custom error message for this.
1 | // --- with a custom message --- |
misconception corollaries
ifT: 'staticthenTmust be valid for the entire program
Misconception Corollaries
T: 'staticshould be read as “Thas a'staticlifetime”&'static TandT: 'staticare the same thing- if
T: 'staticthenTmust be immutable - if
T: 'staticthenTcan only be created at compile time
Most Rust beginners get introduced to the'staticlifetime for the first time in a code example that looks something like this:
1 | fn main() { |
They get told that"str literal"is hardcoded into the compiled binary and is loaded into read-only memory at run-time so it’s immutable and valid for the entire program and that’s what makes it'static. These concepts are further reinforced by the rules surrounding definingstaticvariables using thestatickeyword.
1 | // Note: This example is purely for illustrative purposes. |
Regardingstaticvariables
- they can only be created at compile-time
- they should be immutable, mutating them is unsafe
- they’re valid for the entire program
The'staticlifetime was probably named after the default lifetime ofstaticvariables, right? So it makes sense that the'staticlifetime has to follow all the same rules, right?
Well yes, but a type with a'staticlifetime is different from a type bounded by a'staticlifetime. The latter can be dynamically allocated at run-time, can be safely and freely mutated, can be dropped, and can live for arbitrary durations.
It’s important at this point to distinguish&'static TfromT: 'static.
&'static Tis an immutable reference to someTthat can be safely held indefinitely long, including up until the end of the program. This is only possible ifTitself is immutable and does not move after the reference was created.Tdoes not need to be created at compile-time. It’s possible to generate random dynamically allocated data at run-time and return'staticreferences to it at the cost of leaking memory, e.g.
1 | use rand; |
T: 'staticis someTthat can be safely held indefinitely long, including up until the end of the program.T: 'staticincludes all&'static Thowever it also includes all owned types, likeString,Vec, etc. The owner of some data is guaranteed that data will never get invalidated as long as the owner holds onto it, therefore the owner can safely hold onto the data indefinitely long, including up until the end of the program.T: 'staticshould be read as “Tis bounded by a'staticlifetime” not “Thas a'staticlifetime”. A program to help illustrate these concepts:
1 | use rand; |
Key Takeaways
T: 'staticshould be read as “Tis bounded by a'staticlifetime”if
T: 'staticthenTcan be a borrowed type with a'staticlifetime or an owned typesince
1
T: 'static
includes owned types that means
1
T
- can be dynamically allocated at run-time
- does not have to be valid for the entire program
- can be safely and freely mutated
- can be dynamically dropped at run-time
- can have lifetimes of different durations
