Getting Started with Rust: Ownership and Borrowing
Ownership is Rust's hardest and most fundamental concept, enabling memory safety without a garbage collector. This article explains ownership, borrowing, and lifetimes in plain English, along with the common situations where beginners end up "fighting the compiler," to help you clear Rust's first and steepest hurdle.
Key Points
- Topic Focus: TypeScript、开源
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Developers accustomed to TypeScript / Python / Go are usually knocked flat by the compiler the first time they encounter Rust. Once the struggle is over, however, you discover that it is a completely new and worthwhile experience. That steepest hurdle is called ownership. This article explains ownership, borrowing, and lifetimes, along with several traps that every beginner is bound to encounter.
1. Why Rust Is Worth Learning
First, why spend time learning a language with a learning curve steep enough to drive people away? There are three reasons:
First, memory safety without GC. JavaScript has V8's GC to reclaim memory for you, Go has GC, and C/C++ requires manual management. Rust takes a third path, the ownership system, which guarantees at compile time that there are no null pointers, dangling references, or double frees, with zero runtime overhead. You can write low-level code without worrying about memory leaks or use-after-free.
Second, performance. Rust compiles to native machine code. It has no runtime and no JIT warm-up, placing its performance in the same tier as C/C++ while being far safer. Discord, Cloudflare, and ByteDance have all rewritten critical paths in Rust.
Third, the resurgence of systems programming. The Linux kernel has accepted Rust, Windows kernel components are being rewritten in Rust, and Rust is rapidly spreading through blockchains, databases, game engines, and embedded systems. Learning Rust is like gaining an entry pass to low-level systems work.
The learning curve really is steep. Once you clear it, however, everything opens up.
2. The Three Rules of Ownership
Ownership is a concept unique to Rust. Do not look for an equivalent in another language; it is genuinely something new. Remember these three rules, and you will understand 80% of it:
- Every value has exactly one owner.
- Assigning a value to another variable or passing it as a function argument performs a "move" by default, not a copy. The original variable is invalid from that point onward.
- When the owner leaves scope, the value is automatically freed.
Consider this code:
fn main() {
let s1 = String::from("hello"); // s1 is the owner
let s2 = s1; // ownership moves to s2; s1 becomes invalid
// println!("{}", s1); // compile error: s1 has been moved
println!("{}", s2); // OK
} // s2 leaves scope; memory for "hello" is freed
Why is it designed this way? Rust needs to know at compile time who is responsible for freeing this memory. If both s1 and s2 remained valid, the same memory would be freed twice, causing the notorious double-free bug found in C/C++. Rust resolves the problem decisively with move semantics.
Note that simple scalar types (integers, floating-point numbers, booleans, and char) implement the Copy trait. Assignment copies them, so the original variable remains valid:
let x = 5;
let y = x; // x is copied to y; x remains valid
println!("{} {}", x, y); // OK
Passing an argument to a function also performs a move:
fn take(s: String) {
println!("got {}", s);
} // s leaves scope and is freed
fn main() {
let s = String::from("hello");
take(s);
// println!("{}", s); // compile error: s has been moved into the function
}
Want to keep using it? Either have the function return ownership or use the borrowing mechanism explained in the next section.
3. Borrowing and References
Moving ownership back and forth for every function call is exhausting. Rust's solution is borrowing: take a reference to inspect or modify a value without transferring ownership.
There are two kinds of references:
- Immutable reference
&T: Multiple immutable references can exist simultaneously, and they are read-only. - Mutable reference
&mut T: Only one can exist at a time, and it permits writes.
fn main() {
let s = String::from("hello");
let r1 = &s; // immutable borrow
let r2 = &s; // another immutable borrow, OK
println!("{} {}", r1, r2); // multiple immutable borrows can coexist
let mut s2 = String::from("world");
let r3 = &mut s2; // mutable borrow, with exclusive access
r3.push_str("!");
println!("{}", r3);
}
Here is the iron rule: immutable and mutable borrows cannot coexist at the same time. Either everyone reads, or one borrower has exclusive write access. The following code does not compile:
let mut s = String::from("hello");
let r1 = &s; // immutable borrow
let r2 = &mut s; // compile error: an immutable borrow already exists
Why is Rust so strict? It eliminates data races at compile time. If one party writes while another reads, the data may change halfway through the read, creating fertile ground for concurrency bugs. Rust stops the problem during compilation instead of waiting for it to explode in production.
One detail about NLL (Non-Lexical Lifetimes): a reference's scope is considered to end at its last use, not when the variable leaves its enclosing braces. That makes the following code valid:
let mut s = String::from("hello");
let r1 = &s;
println!("{}", r1); // r1 is used for the last time here and is "dead" afterward
let r2 = &mut s; // r1 is dead, so this is OK
println!("{}", r2);
This is the result of the compiler becoming smarter after the Rust 2018 edition. In earlier years, this code would have been rejected.
4. Lifetimes
A borrow cannot exist out of thin air; it must point to something. A lifetime is the mechanism the compiler uses to track "exactly how long this reference can remain valid."
The core rule can be stated in one sentence: a reference cannot outlive the value it refers to. Otherwise, it becomes a dangling reference.
fn main() {
let r;
{
let x = 5;
r = &x;
} // x is freed here
// println!("{}", r); // compile error: the x referenced by r no longer exists
}
Most of the time, the compiler can infer lifetimes by itself, and you do not need to think about them. In a function signature where both the parameters and return value are references, however, the compiler cannot determine which argument the returned reference is tied to. You must then annotate the lifetime explicitly:
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}
'a is a lifetime parameter. It tells the compiler: the return value's lifetime is tied to the shorter lifetime of s1 and s2. After the caller receives the returned reference, this constraint strictly limits the range in which it can be used.
Beginners are often intimidated the first time they see <'a>. Treat it like a type parameter: generics mean "any type is acceptable," while lifetimes mean "any duration is acceptable, provided it satisfies the constraints." Once you accept that model, most lifetime errors become understandable.
5. The Three Situations Where Beginners Fight the Compiler Most Often
During your first few hundred hours of writing Rust, you will repeatedly stumble into three traps. Knowing about them in advance can save a great deal of time.
Trap one: String vs &str
String is a heap-allocated, mutable string that owns its data. &str is a string slice and is usually borrowed. Beginners often do not know which one to use and end up with compiler errors everywhere.
Rule of thumb: prefer &str for function parameters and use String only when ownership is required.
fn first_word(s: &str) -> &str { // accept &str to make the function more general
// ...
s
}
fn main() {
let s = String::from("hello world");
let w = first_word(&s); // String coerces to &str, OK
let literal = "hi";
let w2 = first_word(literal); // a literal is already &str, OK
}
Use String when a struct field needs to own its data. Use &'a str when it only references data, although that requires a lifetime annotation.
Trap two: values being moved in loops or collections
let words = vec![String::from("a"), String::from("b")];
for w in words { // words is moved into the iterator here
println!("{}", w);
}
// println!("{:?}", words); // compile error: words has already been consumed
You "only wanted to look," but the collection was moved away. Want to preserve it? Iterate by reference with for w in &words, which gives you a &String; use &mut when you need to modify it. This is the most common beginner trap, bar none.
Trap three: returning references
fn dangle() -> &String { // compile error
let s = String::from("hi");
&s
} // s is freed here, so the returned reference dangles
The compiler rejects this outright. There are two solutions: return String and transfer ownership, or tie the reference's lifetime to an externally supplied argument, as in the earlier longest example.
These three traps may seem tedious, but they all teach the same lesson: who should own the data, and how long should the reference live? Once you answer those two questions, the code naturally passes compilation.
6. A Change in Mindset: The Compiler Is a Teammate, Not an Enemy
Many beginners have an adversarial relationship with the Rust compiler. It fills the screen with red errors, and they start guessing: add a clone()? Add an &? Rearrange the code and try again?
That is the wrong approach.
The Rust compiler is the most patient teacher available. Every error it reports points to a real memory problem that could crash your program in production. A casual .clone() is a painkiller, not a cure. It hides the fact that you have not properly designed the ownership model.
The right mindset is:
- Understand the error message. Rust has the best diagnostics of any language: they tell you where the problem is, why it occurred, and how to fix it.
- Do not rush to use
.clone(). First ask yourself: who should actually own this data? - Accept that rejection is normal. For the first two weeks, you will feel as though you no longer know how to program. That is normal; everyone goes through it.
Once you establish a working relationship with the compiler, you will notice something remarkable: once a Rust program compiles, it generally works. Many of those mysterious "it works on my machine" bugs are eliminated at compile time in Rust. No dynamic language can match the confidence that comes from having the compiler provide this safety net.
7. Recommended Learning Path
Finally, here is a concrete learning path so you do not waste time working at the wrong level:
Step one: Read the first four chapters of The Rust Programming Language, known in the community as The Book, three times, including the ownership chapter. Do not skip anything. After every section, write code to verify what you learned. You cannot learn ownership by reading alone; your hands need practice.
Step two: Do not rush into web frameworks or databases. First complete the dozens of small exercises in Rustlings, the official exercise collection, to build muscle memory.
Step three: Write a somewhat larger CLI utility, such as a todo list or a simple grep. The process will force you to work with ownership, borrowing, lifetimes, error handling (Result), and traits together.
Step four: Only then choose the field you want to pursue. For Web development, learn axum; for asynchronous programming, learn tokio; for embedded development, explore no_std. With ownership as a foundation, everything else becomes easier to learn.
The easiest mistake to make is trying to recreate a familiar TypeScript project in Rust from day one. You will repeatedly run headfirst into ownership problems and then blame the language for being "hard to use." Rust is not a better TypeScript; it is a different way of thinking. Understand ownership first, then discuss everything else.
Summary
- The three rules of ownership: Every value has a unique owner, assignment performs a move by default, and the value is automatically freed when its scope ends. These rules are the foundation of Rust's memory safety.
- Borrowing means "use it temporarily without moving ownership": Multiple
&Treferences can coexist,&mut Thas exclusive access, and the two cannot exist simultaneously. This eliminates data races at compile time. - Lifetimes are the compiler's static check of "how long a reference can live." A reference cannot outlive the value it refers to. Annotations in function signatures tell callers the valid range of returned references.
- The three major beginner traps:
Stringvs&str, values being moved in loops, and returning references. They all come down to the same question: who owns the data? - The compiler is a teammate, not an enemy. Its errors help eliminate potential memory bugs. Understand them instead of fighting them.
Learning Rust is not about learning syntax; it is about learning the engineering discipline of "who owns what." The harder the compiler scolds you, the more robust your future programs will be.
Related
Related Content
Article
Self-Hosting Open-Source LLMs: Getting Started with Ollama, vLLM, and llama.cpp
Technical trends, engineering practices, and an independent developer's perspective on LLMs and open source.
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
Learning Technical Community Product Design from dev.to
Technical trends, engineering practices, and an independent developer's perspective on independent development and open source.