なぜ二度使ってはいけないのか
한국어 원문으로 표시합니다.
한 줄 요약
Rust 는 값의 주인을 하나로 강제한다. 다른 곳에 넘기면 원래 자리에서는 못 쓴다. 그 규칙 하나가 GC 없이 메모리 안전을 만든다.
왜 이게 필요했나
C 계열의 대표적 사고 세 가지가 있다.
- use after free — 해제한 메모리를 다시 쓴다
- double free — 두 번 해제한다
- data race — 두 스레드가 같은 값을 동시에 바꾼다
셋 다 "누가 이 값의 주인인가" 가 불분명해서 생긴다. GC 는 첫 두 개를 없애지만 멈춤 시간과 메모리 대가가 있고, 세 번째는 여전히 남는다.
Rust 는 다른 답을 골랐다 — 주인을 컴파일 시점에 추적한다. 실행 중 비용이 0이다.
규칙은 셋뿐이다
- 모든 값에는 주인(owner)이 하나 있다
- 주인이 사라지면 값도 사라진다
- 값을 다른 곳에 넘기면(move) 원래 주인은 잃는다
let s = String::from("hi");
let t = s; // 주인이 t 로 옮겨졌다
println!("{}", s); // ❌ 컴파일 오류: s 는 이미 넘겨졌다
오류 메시지가 정확히 그렇게 말한다.
error[E0382]: borrow of moved value: `s`
= note: move occurs because `s` has type `String`,
which does not implement the `Copy` trait
Copy 를 구현한 타입은 복사된다. 정수·불리언·문자 같은 것들이다. 그래서 let a = 5; let b = a; 는 문제가 없다. 크기가 고정이고 힙을 안 쓰기 때문이다.
빌리기
넘기지 않고 잠깐 쓰려면 빌린다(borrow).
fn len(s: &String) -> usize { s.len() } // 읽기만 빌림
let s = String::from("hi");
println!("{} {}", len(&s), s); // ✅ s 는 여전히 내 것
빌림에는 규칙이 하나 더 있다.
읽기 빌림은 여러 개 동시에 되지만, 쓰기 빌림은 하나뿐이고 그동안 읽기도 못 한다.
let mut v = vec![1, 2, 3];
let first = &v[0]; // 읽기 빌림
v.push(4); // ❌ 쓰기 빌림이 필요한데 읽기가 살아 있다
println!("{}", first);
이게 막는 것은 실제 사고다. push 가 벡터를 재할당하면 first 가 가리키던 주소는 무효가 된다 — C++ 에서는 그게 조용히 통과하고 나중에 이상한 값을 읽는다.
수명
빌림은 원본보다 오래 살 수 없다. 컴파일러가 그걸 검사한다.
fn dangle() -> &String { // ❌
let s = String::from("hi");
&s // s 는 함수가 끝나면 사라진다
}
대부분의 경우 수명은 추론된다. 명시적으로 'a 를 쓰는 일은 생각보다 드물고, 주로 여러 참조 중 어느 것과 묶이는지를 알려 줄 때다.
흔한 착각
"clone 하면 되잖아" — 된다. 그리고 그게 맞는 답일 때도 많다. 소유권과 싸우느라 하루를 쓰느니 clone() 하고 넘어가는 편이 낫다. 성능이 문제가 되면 그때 프로파일러를 보고 고친다. 처음부터 참조로 최적화하려는 것이 Rust 를 어렵게 만드는 가장 흔한 이유다.
"unsafe 는 나쁘다" — unsafe 는 "여기서는 컴파일러가 검사할 수 없으니 내가 보증한다" 는 표시다. 표준 라이브러리 안에도 많다. 나쁜 것은 unsafe 를 쓰는 것이 아니라 왜 안전한지 적지 않는 것이다.
소유권 문제를 푸는 도구 상자
빌림 검사기와 싸우는 대신, 상황에 맞는 도구를 고르면 대부분 풀립니다.
| 상황 | 도구 | 대가 |
|---|---|---|
| 잠깐 읽기만 | &T |
없음 |
| 잠깐 고치기 | &mut T |
그동안 다른 참조 불가 |
| 소유권을 넘긴다 | 값 그대로 | 원래 변수를 못 쓴다 |
| 복제해도 싸다 | .clone() |
복사 비용 |
| 여러 곳이 함께 소유 | Rc<T> (단일 스레드) |
참조 카운트, 순환 누수 |
| 여러 스레드가 공유 | Arc<T> |
원자적 카운트(약간 느림) |
| 공유하면서 고쳐야 | Arc<Mutex<T>> |
잠금 비용, 교착 가능 |
초보자가 .clone() 을 쓰는 것은 잘못이 아닙니다. 먼저 동작하게 만들고, 프로파일
러가 그 복사를 지목하면 그때 참조로 바꿉니다. 처음부터 수명 표기와 싸우다 포기하는
것보다 훨씬 낫습니다.
컴파일 오류를 읽는 순서
러스트의 오류 메시지는 길지만 구조가 일정합니다.
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let first = &v[0]; ← 여기서 불변으로 빌렸고
| - immutable borrow occurs here
4 | v.push(4); ← 여기서 가변으로 빌리려 한다
| ^^^^^^^^^ mutable borrow occurs here
5 | println!("{}", first); ← 불변 빌림이 여기까지 살아 있다
| ----- immutable borrow later used here
세 화살표를 순서대로 읽습니다 — 어디서 빌렸고, 어디서 충돌하고, 왜 아직 살아
있는가. 마지막 줄이 핵심입니다. first 를 쓰지 않으면 빌림이 push 전에 끝나
오류가 사라집니다(NLL, non-lexical lifetimes).
rustc --explain E0502 로 그 오류의 설명 전문을 볼 수 있습니다. 코드마다 예제가
붙어 있어 처음 만나는 오류는 이것부터 읽는 것이 빠릅니다.
언제 unsafe 를 쓰나
거의 안 씁니다. 그런데 unsafe 는 "검사를 끈다" 가 아니라 "내가 불변식을
지킨다고 컴파일러에게 약속한다" 는 뜻입니다. 다섯 가지 연산만 추가로 허용되고
(원시 포인터 역참조, unsafe 함수 호출 등), 나머지 검사는 그대로입니다.
실무에서 만나는 자리는 셋입니다. C 라이브러리를 부를 때(FFI), 자료구조를 직접 구현할 때(이중 연결 리스트), 그리고 성능이 증명된 병목일 때.
unsafe 블록은 최대한 좁게 두고, 그 위에 안전한 API 를 덮습니다. 그러면
그 블록만 사람이 검토하면 되고 사용자는 안전한 인터페이스만 봅니다. 표준
라이브러리가 정확히 이 구조입니다.
실무에서 진짜 중요한 것
오류 메시지를 끝까지 읽는 습관이 Rust 학습의 절반이다. rustc 는 대개 고치는 방법까지 알려 준다.
help: consider cloning the value if the performance cost is acceptable
|
5 | let t = s.clone();
| ++++++++
그리고 cargo clippy 는 컴파일은 되지만 더 나은 방법이 있는 곳을 짚어 준다. 배우는 동안에는 clippy 를 켜 두는 것이 책을 한 권 더 읽는 것과 비슷하다.