본문으로 건너뛰기
Previous
Next
Rust Memory Safety: Ownership, Borrowing, Lifetimes, unsafe

Rust Memory Safety: Ownership, Borrowing, Lifetimes, unsafe

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:

  1. Dereferencing raw pointers (*const T, *mut T)
  2. Calling unsafe functions (including C functions via FFI)
  3. Accessing mutable static variables
  4. Implementing unsafe traits
  5. 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 (&T can 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 errorRc is not Send, so the compiler refuses to let you move it into a thread.


Ownership vs C++ Smart Pointers

ConceptRustC++ Equivalent
Unique ownershipDefault / Box<T>std::unique_ptr<T>
Shared ownershipArc<T>std::shared_ptr<T>
Non-thread-safe sharedRc<T>std::shared_ptr (not thread-safe by default)
Mutable shared accessMutex<T> / RwLock<T>std::mutex + manual locking
Raw pointersunsafe { *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
  • unsafe is needed for FFI, raw pointers, and manual invariants — keep blocks small and document invariants
  • Send / Sync traits make thread safety a compile-time guarantee: Rc cannot cross threads, Arc can
  • 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와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Rust, memory safety, ownership, borrow checker, lifetimes, unsafe, Send 등으로 검색하시면 이 글이 도움이 됩니다.