Timeline
Timeline
2025-09-11
init
2025-10-19
modify some format errors
2025-10-22
move something to another post
This article introduces the basics of the Rust language, discusses in detail the installation and debugging of the Rust environment, the usage of the Cargo package management tool, and summarizes the core syntax of variables and mutability mechanisms as well as scalar data types such as integers, floating-point numbers, booleans, and characters.
Installation and Debugging
Installation
On Linux
1 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh |
Debugging
Usage on Linux
1 | rust-gdb target/debug/your_program |
Cargo
Create a Project
cargo new project_name
Help Information
1 | Create a new cargo package at <path> |
Cargo.toml
TOML (Tom’s Obvious, Minimal Language) format is the configuration format for Cargo
1 | [package]#Section header, indicating that the following is used to configure the package |
In Rust, a package of code is calledcrate
Build a Cargo Project
1 | cargo build |
Create executable target/debug/hello_cargo or target\debug\hello_cargo.exe(Windows)
Run .\target\debug\hello_cargo.exe
The first run generates the cargo.lock file
This file tracks the exact versions of project dependencies; no need to manually modify it
Build and Run a Cargo Project
1 | cargo run |
If previously compiled and code unchanged, it will execute directly
cargo check
1 | cargo check |
Check code to ensure it compiles, but produce no executable file
cargo check is much faster than cargo build
Release build
1 | cargo build --release |
Optimizations are performed during compilation, making the code run faster but increasing compilation time
The executable will be generated in target/release instead of target/debug
Variables and Mutability
Variables
Declaration and usageletKeyword
By default, variables are immutable
When declaring a variable, addmutkeyword to make the variable mutable
let mut x = 3;
Constants
Similar to immutable variables,_Constants_are values bound to a name that are not allowed to change, but constants differ from variables in some ways.
The mut keyword is not allowed with constants. Constants are not just immutable by default—they are always immutable.
Use the const keyword instead of let to declare constants, and**must** annotate the type of the value。
3.Constants can be declared in any scope, including the global scope,
- The last difference is that constants can only be set toconstant expressions, andcannot be any other value that can only be computed at runtime。
1 | const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3; |
Naming convention: all uppercase, separated by underscores
Shadowing
1 | fn main() { |
We can define a new variable with the same name as a previous variable,the new variable shadows the previously declared variable with the same name
Shadowing and marking a variable as mutare different
- If the let keyword is not used, assigning a value to a non-mut variable will cause a compile-time error.
- A new variable with the same name declared using let is also immutable.
- A new variable with the same name declared using let,its type can be different from the previous one.
Allowing unused variables.
Two ways.
1 | fn main() { |
Data types.
Rust isa statically compiled language, the types of all variables must be known at compile time.
Scalar types.
Rust has four basic scalar types:Integer、Floating-point、Booleanandcharacter type
integer type
If we do not explicitly give a variable a type, the compiler will automatically infer one for us.
1 | fn main(){ |
If an integer is not given a type, it defaults to the i32 type.
1 | fn main() { |
| length | signed | unsigned |
|---|---|---|
| 8-bit | i8 | u8 |
| 16-bit | i16 | u16 |
| 32-bit | i32 | u32 |
| 64-bit | i64 | u64 |
| 128-bit | i128 | u128 |
| arch | isize | usize |
The isize and usize types depend on the computer architecture running the program: they are 64-bit on 64-bit architectures and 32-bit on 32-bit architectures.
1 | fn main() { |
integer literals
| numeric literals | example |
|---|---|
| Decimal | 98_222 |
| Hex | 0xff |
| Octal | 0o77 |
| Binary | 0b1111_0000 |
| Byte (single-byte character, only for u8) | b’A’ |
Rust’s default numeric type is i32. isize or usize are mainly used as indices for certain collections.
Integer Overflow
For example, a u8 can hold values from 0 to 255. So what happens when you try to change it to 256? This is called “integer overflow” (integer overflow), which leads to one of two behaviors. Whencompiling in debug mode, Rust checks for such issues and causes the program to panic, a term Rust uses to indicate the program exits due to an error.
In release builds, Rust does not detect overflow; instead, it performs an operation known as two’s complement wrapping. In short, values larger than the maximum the type can hold wrap around to the minimum, so 256 becomes 0, 257 becomes 1, and so on. Relying on integer wrapping is considered an error, even if this behavior can occur. If you truly need this behavior, the standard library provides a type, Wrapping, that explicitly offers this functionality. To explicitly handle the possibility of overflow, you can use the following methods provided by the standard library on primitive numeric types:
- Available in all modeswrapping_* methods for wrapping, such as wrapping_add
- Ifchecked_* methodreturns None if overflow occurs
- withoverflowing_* methodreturns a value and a boolean indicating whether overflow occurred
- withsaturating_* methodsaturates at the minimum or maximum value
1 | // fix errors and`panic` |
modify
1 | fn main() { |
floating-point types
Rust’s floating-point types are f32 and f64, which are 32 bits and 64 bits respectively. The default type is f64 because on modern CPUs it is roughly the same speed as f32 but offers higher precision. All floating-point types are signed.
1 | fn main() { |
Floating-point numbers are represented according to the IEEE-754 standard. f32 is a single-precision float, and f64 is a double-precision float.
1 | fn main() { |
numeric operations
All number types in Rust support basic mathematical operations: addition, subtraction, multiplication, division, and remainder. Integer division truncatesRound downTo the nearest integer
1 | fn main() { |
Two modification methods
1 | fn main() { |
Calculate
1 | use std::fmt::Display; |
Sequence
1 | fn main() { |
Boolean type
The boolean type in Rust is represented by bool
1 | fn main() { |
Character type
Rust’s char type is the most primitive alphabetic type in the language
1 | fn main() { |
Char literals are declared with single quotes, whereas string literals are declared with double quotes. The size of Rust’s char type isfour bytes(four bytes), and represents a Unicode Scalar Value, which means it can represent more than ASCII. In Rust, accented letters, Chinese, Japanese, Korean characters, emoji, and zero-length whitespace characters are all valid char values. Unicode scalar values range from U+0000 to U+D7FF and U+E000 to U+10FFFF. However, ‘character’ is not a concept in Unicode, so what people intuitively consider a ‘character’ may not correspond to Rust’s char.
Size
1 |
|
Unit type
1 | fn main() { |
The memory occupied by the unit type is 0!!!
1 | use std::mem::size_of_val; |
Compound types
Compound types(Compound types) can combine multiple values into one type. Rust has two primitive compound types: tuples and arrays.
Tuple type
TupleFixed length: Once declared, its length cannot increase or decrease
Create a tuple using a comma-separated list of values enclosed in parentheses. Each position in the tuple has a type, and the types of these different valuesdo not have to be the same
1 | fn main() { |
The tup variable is bound to the entire tuple because a tuple is a single compound element. To get individual values from a tuple, you can usepattern matching(pattern matching) todestructure(destructure) the tuple value, like this:
1 | fn main() { |
The program first creates a tuple and binds it to the tup variable. Then it uses let and a pattern to split tup into three different variables, x, y, and z. This is calleddestructuring(destructuring), because it splits a tuple into three parts.
You can alsoUse a dot (.) followed by the index of the value to directly access them. The first index of a tuple is 0. For example:
1 | fn main() { |
A tuple without any values has a special name, called theunittuple. This value and its corresponding type are both written as (), representing an empty value or empty return type. If an expression does not return any other value, it implicitly returns the unit value.
Tuples that are too long cannot be printed
1 | // Fix the code error |
Array type
Unlike tuples, every element in an array must have the same type. Arrays in Rust are different from arrays in some other languages; in Rust, array length is fixed.
The type of an array is [T; Length],The length of an array is part of its type signature, so the length must be known at compile time,
The vector type is an array-like collection type provided by the standard library that allows its length to grow and shrink. When unsure whether to use an array or a vector, you should probably use a vector.
You can write the type of an array like this: within square brackets, include the type of each element, followed by a semicolon, then the number of elements in the array.
let a: [i32; 5] = [1, 2, 3, 4, 5];
Here, i32 is the type of each element. After the semicolon, the number 5 indicates that the array contains five elements.
1 | fn main() { |
You can also create an array by specifying the initial value, a semicolon, and then the number of elements in square brackets.an array where every element has the same value.:
1 | let a = [3; 5]; |
The array named a will contain 5 elements, all initially set to 3. This syntax is equivalent to let a = [3, 3, 3, 3, 3]; but more concise.
Accessing Array Elements
An array is a single memory block of a known fixed size that can be allocated on the stack. You can access array elements using an index, like this:
1 | fn main() { |
Invalid Array Access
If we access an element beyond the end of the array, the program causes aruntimeError. The program exits with an error message. Whentrying to access an element by index, Rust checks whether the specified index is less than the array length. If the index exceeds the array length, Rust will panic, which is a Rust term used for when a program exits due to an error.
This check must be performed at runtime, especially in certain cases, because the compiler cannot know what value the user will input when running the code later.
Type Casting
Basic type conversion using as
- Rust doesnot provide implicit type coercion for basic types, but we can perform explicit conversion using as.
1 |
|
2.By default, numeric overflow causes a compilation error, but we can avoid the compilation error by adding a global annotation**#![allow(overflowing_literals)]**(overflow will still occur)
1 |
|
- When converting any numeric value to an unsigned integer type T, if the current value is not within the range of the new type, we canadd or subtract (increase or decrease by T::MAX + 1) from the current value, until the latest value is within the range of the new type. Suppose we want to convert 300 to u8 type. Since the maximum value of u8 is 255, 300 is not within the range of the new type and is greater than the maximum value of the new type, so we need to subtract T::MAX + 1, which is 300 - 256 = 44.
1 |
|
- Raw pointers can be converted to and from integers representing memory addresses
1 | fn main() { |
From/Into
- The From trait allows a typeto define how to create itself based on another type, thus it provides a very convenient way of type conversion.
- From and Into are paired; as long as we implement the former, the latter will be automatically implemented: As long as impl From
for U is implemented, the following two methods can be used: let u: U = U::from(T) and let u: U = T.into(). The former is provided by the From trait, while the latter is provided by the automatically implemented Into trait. - It should be noted that when using the into method, explicit type annotation is required, because the compiler is likely unable to deduce the required type for us.
Example
1 | fn main() { |
Implement the From trait for a custom type
1 | // From is included in`std::prelude`, so we don't need to manually bring it into the current scope |
When performing error handling, implementing the From trait for our custom error type is very useful. This way, we canautomatically convert a certain error type into our custom error type using the ? operator
1 | use std::fs; |
TryFrom / TryInto
Similar to From and Into, TryFrom and TryInto are also generic traits for type conversion.
However, unlike From/Into,TryFrom and TryInto can handle failures after conversion and return a Result。
1 | fn main() { |
Custom Implementation
1 |
|
Other Conversions
Converting any type to String
As long as the ToString trait is implemented for a type, any type can be converted to String. In fact, this approach is not the best; you can use the fmt::Display trait? It controls how a type is printed, and when implemented, it automatically implements ToString. Because to_string is based on fmt::Display
1 | use std::fmt; |
Parsing a String
Using the parse method, a String can be converted to an i32 number, because the standard library implements FromStr for the i32 type: impl FromStr for i32
1 | // To use `from_str` method, you needs to introduce this trait into the current scope. |
Custom implementation of the FromStr trait
1 | use std::str::FromStr; |
transmute
std::mem::transmuteis an unsafe function that caninterpret one type as another type at the bit level, where these two typesmust have the same number of bits。
transmute is equivalent to moving one type bitwise into another type; it copies all bits of the source value into the target value and then forgets the source value. This function is similar to the memcpy function in C.
Because of this,transmute **it is extremely unsafe!**The caller must ensure the safety of the code themselves, which is, of course, the purpose of unsafe.
Example
- transmute can convert a pointer into a function pointer; this conversion is not portable because function pointers and data pointers may have different sizes on different machines.
1 | fn foo() -> i32 { |
- transmute can also extend or shorten the lifetime of an invariant, i.e.,an ‘illegal conversion’ of lifetimes!
1 | // R is a struct that wraps a reference. It does not store the i32 itself, but rather a reference to an i32 |
- In fact, we can also use some safe methods to replace transmute.
1 | fn main() { |
Functions
In Rust code,function and variable namesuse**snake case**snake case style. In snake case, all letters are lowercase and words are separated by underscores
We define functions in Rust by typing fn followed by the function name and a pair of parentheses. Curly braces tell the compiler where the function body begins and ends.
Parameters
We can define as havingparameter(parameters) function, parameters are special variables that are part of the function signature. When a function has parameters (formal parameters), specific values (actual arguments) can be provided for these parameters. Technically, these specific values are called arguments (arguments)
1 | fn main() { |
When defining multiple parameters, separate them with commas
Statements and Expressions
The function body consists of a series of statements and an optional trailing expression.
Statement(Statements)is an instruction that performs some action but does not return a value。
Expression(Expressions)evaluates and produces a value。
Statements do not return values, while expressions evaluate to a value.
1 | fn main() { |
In the statement let y = 6;, the 6 is an expression that evaluates to the value 6.A function call is an expression。A macro call is an expression。A new block scope created with curly braces is also an expression, for example:
1 | fn main() { |
This expression:
1 | { |
is a code block whose value is 4. There is no semicolon at the end of the expression. If you add a semicolon at the end of the expression, it becomes a statement, and statements do not return a value.
Return value
Do not name the return value, but declare its type after the arrow (->)
In Rust, the return value of a function is equivalent to the value of the last expression in the function body. Use the return keyword with a specified value to return early from a function; however, most functions implicitly return the last expression.
1 | fn main() { |
Return type is ()
1 | fn main(){ |
Return type is never
1 | use std::thread; |
Diverging function
A diverging function does not return any value, so it can be used in places where any value is expected to be returned
1 | fn main() { |
The difference between unimplemented! and [todo] is that while todo! conveys an intent of implementing the functionality later and the message is “not yet implemented”, unimplemented! makes no such claims. Its message is “not implemented”. Also some IDEs will mark todo!s.
Call
1 |
|
Using unimplemented!() and todo!(); will cause the following error
thread ‘main’ panicked at ‘not implemented’, src\main.rs:24:5
1 | let _v = match b { |
Control flow
if expressions
1 | fn main() { |
The code blocks associated with conditions in if expressions are sometimes called *arms
if/else can be used as an expression for assignment
1 | fn main() { |
Note
The condition in the codemust be a boolvalue. If the condition is not a bool value, we will get an error. Rust does not attempt to automatically convert non-boolean values to boolean. You must always explicitly use a boolean value as the condition for if.
If more than one else if is used, it’s better to refactor the code using match
Using if in let statements
Because if is an expression, we can use it on the right side of a let statement
1 | fn main() { |
The possible return values of each branch of if must be of the same type
Note
If the expression in the if block returns an integer, while the expression in the else block returns a string. This is not feasible because a variable must have only one type. Rust needs to know the exact type of a variable at compile time.
Loops
loop
1 | fn main() { |
Returning Values from Loops
1 | fn main() { |
Loop Labels
If there are nested loops, break and continue apply to the innermost loop at that point. You can optionally specify aloop label(loop label) on a loop, and then use the label with break or continue to have those keywords apply to the labeled loop instead of the innermost loop
1 | fn main() { |
while
1 | fn main() { |
for
1 | fn main() { |
Using a for loop to iterate over collection elements enhances code safety compared to a while loop, and eliminates bugs that could arise from going beyond the end of an array or not traversing far enough and missing some elements
For iterable objects that do not implement Copy, for in takes ownership
1 | fn main() { |
Iterating over an array by index and value
1 | fn main() { |
Range
It is a type provided by the standard library used to generate a sequence of all numbers starting from one number up to but not including another number. (The ending number is excluded)
The rev method can reverse a range
1 | fn main() { |
Ownership, References, and Borrowing
Stack and Heap
Both the stack and the heap are memory available to your code to use at runtime, but they are structured differently. The stack stores values in the order it gets them and removes the values in the opposite order. This is also referred to aslast in, first out(last in, first out)。
Adding data is calledpushing onto the stack(pushing onto the stack), and removing data is calledpopping off the stack(popping off the stack). All data on the stack must occupy a known, fixed size.
Data with an unknown size at compile time or a size that might change must be stored on the heap instead.The heap is less organized: when you put data on the heap, you request a certain amount of space. The memory allocator finds an empty spot in the heap that is big enough, marks it as being in use, and returns a pointer, which is the address of that location.pointer(pointer). This process is calledallocating on the heap(allocating on the heap), or sometimes just “allocating.” (Pushing values onto the stack is not considered allocating.)**Because the pointer to the data placed on the heap is known and its size is fixed, you can store that pointer on the stack.**However, when the actual data is needed, the pointer must be accessed.
**Pushing onto the stack is faster than allocating memory on the heap because the allocator does not need to search for memory space to store new data; its location is always at the top of the stack.**In contrast, allocating memory on the heap requires more work because the allocator must first find a memory space large enough to hold the data and then make some records to prepare for the next allocation.
Accessing data on the heap is slower than accessing data on the stack because it must be accessed through a pointer. Modern processors are faster with fewer jumps in memory (caching), and for the same reason, processors work better when the data they are processing is close together (like on the stack) rather than far apart (like possibly on the heap).
When your code calls a function, the values passed to the function (including pointers that may point to data on the heap) and the function’s local variables are pushed onto the stack. When the function ends, these values are popped off the stack.
Tracking which parts of code are using which data on the heap, minimizing the amount of duplicate data on the heap, and cleaning up unused data on the heap to ensure space is not exhausted—these are the problems that the ownership system addresses. Once you understand ownership, you won’t need to think about the stack and heap often, but understanding that the main purpose of ownership is to manage heap data helps explain why ownership works the way it does.
Ownership Rules
- Each value in Rust has an owner.
- There can be only one owner at a time.
- When the owner (variable) goes out of scope, the value will be dropped.
Example of Ownership:
Modify the code below:
1 | fn main() { |
method
1 | fn main() { |
or
1 | fn main() { |
When ownership is transferred, mutability can also change accordingly.
1 | fn main() { |
Variables and Scope
A scope is the range within a program for which an item is valid. Consider a variable like this:
let s = “hello”;
The variable s is bound to a string literal, which ishardcodedinto the program code. This variable is valid from the point it is declared until the currentscopeends. The comments in Example 4-1 indicate where the variable s is valid.
1 | { // s is not valid here, it is not yet declared |
- When senters scope, it is valid.
- This continues until itleaves scope.
str and &str
Normally we cannot use the str type, but we can use &str instead
1 | fn main() { |
To use the str type, it must be paired with Box.
1 | fn main() { |
& can be used to convert Box&Box<str>automatically convert to&str
1 | fn main() { |
String type
String is a type defined in the standard library, allocated on the heap, and can grow dynamically. Its underlying storage is a dynamic byte array (Vec
Difference between Unicode and encoding methods
- Unicode
- is a character set that specifies a uniquecode point for each character
- Code point form:
U+0000~U+10FFFF- For example:
U+4F60→ ‘you’U+1F600→ 😀- UTF-8 / UTF-16 / UTF-32
- is the method of converting Unicode code points intobyte sequencesspecific method
- In other words, Unicode is the ‘character table’, and UTF-8 is the ‘encoding rule for how to store or transmit these characters’
String.chars() and String.bytes() iterate over Unicode characters and bytes respectively.
- The length of a Unicode character is not fixed
- A Unicode character is not necessarily a fully displayed character
In Unicode and text processing,a grapheme clusteris a user-perceived ‘character unit’, meaning it is a complete character seen by the user, but it may consist of multiple Unicode scalar values (char).
Simply put:
- A grapheme cluster ≈ ‘a fully displayed character’
- Unlike Rust’s
char,charis a single Unicode scalar value (which could be a letter, a Chinese character, or a component of an emoji) - A grapheme cluster may contain:
- Base character + combining marks (such as diacritics)
- Emoji sequences (e.g., 👨👩👧👦 family emoji, composed of multiple emojis and zero-width joiners)
To iterate over grapheme clusters, a third-party crate is needed, such as:
String manages data allocated on the heap, so it can store text whose size is unknown at compile time. You can create a String from a string literal using the from function
1 | let s = String::from("hello"); |
OkayModify this type of string:
1 | let mut s = String::from("hello"); |
Memory and Allocation
In the case ofstring literals, we know their contents at compile time, sothe text is directly hardcoded into the final executable. This makes string literals fast and efficient. However, these properties come only from the immutability of string literals. Unfortunately, we cannot put a blob of memory into the binary for each piece of text whose size is unknown at compile time and whose size might change while the program is running.
For the String type, in order to support a mutable, growable piece of text, we need to allocate an amount of memory on the heap, unknown at compile time, to hold the content. This means:
- The memory must be requested from the memory allocator at runtime.
- We need a way of returning this memory to the allocator when we’re done with our String (garbage collection in some languages).
Rust takes a different path:The memory is automatically freed once the variable that owns it goes out of scope.. Below is a version of the scoping example from Listing 4-1 that uses a String instead of a string literal:
1 | { |
This is a natural point to return the memory needed by the String to the allocator: when s goes out of scope. When a variable goes out of scope, Rust calls a special function for us. This function is calleddrop, where the author of String can place the code to free the memory. Rust calls drop automatically at the closing }.
Ways Variables and Data Interact
move
Stack-Only Data
1 | let x = 5; |
binds 5 to x; then makes a copy of the value in x and binds it to y. Now we have two variables, x and y, both equal to 5
Because integers are simple values with a known, fixed size, these two 5s are placed on the stack.
For such data, moving and cloning are the same
1 | let s1 = String::from("hello"); |
A String is made up of three parts:
- a pointer to the memory that holds the contents of the string
- a length, len, which is the number of bytes that the contents of the string is currently using
- A capacity refers to the total number of bytes a String has obtained from the operating system.
The aboveStored on the stack, the part storing the string content is stored on the heap
When we assign s1 to s2, if the String’s data is copied, it means we copy its pointer, length, and capacity from the stack. We do not copy the heap data pointed to by the pointer.
When the variable goes out of scope, drop is called, leading to a double free
To ensure memory safety
- Rust does not attempt to copy the allocated memory
- Rust invalidates s1, meaning that when variable s1 goes out of scope, nothing needs to be freed (corresponding to ownership rule 2: a value has exactly one owner at any given time)
1 | let s1 = String::from("hello"); |
Rust’s approach differs from a shallow copy becauseit invalidates the copied entity while performing a shallow copy, thus using a new term: Move
Implicit design principle : Rust does not automatically create deep copies of data
Because in terms of runtime performance, any automatic assignment operation is cheap.
partial move
When destructuring a variable, you can use both move and reference pattern bindings simultaneously. When doing so, a partial move occurs:Ownership of part of the variable is transferred to other variables, while we obtain a reference to another part.
In this case,the original variable can no longer be used, but the part whose ownership was not transferred can still be used, that is, the part that was previously referenced.
1 | fn main() { |
clone
If you want to deep copy data on the heap, not just data on the stack, you can use the clone method
1 | let s1=String::from("Hello"); |
copy
Copy trait, which can be used for types like integers that are entirely placed on the stack
- If a type implements the Copy trait, the old variable remains usable after assignment.
- If a type or any part of it implements the Drop trait, Rust does not allow it to also implement the Copy trait.
Any simple scalar type and its combinations are Copy.
Anything that requires memory allocation or some resource is not Copy.
Some types that have the Copy trait:
- All integer types, such as u32.
- The Boolean type, bool, with values true and false.
- All floating-point types, such as f64.
- The character type, char.
- Tuples, but only if their contained types also implement Copy. For example, (i32, i32) implements Copy, but (i32, String) does not.
Ownership and Functions
Passing a value to a function is similar to assigning it to a variable. Passing a value to a function may move or copy it, just like an assignment statement.
1 | fn main() { |
When trying to use s after calling takes_ownership, Rust throws a compile-time error.
Return values and scope
Return values can also transfer ownership
1 | fn main() { |
The ownership of variables always follows the same pattern:
- moving the value when assigning it to another variable。
- when a variable holdinga value on the heap goes out of scope, its value will be cleaned up via drop, unless the data has been moved to be owned by another variable.
If you want a function to use a value without taking ownership, you need to return the parameter passed in
1 | fn main() { |
This is too cumbersome; Rust provides a feature for using values without taking ownership, calledreferences(references)。
References and Borrowing
1 | fn main() { |
A reference(reference) is like a pointer, becauseit is an address, we can access data belonging to other variables stored at that address.
Unlike pointers,references guarantee they point to a valid value of a particular type.
1 | fn main() { |
Note: The opposite of using & to reference isdereferencing(dereferencing), which uses the dereference operator " * "
1 | let s1 = String::from("hello"); |
The &s1 syntax lets us create areference tothe value s1, but does not own it. Because we don’t own the value,when the reference goes out of use, the value it points to is not dropped。
We call the action of creating a referenceborrowing(borrowing)
Just as variables are immutable by default, so are references.References (by default) do not allow modifying the value they refer to.
Rust automatically dereferences in certain situations
1 | fn main() { |
Example:
1 | fn main() { |
Mutable References
1 | fn main() { |
Mutable references have a big restriction:Only one mutable reference can exist at a time
1 | let mut s = String::from("hello"); |
The benefit of this restriction is that Rust can prevent data races at compile time.Data Race(data race) Similar to a race condition, it can be caused by these three behaviors:
- Two or more pointers access the same data at the same time.
- At least one pointer is used to write data.
- There is no mechanism to synchronize data access.
You can use curly braces to create a new scope, allowing multiple mutable references, but they cannotsimultaneouslyexist:
1 | let mut s = String::from("hello"); |
Another restriction:You cannot have both a mutable reference and an immutable reference at the same time
Butmultiple immutable references are allowed
1 | let mut s = String::from("hello"); |
Dangling References
In Rust, the compiler ensures that references will never become dangling: when you have a reference to some data, the compiler guarantees that the data will not go out of scope before the reference does.
Let’s try to create a dangling reference, Rust will prevent it with a compile-time error:
Filename: src/main.rs
1 | fn main() { |
ref
ref is similar to &, it can be used to get a reference to a value, but their usage differs.
1 | fn main() { |
Summary of reference rules (borrowing rules)
- At any given time,eitheryou can have only one mutable reference,oryou can have any number of immutable references.
- References must always be valid.
Ok: Borrowing immutable from a mutable object
1 | fn main() { |
None Lexical Lifetimes(NLL)
Non-lexical lifetimes
Example
1 | // Comment out one line of code to make it work |
Just comment out the println
1 | fn main() { |
This code compiles successfully because the compiler knows that the mutable borrow of x does not last until the end of the scope, but ends before x is used again, so there is no conflict.
1 | fn main() { |
Just add r1.push_str(“world”);
1 | warning: unused variable: `r2` |
Slice Type
**slice**allows you to reference a contiguous sequence of elements in a collection rather than the entire collection. A slice is a kind of reference, so it does not have ownership.
1 | fn first_word(s: &String) -> usize { |
This function takes a string of words separated by spaces and returns the first word found in that string. If the function does not find a space in the string, then the entire string is one word, so it should return the whole string.
The first_word function has a parameter &String. Since we don’t need ownership, this is fine. But what should we return? We don’t actually have a way to get apartof the string. However, we can return the index of the end of the word, indicated by a space
Because we need to check each element of the String for spaces, we need to convert the String to a byte array using the as_bytes method:
let bytes = s.as_bytes();
Next, create an iterator over the byte array using the iter method:
for (i, &item) in bytes.iter().enumerate() {
Because the enumerate method returns a tuple, we can use pattern matching to destructure it. So in the for loop, we specify a pattern where i in the tuple is the index and &item in the tuple is a single byte. Since we get a reference to the collection element from .iter().enumerate(), we use & in the pattern.
However, there is a problem. We return an independent usize, but it is only a meaningful number in the context of the &String. In other words, because it is a value separate from the String, there is no guarantee that it will still be valid in the future.
string slice
string slice(string slice) is a reference to a portion of a String, and it looks like this:
1 | let s = String::from("hello world"); |
[starting_index…ending_index]
[starting_index…ending_index]
where starting_index is the first position of the slice, ending_index is thevalue after the last position of the slice.
If you want to start from index 0, you can omit the value before the two dots
1 | let s = String::from("hello"); |
If the slice includes the last byte of the String, you can also drop the trailing number
1 | let s = String::from("hello"); |
You can also omit both values to get a slice of the entire string
1 | let s = String::from("hello"); |
Note:**The indices of a string slice range must lie within valid UTF-8 character boundaries,**if you try to create a string slice from the middle of a multi-byte character, the program will exit with an error.
1 | fn main() { |
1 | thread 'main' panicked at src/main.rs:3:19: |
String indexing operations in Rusts[i]do not directly return characters because UTF-8 is a variable-length encoding. You need to usechars()orbytes()to iterate or manipulate string content. For example,chars()can iterate over Unicode characters, whilebytes()single bytes.
Rewrite the function to return a slice (the return type for a string slice can be written as: &str)
1 | fn first_word(s: &String) -> &str { |
Call
1 | fn main() { |
When you have an immutable reference to a value, you cannot obtain a mutable reference. Because clear needs to empty the String, it attempts to get a mutable reference. The println! after calling clear uses the reference from word, so this immutable reference must still be valid at that point. Rust does not allow the mutable reference in clear and the immutable reference in word to exist simultaneously, so compilation fails
String literals are slices
1 | let s = "Hello, world!"; |
Here the type of s is &str:**It is a slice pointing to a specific location in the binary program,**This is why string literals are immutable; &str is an immutable reference.
String slices as parameters
After learning that we can obtain slices from literals and Strings, we improved first_word, and here is its signature:
fn first_word(s: &String) -> &str {
A more experienced Rustacean would write the following signature, because it allows the same function to be used with both &String and &str values:
fn first_word(s: &str) -> &str {
If you have a string slice, you can pass it directly. If you have a String, you can pass a slice of the entire String or a reference to the String. This flexibility leverages_deref coercions_The advantage is that defining a function that takes a string slice instead of a String reference makes our API more general and does not lose any functionality:
1 | fn main() { |
&String can be implicitly converted to &str type
1 | fn main() { |
Other types of slices
String slices are for strings. However, there is also a more general slice type. Consider this array:
let a = [1, 2, 3, 4, 5];
Just as we might want to get a part of a string, we might also want to reference a part of an array. We can do this:
1 | let a = [1, 2, 3, 4, 5]; |
The type of this slice is &[i32]. It works the same way as string slices, by storing a reference to the first element of the collection and the total length of the collection. You can use this kind of slice on all other collections.
Slices are similar to arrays, butthe length of a slice cannot be known at compile time, so youcannot directly use the slice type。
1 | // Fix the errors in the code, do not add new lines of code! |
- A slice reference occupies2 wordssized memory space (from now on, for brevity, unless otherwise specified, we uniformly use slice to refer to a slice reference). The slice’sfirst word is a pointer to the data, and the second word is the length of the slice。
- The size of a word depends on the processor architecture; for example, on x86-64, a word is 64 bits or 8 bytes, so a slice reference is 16 bytes in size.
1 | fn main() { |
- A slice (reference) can be used toborrow a contiguous portion of an array, with the corresponding signature being &[T], which can be compared to the array signature [T; Length].
1 | fn main() { |
Struct
Defining a struct
requires using the struct keyword and providing a name for the entire struct.
Within the curly braces, define the name and type of each piece of data, which we callfields(field)
1 | struct User { |
Instantiation
1 | fn main() { |
You can mark an entire struct as mutable when instantiating it, but Rustdoes not allow us to designate a specific field of a struct as mutable.
1 | struct Person { |
Access
1 | fn main() { |
Once an instance of a struct is mutable, all fields in that instance are mutable.
Field Init Shorthand
When the field name and the variable name corresponding to the field value are the same, the field init shorthand can be used.
1 | fn build_user(email: String, username: String) -> User { |
Struct Update Syntax
Creating a new instance based on an existing struct instance
1 | fn main() { |
Using struct update syntax
1 | fn main() { |
Tuple Structs
Tuple structs have the meaning provided by the struct name but no specific field names, only the types of the fields.
Useful for giving a name to the entire tuple and making the tuple a different type from other tuples.
1 | struct Color(i32, i32, i32); |
Accessing such structs is the same as accessing tuples:
1 | struct Point(i32, i32); |
Unit-like structs with no fields
Unit-like structs with no fields, they are similar to ()
1 | struct AlwaysEqual; |
Ownership in structs
In the definition of the User struct in Listing 5-1, we used the owned String type rather than the &str string slice type. This is a deliberate choice because we wantThis struct owns all of its data,Therefore, as long as the entire struct is valid, its data is also valid.
A struct can store references to data owned by other objects, but doing so requires the use oflifetimes(lifetimes)
1 | struct User { |
Error: missing lifetime specifier
1 | error[E0106]: missing lifetime specifier |
Printing structs
1 |
|
Methods of a struct
1 |
|
- Defining methods within an impl block
- The first parameter of a method can be &self, or it can take ownership or a mutable borrow, just like other parameters
- Better code organization
Method call operators
In C/C++, there are two different operators for calling methods: . calls a method directly on an object, while -> calls a method on a pointer to an object, requiring dereferencing the pointer first. In other words, if object is a pointer, then object->something() is like (*object).something().
Rust does not have an operator equivalent to ->; instead, Rust has a feature calledautomatic referencing and dereferencing(automatic referencing and dereferencing)Method callsare one of the few places in Rustthat have this behavior。
Here’s how it works: when you call a method with object.something(), Rust automatically adds &, &mut, or * to the object so that it matches the method signature. In other words, these are equivalent:
1 | p1.distance(&p2); |
This automatic referencing behavior works becausemethods have a clear receiver———— the type of self. Given the receiver and method name, Rust can definitively determine whether the method is just reading (&self), making modifications (&mut self), or taking ownership (self).
Associated functions
1 | impl Rectangle { |
All functions defined within an impl block are calledassociated functions(associated functions)
Associated functions that are not methods are often used as constructors that return a new instance of the struct. These functions are typically named new, butnew is not a keyword。
Use the struct name and :: syntax to call this associated function: for example, let sq = Rectangle::square(3);. This function is in the struct’s namespace: the :: syntax is used for both associated functions and namespaces created by modules
Each struct is allowed to have multiple impl blocks.
Example
1 | struct Point { |
Enum
Define an enum
1 | enum IpAddrKind { |
By defining an IpAddrKind enum in the code to represent this concept and listing the possible IP address types, V4 and V6. This is called the enum’svariants(variants):
When creating an enum, you can use explicitintegervalues to set the enum variants.
1 | enum Number { |
Enum values
You can create instances of the two different members of IpAddrKind like this:
1 | let four = IpAddrKind::V4; |
Values in enum members can be extracted using pattern matching
1 | enum Message { |
Attaching data to enum variants
Using struct
1 | enum IpAddrKind { |
Simply use the enum and put data directly into each enum member instead of having the enum as part of a struct. The new definition of the IpAddr enum shows that both V4 and V6 members are associated with a String value:
1 | enum IpAddr { |
Wedirectly attach data to each member of the enum, so there is no need for an extra struct.
1 | enum IpAddr { |
Note that even though the standard library contains a definition for IpAddr, we can still create and use our own definition without conflict because we have not brought the standard library’s definition into scope.
Enums can embed multiple types
1 | enum Message { |
- Quit has no associated data.
- Move is similar to a struct containing named fields.
- Write contains a single String.
- ChangeColor contains three i32s.
Defining functions in enums
Structs and enums have another similarity: just asyou can use impl to define methods for structs, you can also define methods on enums. Here is a method called call defined on our Message enum:
1 | impl Message { |
The method body uses self to get the value on which the method is called. In this example, we created a variable m with the value Message::Write(String::from(“hello”)), and that is what self will be in the call method when m.call() runs.
1 |
|
The Option enum
Defined in the standard library, in the prelude
Rust does not have Null, but provides an enum similar to the concept of Null - Option
1 | enum Option<T> { |
Use
1 | let some_number = Some(5); |
When we have a Some value, we know that a value exists and is held within the Some. When we have a None value, in a sense it means the same thing as null: there is no valid value. So, Option
In short, because Option
1 | let x: i8 = 5; |
In fact, the error message means Rust doesn’t know how to add Option
In other words, before performing an operation on Option
To have a value that might be null, you must explicitly put it into an Option of the corresponding type
This is a well-considered design decision in Rust tolimit the proliferation of null valuesto increase the safety of Rust code.
Implementing a linked list with an enum
1 |
|
Pattern matching
The match control flow construct
Rust has an extremely powerful control flow operator called match that allows us toa valuewitha series of patternscompare and execute code based on the matching pattern
1 | enum Coin { |
matches!
matches! looks like match, but it can do something special
1 | fn main() { |
The code below will error because enums do not implement PartialEq by default, so they cannot be compared with ==
1 | enum MyEnum { |
Change to
1 | enum MyEnum { |
Pattern that binds values
Another useful feature of match arms is that they can bind to parts of the values that match the pattern. This is how you extract values out of enum variants.
1 | // This lets us see the name of the state immediately |
Matching Option
1 | fn main(){ |
Match must be exhaustive
Usethe _ placeholder(must be placed last)
1 | match dice_roll { |
if let concise control flow
Handle cases where you only care about one pattern and ignore the rest
1 | let config_max = Some(3u8); |
Using if let
1 | let config_max = Some(3u8); |
In this example, the pattern is Some(max), and max binds to the value inside Some. Then you can use max within the if let block, just like in the corresponding match arm. The code in the if let block does not execute when the pattern does not match.
Giving up the possibility of exhaustiveness
You can think of if let as syntactic sugar for match
Using with else
1 | let mut count = 0; |
Variable shadowing in pattern matching
1 | fn main() { |
Workspace, Package,Crate,Module
- Package(Packages): A feature of Cargo that lets you build, test, and share crates.
- Crate: A tree of modules that forms a library or binary project.
- module(Modules) anduse: Allows you to control the privacy of scopes and paths.
- path(path): A way to name items such as structs, functions, or modules
| Level | Name | Description | Example |
|---|---|---|---|
| 📦 Package | “Package”, a unit managed by Cargo (a project) | Contains one or more crates, andCargo.toml | the entire project directory |
| 🧩 Crate | “Single compilation unit”, can be a library or executable | Eachrustccompilation is a crate | src/lib.rsorsrc/main.rs |
| 📁 Module | Modules, organizing the structure of code within a crate | Similar to C++ namespaces or Python modules | mod network; |
| 🛣️ Path | Paths used to access modules, structs, and functions | Similar tostd::io::Write | crate::foo::bar() |
Types of Crates
- binary
- library
Each crate is an independent compilation unit.
The Rust compiler compiles only one crate at a time.
Crate Root
Is the source code file from which the Rust compiler starts, forming the root module of the crate.
The crate root isThe starting file when the Rust compiler builds a crate,
It determinesthe module tree’s top-level structure.
For example
Suppose we have the simplest project:
1 | my_project/ |
Both of these files arepossible crate roots。
| file | crate type | purpose |
|---|---|---|
src/main.rs | binary crate root | program entry point (must containfn main()) |
src/lib.rs | library crate root | library entry point (defines the module structure of the library) |
compiler’s working logic
When runningcargo build:
Cargo will:
- Read
Cargo.toml; - Find thecrate roots(e.g.,
src/main.rs、src/lib.rs); - The compiler is invoked for each crate root, starting from this file to build the entire crate’s module tree.
A Package
- Contains one Cargo.toml, which describes how to build these Crates
- Can only contain 0-1 library crate
- Can contain any number of binary crates
- But mustcontain at least one crate(library or binary)
Example:
1 | my_package/ |
No special configuration is needed in Cargo.toml. Run:
1 | # main.rs |
workspace
A workspace is a collection of multiple packages, each of which can have its own library crate. Example:
1 | my_workspace/ |
workspace declaration toml
1 | [workspace] |
Cargo conventions
src/main.rs
- crate root of a binary crate
- crate name same as package name
src/lib.rs
- package contains a library crate
- crate root of a library crate
- crate name same as package name
a package can contain both src/main.rs and src/lib.rs
a package can have multiple binary crates:
files placed under src/bin, each file is a separate binary crate
define modules to control scope and privacy
Module
- group crates within a crate
- Control the privacy of items: public, private
Create a module
- The mod keyword
- Can be nested
- Can contain definitions of other items (struct, enum, constants, traits, functions, etc.)
1 | mod front_of_house { |
Module tree of the above code
1 | crate |
src/main.rs and src/lib.rs are called crate roots
- The content of either of these files forms a module named crate, located at the root of the entire module tree
- The entire module tree is under the implicit crate module
Paths
To find an item in Rust’s module system, you need to usepaths
- Absolute pathStarts from the crate root, using the crate name or the literal crate
- Relative pathStarting from the current module, use self, super, or the identifier of the current module
A path consists of at least one identifier, with identifiers separated by ::
src/lib.rs
1 | mod front_of_house { |
Privacy boundary
- All items in Rust (functions, methods, structs, enums, modules, constants)Are private by default
- Parent modules cannot access private items of child modules
- Can be used within child modulesAllItems in ancestor modules
- Sibling modulesCanCall each other
- The pub keyword can mark something as public
super
super: used to access content in the parent module path, similar to … in the file system
1 | fn serve_order() {} |
pub struct
Put pub before a struct:
- The struct is public
- Fields of a struct are private by default,Adding pub before a field makes it public
File name: src/lib.rs
1 | mod back_of_house { |
pub enum
Put pub before an enum:
- The enum is public
- Variants of an enum are also public by default (no need to add the pub keyword)
The use keyword
Bringing paths into scope with the use keyword
1 | mod front_of_house { |
- Still following privacy rules
- Using use to specify relative paths
Idiomatic use of use
- Functions: bring the parent module of the function into scope (specify to parent)
File name: src/lib.rs
1 | mod front_of_house { |
- Structs, enums, others: specify the full path (specify to itself)
File name: src/main.rs
1 | use std::collections::HashMap; |
Bringing two items with the same name into scope
File name: src/lib.rs
1 | use std::fmt; |
Providing a new name with the as keyword
File name: src/lib.rs
1 | use std::fmt::Result; |
Re-exporting with pub use
After bringing a path (name) into scope with use,the name is private within this scope
If you wantexternal modules to also access through your paththat item (such as a function, struct, module, etc.), you can use pub use for re-exporting
pub use: re-export
- Bringing items into scope
- The item can beexternal codebrought into their scope
pub(in Crate)
Sometimes we want an item to be visible only to a specific crate, then we can use the pub(in Crate) syntax.
Example:
1 | pub mod a { |
Using external packages
Add the dependent package in Cargo.toml
Use ‘use’ to bring specific items into scope
- The standard library std is also treated as an external package, but you don’t need to modify Cargo.toml to include std
- You need to use ‘use’ to bring specific items from std into the current scope
Use nested paths to clean up large use statements
Common part of the path::{Differing part of the path}
1 | use std::{cmp::Ordering,io}; |
If one of the two use paths is a subpath of the other
Use self
1 | //use std::io; |
Wildcard *
Using * brings all public items in the path into scope
Use with caution
Use cases:
- prelude
- Testing, to bring all code under test into the tests module
Splitting modules into different files
When defining a module, if the module name is followed by “;” instead of a code block
- Rust will load the content from a file with the same name as the module
- The module tree does not change
Example:
File name: src/lib.rs
1 | mod front_of_house; |
Example: Declare frontof_house module, whose content will be located in _src/front_of_house.rs
_src/front_of_house.rs_will get front_of_house module’s definition content, as shown in the example.
Filename: src/front_of_house.rs
1 | pub mod hosting { |
Example:
Define modulefront_of_house
Method 1: Write module content directly in the file
File:src/front_of_house.rs
1 | pub mod hosting { |
- Here
front_of_housemodule directly defines the submodulehosting。 - Advantage: Simple, can be written this way when the module is small.
Method 2: Split the submodule into a separate file
- In
front_of_house.rsonly declare submodules:
1 | pub mod hosting; |
- Create directory and file structure:
1 | src/ |
- In
hosting.rswrite the actual content:
1 | pub fn add_to_waitlist() {} |
- At this point,
hostingthe content of is completely placed inhosting.rs。 - Benefit: When modules are large, splitting files makes them clearer and more maintainable.
Traditional style
Before Rust Edition 2018, use the following style
1 | src/ |
Common collections
Vector
Vec
Create a vector
Vec::new function
let v:Vec
Creating a Vec with initial values
let v = vec![1,2,3];
1 | let arr: [u8; 3] = [1, 2, 3]; |
Adding elements
1 | let mut v:Vec<i32>=Vec::new(); |
Deleting a Vector
Like any other struct, a vector is freed when it goes out of scope
1 | { |
Reading values from a Vector
- Indexing method
- get method
1 | let v = vec![1, 2, 3, 4, 5]; |
- When accessing a value beyond the array elements using the indexing method, the program will panic
- When accessing with the get method, the program returns None
1 | fn main() { |
Reference borrowing rules
When we obtain an immutable reference to the first element of a vector and try to add an element at the end of the vector, attempting to reference that element later in the function will not work
1 | let mut v = vec![1, 2, 3, 4, 5]; |
Why does the reference to the first element care about changes at the end of the vector? The reason this cannot be done is due to how vectors work: when adding a new element at the end of a vector, if there is not enough space to store all elements contiguously,it may require allocating new memory and copying the old elements to the new space. At this point,the reference to the first element points to deallocated memory. Borrowing rules prevent the program from falling into this situation。
Iterating over a Vector
1 | let v = vec![100, 32, 57]; |
We can also iterate over mutable references to each element of a mutable vector in order to change them
1 | let mut v = vec![100, 32, 57]; |
To modify the value pointed to by a mutable reference, you must use the dereference operator (*) to obtain the value in i before using the += operator.
Extending a Vector
A Vec can be extended using the extend method
1 | fn main() { |
Use enum to store multiple data types in Vec
Define an enum to store different types of data in a vector
1 | enum SpreadsheetCell { |
Use trait objects to store multiple data types in Vec
1 | trait IpAddr { |
Convert type X (using From/Into traits) to Vec
1 | fn main() { |
Slice
Similar to String slices, Vec can also use slices. If Vec is mutable, its slice is immutable or read-only, and we can obtain a slice via &.
In Rust,passing slices as parameters is a more common practicefor example, when a function only needs read access, passing slices &[T] / &str of Vec or String is more appropriate.
1 | fn main() { |
capacity
Capacity is the already allocated memory space used to store future elements added to the Vec. Length (len) is the number of elements currently stored in the Vec. When adding a new element would cause the length to exceed the existing capacity, the capacity automatically grows: Rust reallocates a larger memory block and copies the previous Vec over, thus a new memory allocation occurs here.
If this code occurs frequently, the frequent memory allocations will significantly impact system performance. The best approach is to pre-allocate sufficient capacity to minimize memory allocations.
1 | fn main() { |
As long as the From trait is implemented for Vec
String
- Strings are collections of bytes
- UTF-8 encoding
- Some methods can parse bytes into text
What is a string?
At the core language level of Rust, there is only one string type: the string slice str (or &str)
- String slice: a reference to a UTF-8 encoded string stored elsewhere
- String literal: stored in the binary file, also a string slice
1 | fn main() { |
In fact, String is asmart pointer, which is stored as a struct on the stack and points to the underlying string data stored on the heap.
The smart pointer struct stored on the stack consists of three parts: a pointer pointing to the byte array on the heap, the used length, and the allocated capacity (the used length is less than or equal to the allocated capacity; when the capacity is insufficient, memory space is reallocated).
1 | use std::mem; |
Other string types
String type
From the standard library, also UTF-8 encoded
String vs Str: owned or borrowed variants
Other string types
OsString / OsStr
1 | use std::ffi::OsString; |
Mainly used for handling system strings such as file paths, command-line arguments, environment variables, etc.
CString / CStr
1 | use std::ffi::CString; |
Fromstd::ffi, used forinterfacing with C language。
Characteristics:
- Ends withNUL terminator (
\0) string. CStringOwns the data, guarantees no internal NUL.CStris an immutable borrow, similar to&str。
CStringNo similarpush_stror index access methods to modify content; once created, itcannot modify internal bytes。
Conversion between String and &str
Using to_string()
1 | fn main() { |
Using String::from()
1 | fn main() { |
String escaping
1 | fn main() { |
Sometimes there are many characters to escape, and we want a more convenient way to write strings: raw string.
1 | fn main() { |
Here r#" marks the start of a raw string, and "# marks the end of a string. If there is still ambiguity, you can continue adding #
Create a new String
String::new()
let mut s = String::new();
Using to_string()
1 | let data = "initial contents"; |
You can also use string::from()
let s = String::from(“initial contents”);
Updating a String
push_str()
1 | let mut s = String::from("foo"); |
The push_str() method does not take ownership of the parameter
1 | let mut s1 = String::from("foo"); |
push()
The push method is defined to take a single character as a parameter and append it to the String
1 | let mut s = String::from("lo"); |
Concatenating strings with +
Can only concatenateStringwith&strtypes, andStringownership of is moved during this process
1 | let s1 = String::from("Hello, "); |
The + operator uses the add function, whose signature looks like this:
1 | fn add(self, s: &str) -> String { |
This is not the actual signature in the standard library;
But the type of &s2 is &String, not &str. So why does it still compile?
The reason &s2 can be used in the add call is that &String can becoerced(coerced) into &str. When the add function is called, Rust uses a technique calledDeref coercion(deref coercion), which you can think of as turning &s2 into &s2[…].
format!
1 | let s1 = String::from("tic"); |
The code generated by the format! macro uses references, soit does not take ownership of any arguments
s1,s2,s3are all borrowed.
Rust willcopy the content into the new string(allocated on the heap):
- The original string is unaffected.
- This copy is necessary because a new independent string is created.
access by indexString
Rust’s stringsdo not support indexing syntaxaccess
internal representation
String is a Vec
let hello = String::from(“Hola”);
Here, the value of len is 4, which means the Vec storing the string “Hola” is 4 bytes long: each letter’s UTF-8 encoding takes up one byte.
(Note that the first letter in this string is the Cyrillic letter Ze, not the Arabic numeral 3.)
1 | let hello = String::from("Здравствуйте"); |
When asked how long this character is, someone might say 12. However, Rust’s answer is 24. This is the number of bytes needed to encode “Здравствуйте” in UTF-8, because each Unicode scalar value requires two bytes of storage.
Therefore, indexing into a string’s byte values does not always correspond to a valid Unicode scalar value
byte string
byte string or byte array
1 | use std::str; |
Bytes, Scalar Values, Grapheme Clusters
Rust has three ways to view strings:
- Bytes
1 | fn main(){ |
Output
1 | 224 |
- Scalar Values
1 | fn main(){ |
Output
1 | न |
- Grapheme Clusters (closest to what are called letters)
Obtaining them is more complex; the standard library no longer provides this. A third-party library is needed.
One reason Rust does not allow indexing into a String:
Indexing operations should take constant time O(1)
But a String cannot guarantee this: it needs to traverse all the content to determine how many valid characters there are
Slicing a String
The type that string indexing should return is ambiguous: byte values, characters, grapheme clusters, or a string slice. Therefore, if you really want to use indexing to create a string slice, Rust requires you to be more explicit. To be more explicit about indexing and indicate that you need a string slice, instead of using [] with a single index, you can use [] with a range to create a string slice containing specific bytes:
1 | let hello = "Здравствуйте"; |
s will be a &str that contains the first four bytes of the string. Earlier, we mentioned that these letters are each two bytes long, so this means s will be “Зд”.
What happens if you try to get &hello[0…1]? The answer is: Rust will panic at runtime
Thereforeslicing must not cross string boundaries
Iterating over Strings
You cannot access a character in a string by indexing, but you can use slicing with &s1[start…end], provided that start and end fall exactly on character boundaries.
- For scalar values: the chars() method
- For bytes: the bytes() method
- Regarding grapheme clusters: it’s very complex, the standard library does not provide it, and neither Chinese nor English need to focus on character clusters; using chars is sufficient to iterate over Chinese and English.
1 | fn main() { |
HashMap
HashMap uses the SipHash 1-3 hashing algorithm by default, which is very effective against HashDos attacks. In terms of performance, if your keys are medium-sized, this algorithm works well, but if your keys are small (e.g., integers) or large (e.g., strings), you need to use other algorithms provided by the community to improve performance.
The hash table algorithm is based on Google’sSwissTable, you can find the C++ implementation athere.
Creating HashMap<K,V>
Creating an empty HashMap: the new() function
1 | use std::collections::HashMap; |
- HashMap is used less frequently,not in the Prelude
- the standard library provides limited support for it,There is no built-in macro to create a HashMap
- Data is stored on the heap
- Homogeneous, meaning K must be one type and V another type
1 | use std::collections::HashMap; |
The collect method creates a HashMap
The collect method can gather data into a variety of collection types
1 | use std::collections::HashMap; |
If team names and initial scores are in two separate vectors, you can usezipmethod tocreate an iterator of tuples, where “Blue” pairs with 10, and so on. Then you can use the collect method to convert this iterator of tuples into a HashMap
HashMap and Ownership
- For types that implement the Copy trait (such as i32), values are copied into the HashMap
- For values that own their data (such as String), the values are moved and ownership transfers to the HashMap
- If you insert references into a HashMap, the values themselves are not moved, but the referenced values must remain valid for as long as the HashMap is valid
Accessing values in a HashMap
The get method
1 | use std::collections::HashMap; |
Iterating over a HashMap with a for loop
1 | use std::collections::HashMap; |
This will print each key-value pair inan arbitrary order:
1 | Yellow: 50 |
Indexing and the get method
1 | use std::collections::HashMap; |
Updating a HashMap
Overwriting a value
1 | use std::collections::HashMap; |
This prints {“Blue”: 25}. The original value 10 is overwritten
Inserting only if the key has no value
Using the entry methodInserting only if the key does not have a value
1 | use std::collections::HashMap; |
Output:
1 | Entry(VacantEntry("Yellow")) |
The entry method
Check whether the specified K corresponds to a V
Takes K as a parameter, returns enum Entry: indicates whether the value exists
Entry’sor_insert() method:
Returns:
- If K exists, returns a mutable reference to the corresponding V
- If K does not exist, inserts the method parameter as a new value for K, returns a mutable reference to this value
Update a value based on the old value
1 | use std::collections::HashMap; |
Here, or_insert returns a mutable reference pointing to the value corresponding to the key just inserted by the entry method
Hash function
HashMap uses a hash function called SipHash by default, which can resist Denial of Service (DoS) attacks involving hash tables1However, this is not the fastest algorithm available, but the performance cost is worth it for higher security. If performance monitoring shows this hash function is too slow for your needs, you can specify a different_hasher_to switch to another function. A hasher is a type that implements the BuildHasher trait. You don’t need to implement your own hasher from scratch; there are many libraries on crates.io providing hashers for common hash algorithms.
HashMap key restrictions
Any type that implements the Eq and Hash traits can be used as a key for HashMap, including:
- bool (though rarely used, as it can only represent two kinds of keys)
- int, uint, and their variants, such as u8, i32, etc.
- String and &str (Hint: when the key of a HashMap is of type String, you can actually use &str with the get method for querying)
Note that f32 and f64 do not implement Hash, becausefloating-point precisionissues prevent them from being compared for equality.
If all fields of a collection type implement Eq and Hash, then the collection type automatically implements Eq and Hash. For example, Vec
1 | // Hint:`derive`is a good way to implement some common traits |
Capacity
Regarding capacity, we have detailed it in the previous Vector section, and HashMap can also adjust capacity: you can initialize with a specified capacity via HashMap::with_capacity(uint), or use HashMap::new(), which provides a default initial capacity.
1 | use std::collections::HashMap; |
Ownership
For types that implement the Copy trait, such as i32, the value of that type will be copied into the HashMap. For types with ownership, such as String, ownership of their values will be transferred to the HashMap.
1 | use std::collections::HashMap; |
Third-party Hash Libraries
At the beginning, we mentioned that if the performance of the existing SipHash 1-3 cannot meet the requirements, alternative algorithms provided by the community can be used.
For example, the usage of one such community library is as follows:
1 | use std::hash::BuildHasherDefault; |
Error Handling
In most cases, errors are indicated at compile time and handled.
Classification of Errors
- Recoverable:
For example, file not found; can try again.
- Unrecoverable
bug, e.g., accessing an index out of bounds
Rust does not have an exception mechanism like C++/Java
- Recoverable errors:Result<T,E>
- Unrecoverable: panic! macro
Unrecoverable errors and panic!
When the panic! macro executes
- the program prints an error message
- unwinds, cleans up the call stack
- exits the program
panic = ‘abort’
By default, when a panic occurs
- the program unwinds the call stack (heavy workload)
Rust walks back up the call stack, cleaning up data from each function encountered
- or immediately aborts the call stack
without cleanup, directly stops the program; memory needs to be cleaned up by the OS
To make the binary smaller, change the setting from “unwind” to “abort”
1 | [profile.release] |
Panic in your own code
1 | fn main(){ |
So in dependent code: external panic
1 | fn main(){ |
Output
1 | Compiling demo v0.1.0 (C:\Users\cauchy\Desktop\rust\demo) |
Locate the problematic code by calling the backtrace information of the panic! function
Run the code using the following command (Windows cmd)
1 | set RUST_BACKTRACE=1 && cargo run |
Windows Poweshell
1 | $Env:RUST_BACKTRACE=1 -and (cargo run) |
Under Linux
1 | export RUST_BACKTRACE=1 && cargo run |
More detailed information
RUST_BACKTRACE=full
To obtain a backtrace with debug information, debug symbols must be enabled (without --release)
Result enum and recoverable errors
1 | enum Result<T, E> { |
T represents the type of data in the Ok member returned on success,
And E represents the type of error in the Err member returned on failure
Result and its variants are also brought into scope by the prelude
Open file
1 | use std::fs::File; |
Match different errors
1 | use std::fs::File; |
Useunrap_or_else()andclosure
1 | use std::fs::File; |
unwrap
a shortcut method for match expressions
1 | use std::fs::File; |
equivalent to
1 | use std::fs::File; |
- if the Result is Ok, return the value inside Ok
- if the Result is Err, call the panic! macro
expect
unwrap with a custom error message
1 | use std::fs::File; |
propagating errors
propagate the error to the caller
custom implementation
1 | // src/main.rs |
? operator
1 | use std::fs::File; |
? is defined to work in exactly the same way as the match expression that handles Result values defined in the custom error propagation example.
if the Result value is Ok, this expression will return the value inside Ok and the program will continue executing.
if the value is Err, the value inside Err will be returned as the return value of the entire function,as if usingreturnThe keyword is the same, thus the error value is propagated to the caller.
1 | use std::fs::File; |
? and the from function
The from function on the trait std::convert::From
- Used for conversion between errors
- Errors applied by ? are implicitly handled by the from function
- When ? calls the from function:
The error type it receives is converted to the error type defined by the current function’s return type
Used for:Returning the same error type for different error causes
As long as each error type implements the from function to convert to the returned error type
Can be used directly after ?Chained method callsTo further shorten the code
File name: src/main.rs
1 | use std::fs::File; |
Custom Implementation
1 | use std::fmt; |
? and the main function
The return type of the main function is ()
1 | use std::error::Error; |
map,and_then
map example — modify theOkvalue inside
Only process theOkvalue and return a newResult, without changing the error type
1 | let x: Result<i32, &str> = Ok(2); |
What if it’s an error?
1 | let x: Result<i32, &str> = Err("error"); |
and_then example — chaining Result operations
and_thenProcess theOk, and continue returning aResult(chained logic), suitable for chaining multiple fallible steps:
1 | fn sq_then_to_string(x: i32) -> Result<String, &'static str> { |
If an error occurs, automatically stop chain computation:
1 | let result = Err("bad").and_then(sq_then_to_string); |
Example:
1 | use std::num::ParseIntError; |
When to panic!
General principle
- When defining afunction that might fail, prefer returning Result
- otherwise panic!
Scenarios
- Demonstrating some concepts: unwrap
- Prototype code: unwrap, expect
- Testing: unwrap, expect
Sometimes you have more information than the compiler
- You can be sure the Result is Ok:unwrap
1 | use std::net::IpAddr; |
Calling your code with meaningless parameter values:panic!
Calling external uncontrolled code that returns an illegal state, which you cannot fix:panic!
If failure is expected:Result
When your code operates on values, it should first validate those values:panic! (assert!)
Create custom types for validation
One way to implement this is to parse the guess as an i32 instead of only u32, to allow negative inputs, and then check if the number is within range:
1 | loop { |
The if expression checks whether the value is out of range, tells the user what went wrong, and calls continue to start the next iteration of the loop, asking for another guess. After the if expression, you can compare guess with the secret number, knowing that guess is between 1 and 100.
Instead, we can create a new type and put the validation in a function that creates instances of it, rather than repeating these checks everywhere. This way, it is safe to use the new type in function signatures and trust the values they receive. The example shows a way to define a Guess type that only creates instances of Guess when the new function receives a value between 1 and 100:
1 | pub struct Guess { |
We implement a method named value that borrows self, has no other parameters, and returns an i32. Such methods are sometimes called_getter_because their purpose is to return the data in the corresponding field. Such a public method is necessary because the value field of the Guess struct is private. The private value field is important so that code using the Guess struct is not allowed to set value directly: callersmustuse the Guess::new method to create an instance of Guess, which ensures there is no Guess whose value has not passed the condition check in the Guess::new function.
Thus, a function that accepts (or returns) a number between 1 and 100 can be declared to accept (or return) an instance of Guess instead of an i32, and its body does not need any additional checks.
Using Result in fn main
A typical main function looks like this:
1 | fn main() { |
In fact, the main function can also return a Result type: if an error occurs inside the main function, that error will be returned and a debug message about the error will be printed.
1 | use std::num::ParseIntError; |
Generics
Define generics in functions
Find the maximum value in a vec
1 | fn largest_i32(list: &[i32]) -> i32 { |
Using generics
1 | fn largest<T>(list: &[T]) -> T { |
Run
1 | Compiling demo v0.1.0 (C:\Users\cauchy\Desktop\rust\demo) |
Simply put, this error indicates that the function body of largest cannot apply to all possible types of T
Because the function body needs to compare values of type T, but it can only be used for types we know how to order. To enable comparison, the std::cmp::PartialOrd trait defined in the standard library can implement comparison functionality for types.
Modification:
1 | fn largest<T>(list: &[T]) -> &T |
Define generics in structs
Filename: src/main.rs
1 | struct Point<T> { |
Define generics in enums
1 | enum Option<T> { |
Enums can also have multiple generic types.
1 | enum Result<T, E> { |
Generics in method definitions
Filename: src/main.rs
1 | struct Point<T> { |
- Place T after the impl keyword to indicate implementing methods on type T: impl
Point - Implement methods only for a specific type: impl Point
1 | struct Point<T> { |
- The generic type parameters in a struct can differ from those in its methods
1 | struct Point<X1, Y1> { |
Const generics
Generics implemented for types abstract over different types, but is there a generic for values? The answer is const generics.
1 | struct ArrayPair<T, const N: usize> { |
Currently, const generic parameters can only use arguments of the following forms:
A single const generic parameter
A literal (i.e., integer, boolean, or character).
A concrete const expression (the expression cannot contain any generic parameters).
Const generics can also help us avoid some runtime checks and improve performance.
1 | pub struct MinSlice<T, const N: usize> { |
<T, const N: usize> is part of the struct type, just like array types, meaning different lengths result in different types:Array<i32, 3> and Array<i32, 4> are different types.
1 |
|
Fill in the blank.
1 | // Fill in the blank. |
Answer:
1 | fn print_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) { |
Sometimes we want tolimit the amount of memory a variable occupies., for example in embedded environments, the third form of const generic parameters, const expressions, is very suitable:
The following code uses a feature that requires the nightly compiler
1 |
|
Performance of Generic Code
Rust implements generics in such a way that code using generic type parameters has no speed penalty compared to using concrete types.
Rust achieves this efficiency throughmonomorphization(monomorphization) of generic code at compile time.Monomorphization is the process of converting generic code into specific code by filling in the concrete types used at compile time.
1 | //fn main(){ |
At compile time, Rust expands the generic Option into Option
Trait: Defining Shared Behavior
A trait tells the Rust compiler about functionality a particular type has and can share with other types
Defining a Trait
Group method signatures together to define a set of behaviors necessary to accomplish a purpose
- Keyword: trait
- Only method signatures, no concrete implementation
File name: src/lib.rs
1 | pub trait Summary { |
Implementing a trait on a type
Similar to implementing methods for a type
Difference: impl Xxxx for Tweet
File: src/lib.rs
1 | pub struct NewsArticle { |
File src/main.rs
1 | use demo::{Summary,Tweet}; |
demo is the name of the [package] item in the Cargo.toml file
Constraints for implementing a trait
The prerequisite for implementing a trait on a type is:
- The type itselforthis trait is defined in the local crate
Cannot implement an external trait for an external type
This restriction is part of a property of the program calledcoherence(coherence), or more specifically theorphan rule(orphan rule), named for the absence of a parent type. This rule ensures that code written by others won’t break your code, and vice versa. Without this rule, two crates could implement the same trait for the same type, and Rust would have no way to know which implementation to use.
In short:If neither the type nor the trait is yours,then you are not allowed to add an impl for them。
Default implementations of functions in a trait
Filename: src/lib.rs
1 | pub trait Summary { |
Default implementations are allowed to call other methods in the same trait,even if those methods don’t have default implementations
1 | pub trait Summary { |
Note: It is not possible to call the default implementation from an overriding implementation of the same method
Traits as parameters
impl trait syntax:
Applies to simple cases; it’s syntactic sugar for trait bounds.
1 | pub fn notify(item: &impl Summary) { |
trait bound syntax:
Applies to complex cases.
1 | pub fn notify<T: Summary>(item: &T) { |
Comparison
1 | pub fn notify(item1: &impl Summary, item2: &impl Summary) {} |
This works when item1 and item2 are allowed to be different types (as long as both implement Summary). But what if you want to force them to be the same type? That’s only possible with trait bounds:
1 | pub fn notify<T: Summary>(item1: &T, item2: &T) {} |
Specifying multiple trait bounds with +
1 | pub fn notify(item: &(impl Summary + Display)) {} |
Simplifying trait bounds with where
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
Using where clauses
1 | fn some_function<T, U>(t: &T, u: &U) -> i32 |
Returning types that implement a trait
You can also use impl Trait syntax in return positions to return a type that implements a trait:
1 | fn returns_summarizable() -> impl Summary { |
However, this only works forreturning a single type. For example, this code specifies the return type as impl Summary, but returning either NewsArticle or Tweet won’t work:
1 | fn returns_summarizable(switch: bool) -> impl Summary { |
Here, it attempts to return either NewsArticle or Tweet. This won’t compile due to limitations in how impl Trait works.
You can use dyn trait objects
1 | struct Sheep {} |
Trait objects, using trait objects in arrays
1 | trait Bird { |
&dyn and Box
1 | trait Draw { |
Static dispatch and dynamic dispatch
1 | trait Foo { |
Use trait bounds to fix the largest function
File name: src/main.rs
1 | fn largest<T: PartialOrd + Copy>(list: &[T]) -> T { |
If you don’t want to restrict the largest function to only types that implement the Copy trait, you can specify Clone instead of Copy in the trait bounds for T. Then clone each value of the slice so that the largest function owns them. Using the clone function means that for types like String that own heap data, it will potentially allocate more heap space, and heap allocation can be quite slow when dealing with large amounts of data.
1 | fn largest<T: PartialOrd + Clone>(list: &[T]) -> T { |
Or have largest directly return a reference
1 | fn largest<T: PartialOrd + Clone>(list: &[T]) -> &T { |
Conditionally implement methods using trait bounds
1 | use std::fmt::Display; |
Implementing a trait on all types that satisfy a trait bound is calledBlanket implementations
For example:
1 | impl<T: Display> ToString for T { |
Because the standard library has these blanket implementations, we can call the to_string method defined by ToString on any type that implements the Display trait. For example, we can convert an integer to its corresponding String value because integers implement Display:
let s = 3.to_string();
Derive macro implementations
We can use the #[derive] attribute to derive some traits, for which the compiler will automatically provide default implementations. This is very convenient for everyday code development. For example, the commonly used Debug trait is obtained directly through derivation without needing to implement it manually.
1 | // `Centimeters`, a tuple struct that can be compared for size |
Lifetimes
- Every reference in Rust has its own lifetime
- Lifetimes: the scope for which a reference is valid
- In most cases: lifetimes are implicit and can be inferred
- When the lifetimes of references might be related in different ways: manually annotate lifetimes
The goal of lifetimes is:Avoiding Dangling References
Attempting to use a reference to a value that has gone out of scope will fail
1 | { |
Borrow Checker
The Rust compiler has aborrow checker(borrow checker), which compares scopes to ensure all borrows are valid.
1 | { |
Here, the lifetime of r is marked as 'a and the lifetime of x is marked as 'b. As you can see, the inner 'b block is much smaller than the outer lifetime 'a. At compile time, Rust compares the sizes of these two lifetimes and finds that r has lifetime 'a, but it references an object with lifetime 'b. The program is rejected because lifetime 'b is smaller than lifetime 'a: the referenced object lives for a shorter time than its referencer.
Generic Lifetimes in Functions
1 | fn longest(x: &str, y: &str) -> &str { |
Execute
1 | error[E0106]: missing lifetime specifier |
Annotating Lifetimes
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { |
Indicates that the lifetime of the return value is the overlap of the lifetimes of the two references passed as parameters.
Lifetime Annotation Syntax
Lifetime annotations do not change the length of the lifetimes of references
When generic lifetime parameters are specified, the function can accept references with any lifetime
Lifetime annotations:Describes the relationship between the lifetimes of multiple references, but does not affect the lifetimes.
Lifetime parameter name: starts with ', usually all lowercase and very short; many people use 'a.
Position of lifetime annotations
After the & symbol of a reference, use a space to separate the annotation from the reference type
1 | &i32 // Reference |
Generic lifetime parameters are declared in:Inside the <> between the function name and the parameter list
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { |
Its actual meaning is that the lifetime of the reference returned by the longest function is consistent with the smaller of the lifetimes of the references passed into the function.
Remember that when specifying lifetime parameters in a function signature, we are not changing the lifetimes of any passed-in or returned values, but rather indicating that any values that do not satisfy this constraint will be rejected by the borrow checker. Note that the longest function does not need to know exactly how long x and y will live, but only that there is some scope that can be substituted for 'a that will satisfy this signature.
When concrete references are passed to longest, the specific lifetime substituted for 'a is the overlapping part of the scopes of x and ythe overlapping part. In other words, the concrete lifetime of the generic lifetime 'a is equivalent to the smaller of the lifetimes of x and ythe smaller one. Because we annotated the returned reference with the same lifetime parameter 'a, the returned reference is guaranteed to remain valid until the end of the shorter lifetime between x and y.
Using
1 | fn main() { |
In this example, string1 is valid until the end of the outer scope, string2 is valid in the inner scope, and result references a value that is valid until the end of the inner scope. The borrow checker approves this code; it compiles and runs, printing “The longest string is long string is long”.
Modify
1 | fn main() { |
Program runs with error
The error indicates that to ensure result is valid in println!, string2 needs to be valid until the end of the outer scope. Rust knows this because the parameters and return value of the (longest) function all use the same lifetime parameter 'a.
Deep Understanding of Lifetimes
- The correct way to specify lifetime parameters depends on the specific functionality of the function implementation
If the implementation of the longest function is modified to always return the first parameter instead of the longest string slice, there is no need to specify a lifetime for parameter y. The following code will compile:
Filename: src/main.rs
1 | fn longest<'a>(x: &'a str, y: &str) -> &'a str { |
- When returning a reference from a function, the lifetime parameter of the return value needs to match the lifetime parameter of one of the parameters.
If the returned referencedoes notpoint to any parameter, then the only possibility is that it points to a value created inside the function, which will be a dangling reference because it will go out of scope when the function ends.
1 | fn longest<'a>(x: &str, y: &str) -> &'a str { |
Compilation error
In summary, lifetime syntax is used toassociate the lifetimes of multiple function parameters with that of its return value. Once they form a certain association, Rust has enough information to allow memory-safe operations and prevent actions that would create dangling pointers or violate memory safety.
Lifetime annotations in struct definitions
A struct can include:
- Self-owned types
- References: need to add lifetime annotations on each reference
Filename: src/main.rs
1 | struct ImportantExcerpt<'a> { |
Indicates that the struct’s lifetime is the same as that of its member part.
Lifetime elision
Lifetime Elision
The patterns encoded in Rust’s reference analysis are calledlifetime elision rules(lifetime elision rules)
These are not rules that programmers need to follow; they are a set of specific scenarios where the compiler considers that if the code matches these scenarios, explicit lifetime annotations are unnecessary.
The lifetimes of function or method parameters are calledinput lifetime(input lifetimes), and the lifetime of the return value is called theoutput lifetime(output lifetimes)。
The compiler usesthree rulesto determine when references do not need explicit annotations. The first rule applies to input lifetimes, and the latter two rules apply to output lifetimes. If the compiler checks these three rules and there are still references whose lifetimes cannot be determined, the compiler will stop and generate an error. These rulesapply tofndefinitions, andimplblocks。
- Rule 1:Each parameter that is a reference type has its own lifetime
- Rule 2:If there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters
- Rule 3:If there are multiple input lifetime parameters, but one of them is &self or &mut self (because it is a method), then the lifetime of self is assigned to all output lifetime parameters
Example:
Suppose we ourselves are the compiler.
1 | fn first_word(s: &str) -> &str { |
Then the compiler applies the first rule, which isEach reference parameter has its own lifetime。
1 | fn first_word<'a>(s: &'a str) -> &str { |
For the second rule, it applies because there is exactly one input lifetime parameter. The second rule states that the lifetime of the input parameter will be assigned to the output lifetime parameter, so the signature now looks like this:
1 | fn first_word<'a>(s: &'a str) -> &'a str { |
Now all references in this function signature have lifetimes, so the compiler can continue its analysis without the programmer needing to annotate the lifetimes in this function signature.
1 | fn longest(x:&str,y:&str)->&str{ |
Apply the first rule
1 | fn longest<'a,'b>(x:&'a str,y:&'b str)->&str{ |
The second rule does not apply, the third rule does not apply, so the compiler reports an error
Lifetime annotations in method definitions
When implementing methods for a struct with lifetimes, the syntax is still similar to that of generic type parameters.
Where to declare lifetime parameters depends on:
- Whether the lifetime parameters are related to struct fields or method parameters and return values。
Lifetime names for struct fields:
- Declared after impl
- Used after the struct name
- These lifetimes are part of the struct type
In method signatures within impl blocks:
- References must be bound to the lifetime of struct field references, or references can be independent
- Lifetime elision rules often make lifetime annotations unnecessary in methods
1 | struct ImportantExcerpt<'a>{ |
announce_and_return_part has two input lifetimes here, so Rust applies the first lifetime elision rule and gives &self and announcement their own lifetimes. Then, because one of the parameters is &self, the return type is assigned the lifetime of &self, so all lifetimes are resolved.
Static lifetime
'static, whose lifetimecanlive for the entire duration of the program.
All string literals have'staticlifetime
let s: &'static str = “I have a static lifetime.”;
The text of this string is stored directly in the program’s binary file, which is always available. Therefore, all string literals are 'static.
Combining generic type parameters, trait bounds, and lifetimes
1 | use std::fmt::Display; |
Writing automated tests
Test functions in Rust are used to verify that non-test code is behaving as expected. A test function body typically performs the following three actions:
- Set up any needed data or state (Arrange)
- Run the code that needs to be tested (Act)
- Assert that the results are what we expect (Assert)
Anatomy of a Test Function
Writing Test Functions
A test in Rust is a function annotated with the test attribute. Attributes are metadata about pieces of Rust code.
To turn a function into a test function, add #[test] before the fn line**#[test]**
Running Tests
- Run all test functions using the cargo test command
Rust builds a test runner executable that runs functions annotated with the test attribute and reports whether they succeed
- When creating a library project with cargo, a test module with one test function is generated
You can add any number of test modules or functions
1 | cargo new 项目名 --lib |
src/lib.rs
1 | pub fn add(left: usize, right: usize) -> usize { |
Run
1 | cargo run test |
Test Failure
- A test function panicking indicates failure
- Each test runs in a new thread
- When the main thread sees a test thread die, that test is marked as failed
Assert
Use the assert! macro to check test results
The assert! macro, from the standard library, is used to determine whether a condition is true
- true, test passes
- false, calls panic!, test fails
1 |
|
Using assert_eq! and assert_ne! macros to test equality
- Both come from the standard library
- Determine whether two arguments are equal or not equal
- In fact, they use the assert! of the == and != operators
- On assertion failure, it automatically prints the values of the two arguments
Print parameters using debug format: requires parameters to implement PartialEq and Debug traits (all basic types and most standard library types implement them)
1 | pub fn add_two(a: i32) -> i32 { |
Custom error messages
You can pass an optional failure message argument to the assert!, assert_eq!, and assert_ne! macros, which will print the custom failure message along with the test failure.
- The first argument of the assert! macro is required, with the custom message as the second argument
- assert_eq! and assert_ne! require the first two arguments, with the custom message as the third argument
- The custom message argument is passed to the format! macro, allowing the use of {} placeholders
1 | pub fn greeting(name: &str) -> String { |
Custom error messages
1 |
|
Verifying error handling scenarios
Can verify whether code panics under specific circumstances
should_panicAttribute:
- Function panics: test passes
- Function does not panic: test fails
1 | pub struct Guess { |
Make should_panic more precise
An optional expected parameter can be added to the should_panic attribute. The test harness will ensure that the error message contains the provided text
1 | // --snip-- |
Using Result<T, E> in tests
Instead of panicking, you can write tests that return Result<T, E>
- Returning Ok: test passes
- Returning Err: test fails
1 |
|
Note:You cannot use the #[should_panic] annotation on tests that use Result<T, E>。
Because when they fail, they return an Err instead of panicking
Controlling test execution
Changing the behavior of cargo test: adding command-line arguments
Default behavior:
- Running in parallel
- All tests
- Capturing (not displaying) all standard output to make it easier to read output related to test results
Command line arguments:
- Arguments for cargo test: immediately after cargo test
cargo test --help
- Arguments for the test executable: placed after –
cargo test – --help
Running tests in parallel
By default, tests run in parallel using multiple threads
Ensure that tests:
- Do not depend on each other
- Do not depend on any shared state (environment, working directory, environment variables, etc.)
The --test-threads argument
If you do not want tests to run in parallel, or want more precise control over the number of threads, you can pass the --test-threads argument and the desired number of threads to the test binary. For example:
1 | cargo test -- --test-threads=1 |
Here, the test threads are set to 1, telling the program not to use any parallelism. This will take more time than running in parallel, but when there is shared state, tests will not potentially interfere with each other.
Showing function output
By default, if a test passes, Rust’s test library captures everything printed to standard output
For example, println!:
- If the test succeeds, we will not see the output of println! in the terminal: You will only see the line indicating that the test passed.
- Ifthe test fails, you will see all standard output and other error messages。
If you also want to see the values printed in passing tests, you can add --show-output at the end to tell Rust to display the output of successful tests.
1 | cargo test -- --show-output |
Running a Subset of Tests by Name
If you run tests without passing any arguments, all tests will run in parallel:
Running a Single Test
You can pass the name of any test to cargo test to run only that test:
1 | cargo test one_hundred |
Filtering to Run Multiple Tests
We can specify part of a test name, and any test whose name matches will be run. For example, because the first two tests have names containing ‘add’, you can run them with cargo test add:
1 | $ cargo test add |
This runs all tests with ‘add’ in their name and filters out the test named one_hundred.
Ignoring Some Tests
Sometimes specific tests are very time-consuming to execute, so you may want to exclude them during most cargo test runs.
You can use the ignore attribute to mark time-consuming tests and exclude them, as shown below:
Filename: src/lib.rs
1 |
|
For tests we want to exclude, we add the #[ignore] line after #[test]. Now if we run the tests, we’ll see that it_works runs, while expensive_test does not run:
If we only want to run the ignored tests, we can use cargo test – --ignored
1 | $ cargo test -- --ignored |
Test Organization
The Rust community tends to think about tests in terms of two main categories:
Unit tests(unit tests) andIntegration tests(integration tests)
- Unit tests tend to be smaller and more focused, testing one module at a time in isolation, or testing private interfaces.
- Integration tests are entirely external to your library. They use your code in the same way as other external code, only testing public interfaces, and each test may test multiple modules.
Unit Tests
Annotating the test module with #[cfg(test)]
- Only compile and run code when running cargo test
- Running cargo build will not
- Integration tests are in a different directory and do not need the #[cfg(test)] annotation
cfg: configuration
Tells Rust that the following item is only included under a specified configuration option
Testing private functions
There has been debate within the testing community about whether private functions should be tested directly, and in other languages it is difficult or even impossible to test private functions. However, regardless of which testing ideology you adhere to,Rust’s privacy rules do allow you to test private functions。
Filename: src/lib.rs
1 | pub fn add_two(a: i32) -> i32 { |
Example 11-12: Testing a private function
Note the internal_The adder function is not marked as pub. Also, tests is just another module. As mentioned in the section ‘Paths for Referring to an Item in the Module Tree’, items in child modules can use items from their parent modules. In the test, we bring all items from the parent module of the test module into scope with use super:😗, and then the test calls internal_adder。
Integration tests
In Rust, integration tests are entirely external to the test library
They use the library file just like any other code that uses the library, meaning they can only call the public API of the library. The purpose of integration tests is to test whether multiple parts of the library work together correctly. Some code units that work correctly individually may have issues when integrated together, soIntegration test coverageIt is also very important.
tests directory
- Creating integration tests: the tests directory
- Each test file in the tests directory is a separate crate
Create an integration test. Keep the code in the adder example_src/lib.rs_code. Create a_tests_directory, create a new file_tests/integration_test.rs_, and enter the code from the example.
1 | use adder; |
- There is no need to_tests/integration_annotate any code in test.rs_ with #[cfg(test)].The tests folder is a special folder in Cargo, Cargo only compiles files in this directory when running cargo test.
- Need to import the library being tested
Run a specific integration test
Run a particular integration test
1 | $ cargo test 函数名 |
Run all tests in a specific test file
1 | $ cargo test --test 文件名 |
Submodules in integration tests
As integration tests grow, you may want to add more files to the tests directory to better organize them, for example, grouping tests by their functionality. As we mentioned earlier, each_tests_file in the directory is compiled as a separate crate.
Treating each integration test file as its own crate helps create separate scopes, which provide an environment more similar to how end users would use the crate.
For example, if we can create atests/common.rsfile and create a function named setup, we want this function to be callable by test functions in multiple test files:
Filename: tests/common.rs
1 | pub fn setup() { |
If you run the tests again, you will see a new corresponding_common.rs_section in the test results for the file, even though this file does not contain any test functions and the setup function is not called anywhere:
1 | $ cargo test |
We do not want common to appear in the test results showing ‘running 0 tests’. We just want it to be callable from other integration test files.
To prevent common from appearing in the test output, we willcreatetests/common/mod.rs ,instead of creatingtests/common.rs. This is a Rust naming convention; naming it this wayTell Rust not to treatcommonas an integration test file. Move the setup function code to_tests/common/mod.rs_and delete_tests/common.rs_file, this part will no longer appear in the test output.Subdirectories within the tests directory are not compiled as separate crates nor do they appear as separate test result sections in the test output.。
Once you have_tests/common/mod.rs_, you can use it as a module in any integration test file. Here is an_tests/integration_example of calling the setup function in test.rs__adds_two test example:
Filename: tests/integration_test.rs
1 | use adder; |
Integration tests for binary crates
If the project is a binary crate that only contains src/main.rs and no src/lib.rs:
Cannot create integration tests in the tests directory
Cannot import functions from main.rs into scope
Only library crates can expose functions for use by other crates
A binary crate means it runs independently
IO Project: Building a Command Line Program
Accepting Command Line Arguments
1 | use std::env; |
Note:
std::env::args will panic if any of its arguments contain invalid Unicode characters.
If you need to accept arguments containing invalid Unicode characters, usestd::env::args_osinstead. This functionreturns OsString valuesinstead of String values. Here, std::env::args is used for simplicity, because OsString values differ per platform and are more complex to handle than String values.
Separation of Concerns for Binary Projects
The problem of the main function being responsible for multiple tasks is common in many binary projects. Therefore, the Rust community has developed a set of guidelines for separating concerns in binary programs when the main function starts to grow large. These steps are as follows:
- Split the program into main.rs and lib.rs and put the program’s logic into lib.rs。
- When the command-line parsing logic is small, it can be kept in_main.rs_.
- When the command-line parsing starts to become complex, similarly extract it from_main.rs_into_lib.rs_.
After these processes, the responsibilities remaining in the main function should be limited to:
- Calling the command-line parsing logic with parameter values
- Setting any other configuration
- Calling_lib.rs_the run function in
- If run returns an error, handle that error
The whole point of this pattern is separation of concerns:_main.rs_handles program execution, while_lib.rs_handles all the real task logic. Because the main function cannot be directly tested, this structure allows us to test them by moving all program logic into_lib.rs_functions, enabling us to test them. Only keep in_main.rs_The code in it will be small enough to read and verify its correctness.
TDD (Test-Driven Development)
Follow the Test-Driven Development (TDD) pattern to incrementally add the search logic to minigrep. This is a software development technique that follows these steps:
- Write a failing test and run it to ensure it fails for the reason you expect.
- Write or modify enough code to make the new test pass.
- Refactor the code you just added or modified, and ensure the tests still pass.
- Repeat from step 1!
This is just one of many ways to write software, but TDD helps drive code design. Writing tests before the code that makes them pass helps maintain high test coverage during development.
Writing the minigrep code
src/lib.rs
1 | use std::env; |
src/main.rs
1 | use std::env; |

