Rust Memory Safety: Ownership, Borrowing, Lifetimes, unsafe
이 글의 핵심
Rust eliminates use-after-free and data races at compile time through ownership, borrowing, and lifetimes — bugs that C++ only catches at runtime. This guide explains each mechanism with concrete code examples.
Why Rust Memory Safety Matters
C++ and Rust both give you manual control over memory, but with a fundamental difference: C++ trusts you to get it right at runtime, while Rust verifies you got it right at compile time.
Common C++ memory bugs that Rust eliminates at compile time:
- Use-after-free: accessing memory after it has been freed
- Dangling references: pointers to stack variables that have gone out of scope
- Double-free: freeing the same memory twice
- Data races: two threads accessing the same memory with at least one write and no synchronization
Rust’s three mechanisms — ownership, borrowing, and lifetimes — work together to make these bugs impossible in safe Rust code.
1. Ownership and Move Semantics
Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped (memory freed). Assignment moves ownership by default for non-Copy types.
fn main() {
let s1 = String::from("hello");
let s2 = s1; // ownership moves to s2
// println!("{}", s1); // compile error: value moved
println!("{}", s2); // OK
}
// s2 dropped here — memory freed automatically
Contrast this with C++, where assigning after a std::move leaves the original in a “valid but unspecified” state. Rust makes using the moved-from value a compile error, not a runtime surprise.
Clone vs Move
When you need a copy, use .clone() explicitly:
let s1 = String::from("hello");
let s2 = s1.clone(); // deep copy — both valid
println!("{} {}", s1, s2); // OK
Simple types like integers implement Copy, so they are copied rather than moved:
let x = 5;
let y = x; // copied, not moved
println!("{} {}", x, y); // both valid
Ownership Through Functions
Passing a value to a function moves it into that function’s scope:
fn take_ownership(s: String) {
println!("{}", s);
} // s dropped here
fn borrow(s: &String) {
println!("{}", s);
} // s NOT dropped — caller still owns it
fn main() {
let s = String::from("hello");
borrow(&s); // lend it — s still valid
take_ownership(s); // move it — s is gone
// println!("{}", s); // compile error: moved
}
2. Borrowing and the Borrow Checker
Rust has two kinds of references:
&T— shared (immutable) reference: multiple allowed simultaneously&mut T— exclusive (mutable) reference: only one allowed at a time, and no shared references can coexist
The rule: shared XOR mutable. You can have many readers or one writer, never both.
fn main() {
let mut v = vec![1, 2, 3];
let a = &v; // shared borrow
let b = &v; // another shared borrow — fine
println!("{:?} {:?}", a, b);
// Now the shared borrows are done
let c = &mut v; // mutable borrow — OK
c.push(4);
println!("{:?}", c);
}
This prevents iterator invalidation, a common C++ footgun:
// C++ — undefined behavior
std::vector<int> v = {1, 2, 3};
for (auto& x : v) {
v.push_back(x * 2); // modifies v while iterating — UB
}
// Rust — compile error, caught before running
let mut v = vec![1, 2, 3];
for x in &v {
v.push(*x * 2); // error: cannot borrow `v` as mutable because it is borrowed as immutable
}
3. Lifetimes
Lifetimes ensure that references never outlive the data they point to. Most of the time Rust infers them (lifetime elision), but sometimes you need to be explicit.
The Dangling Reference Problem
fn dangle() -> &String { // compile error
let s = String::from("hello");
&s // returns reference to s, but s is dropped at end of function
}
In C++, this compiles and runs — returning a reference to a local variable. In Rust, it’s a compile error.
Explicit Lifetime Annotations
When a function returns a reference derived from its inputs, you tell Rust which input the output’s lifetime is tied to:
// 'a means: the returned reference lives as long as the shorter-lived input
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xy");
result = longest(s1.as_str(), s2.as_str());
println!("{}", result); // OK — used inside s2's scope
}
// println!("{}", result); // error if used here — s2 dropped
}
Lifetimes in Structs
If a struct holds a reference, it needs a lifetime parameter:
struct Excerpt<'a> {
text: &'a str, // this struct cannot outlive the string it borrows from
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let excerpt = Excerpt { text: first_sentence };
println!("{}", excerpt.text);
}
4. unsafe Rust
unsafe blocks tell the compiler: “I’ve verified the invariants you can’t check.” Five things require unsafe:
- Dereferencing raw pointers (
*const T,*mut T) - Calling
unsafefunctions (including C functions via FFI) - Accessing mutable static variables
- Implementing
unsafetraits - Accessing fields of
unions
// FFI — calling a C function
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("{}", abs(-3)); // 3
}
}
// Raw pointers — manual memory management
fn main() {
let mut x = 42;
let raw = &mut x as *mut i32;
unsafe {
*raw = 100; // dereference raw pointer
}
println!("{}", x); // 100
}
Rule of thumb: keep unsafe blocks as small as possible, document what invariant makes this safe, and test the boundary thoroughly. The unsafe block doesn’t disable the borrow checker for surrounding safe code — it only unlocks the five operations above.
5. Concurrency — Send and Sync
Rust’s thread safety is enforced by two marker traits:
Send: a type is safe to transfer to another thread (ownership moves across threads)Sync: a type is safe to share references across threads (&Tcan be sent to another thread)
use std::thread;
let v = vec![1, 2, 3]; // Vec<i32> is Send
let handle = thread::spawn(move || {
println!("{:?}", v); // v moved into new thread
});
handle.join().unwrap();
Shared Mutable State — Arc and Mutex
To share data across threads, combine Arc (atomic reference counting) with Mutex (mutual exclusion):
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap()); // always 10
}
The equivalent in C++ requires manual discipline. In Rust, using Rc instead of Arc here would be a compile error — Rc is not Send, so the compiler refuses to let you move it into a thread.
Ownership vs C++ Smart Pointers
| Concept | Rust | C++ Equivalent |
|---|---|---|
| Unique ownership | Default / Box<T> | std::unique_ptr<T> |
| Shared ownership | Arc<T> | std::shared_ptr<T> |
| Non-thread-safe shared | Rc<T> | std::shared_ptr (not thread-safe by default) |
| Mutable shared access | Mutex<T> / RwLock<T> | std::mutex + manual locking |
| Raw pointers | unsafe { *ptr } | Raw pointer dereferencing |
The key difference: in Rust, the type system enforces correct usage at compile time. In C++, misuse compiles fine and fails at runtime.
Key Takeaways
- Ownership gives every value exactly one owner; the value is dropped when the owner goes out of scope
- Borrowing allows
&T(shared) or&mut T(exclusive) references, never both at once — this prevents data races and iterator invalidation - Lifetimes ensure references never outlive the data they point to, eliminating dangling pointers at compile time
unsafeis needed for FFI, raw pointers, and manual invariants — keep blocks small and document invariantsSend/Synctraits make thread safety a compile-time guarantee:Rccannot cross threads,Arccan- The borrow checker rejects whole classes of bugs that C++ only discovers through sanitizers, tests, or production crashes
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. How Rust prevents use-after-free and data races at compile time: move semantics, borrow checker rules, lifetime annotati… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ vs Rust 완전 비교 | 소유권·메모리 안전성·에러 처리·동시성·성능 실전 가이드
- C++와 Rust: 두 언어의 상호 운용성과 Memory Safety 논쟁의 실체 [#44-2]
이 글에서 다루는 키워드 (관련 검색어)
Rust, memory safety, ownership, borrow checker, lifetimes, unsafe, Send 등으로 검색하시면 이 글이 도움이 됩니다.