겹을 센다: Rust에서 &와 *가 자리에 따라 하는 일
Rust 슬라이스 문제를 하나 풀다가 컴파일러한테 세 번 혼났어요. 그런데 세 에러가 결국 한 얼굴이더라고요 — 전부 &와 *를 제자리에 못 놓은 거였어요. &는 C에서 하던 그대로 주소를 주는 줄로만 알았는데, Rust에선 같은 글자가 자리에 따라 반대로 작동하거든요. 그게 안 잡히니 for &i도, *v = value도, let a = *b도 죄다 흐릿했어요.
이 글은 그 &와 * 딱 두 개가 자리에 따라 뭘 하는지만 다뤄요. lifetime, match ergonomics, &mut의 독점권(공유냐 변경이냐)은 다루지 않아요 — 마지막 건 지난 글에서 이미 팠고, 앞의 둘은 이번에 제가 안 걸린 곳이라 다음으로 미뤘어요. 발단이 된 get_mut이 Option을 돌려주는 얘기도 도입으로만 쓰고 지나갈게요.
들어가기 전에 — 용어 짚기
- 표현식(expression) — 값을 계산해서 내놓는 자리.
let a = ___의 오른쪽, 함수 인자, 연산식. 여기의&는 참조를 만들고*는 참조를 따라간다. - 패턴(pattern) — 값을 받아서 분해하는 자리.
let ___ = a의 왼쪽,for ___ in,match의 갈래. 여기의&는 참조를 벗긴다.*는 아예 없다. - 참조(reference) — 어떤 값이 사는 주소.
&x로 만든다.&i32는 "i32가 있는 주소". - 역참조(dereference) — 그 주소를 따라가 값에 닿는 것.
*p로 한다. C의*p와 같다. - 바인딩(binding) —
let a = ...의a. "변수"라 불러도 되지만, 정확히는 이름과 값을 묶은 것이다. 이 묶음을 바꾸는 것(a = ...)과, 이름이 가리키는 자리에 쓰는 것(*a = ...)은 다른 일이다. - 겹 / 겹 세기 — 이 글에서 쓰는 도구. 타입에
&가 몇 개 붙었나.i32는 0겹,&i32는 1겹,&&i32는 2겹.&/*/패턴을 각각 외우는 대신 겹 수의 +1/−1만 세면 된다.
1. 표현식 자리 — C랑 똑같다
C를 안다면 여기선 배울 게 없어요. 제가 헷갈렸던 건 이 부분이 아니라, 이게 전부인 줄 알았다는 데 있었어요.
표현식 자리의 &와 *는 C와 동작이 같다. &x는 주소를 만들고, *p는 그 주소를 따라가 값을 꺼낸다.
let x: i32 = 42;
let p = &x; // C 의 &x : x 의 주소
&x 타입 &i32 값(주소) 0x16f336644
x 의 진짜 주소 0x16f336644 // 완전히 같음
*p = 42 // C 의 *p 와 같다
&i32는 int*, 그 이상도 이하도 아니다. 딱 하나 Rust가 얹은 편의가 있는데, 읽을 때는 *를 자주 생략할 수 있다. C라면 *p라 써야 할 자리를 그냥 p로 넘어간다.
println!("{}", p); // C: printf("%d", *p) — 여기선 *p 안 써도 42
let s = p + 1; // &i32 + i32 도 됨 → 43
이 "읽을 때 자동, 쓸 때 수동"이 나중에 *v = value에서 발목을 잡는데, 그건 5절에서.
2. &&는 주소의 주소
여기서 제가 실제로 물었던 게 "&&는 주소의 주소야?"였는데, 맞았어요. 그리고 이걸 확인하고 나서야 "겹"이라는 게 머릿속 비유가 아니라 진짜 메모리 사슬이라는 게 잡혔어요.
&&x는 "x의 주소를 담은 변수"의 주소다. C의 이중 포인터(int**)와 같다.
let x: i32 = 42;
let p: &i32 = &x; // p = &x (x 의 주소)
let pp: &&i32 = &p; // pp = &p (p 가 놓인 자리의 주소)
x 가 놓인 주소 0x...5e4
p 가 담은 주소 0x...5e4 ← p ──▶ x
p 가 놓인 주소 0x...5e8
pp 가 담은 주소 0x...5e8 ← pp ──▶ p
pp ──▶ p ──▶ x
0x5e8 0x5e4 42
벗길 때도 사슬을 따라 한 칸씩 간다.
*pp = 0x...5e4 (&i32) 한 겹 → p 로 돌아옴 (x 의 주소)
**pp = 42 (i32) 두 겹 → x 값
그래서 겹 수는 "값이 나올 때까지 주소를 몇 번 따라가야 하나"다. &&i32는 두 번(**pp), &i32는 한 번(*p), i32는 0번. 이게 3절의 눈금이 된다.
3. 겹 세기 — let a = b는 아무것도 안 한다
이 글에서 제일 오래 막혔던 게 이거였어요. let a = b, let a = *b, let a = &b — 셋이 뭐가 다른지 도무지 구분이 안 됐거든요. 그런데 타입을 전부 찍어보니 답이 한 줄로 나왔어요. 겹 수만 세면 된다.
세 문법이 겹 수를 이렇게 건드린다.
| 쓴 것 | 자리 | 겹 |
|---|---|---|
&x | 표현식 | +1 (한 겹 싼다) |
*x | 표현식 | −1 (한 겹 벗긴다) |
&p | 패턴 | −1 (받으면서 한 겹 벗긴다) |
그리고 let a = b는 아무 짓도 안 한다. 오른쪽 타입이 그대로 온다(겹 ±0). 여기가 셋을 못 가른 지점의 정체였다. = b는 항등, = &b는 +1, = *b는 −1 — 셋이 다른 연산이 아니라 같은 눈금 위의 세 지점이다.
전체 타입 실측(type_name_of_val):
--- b: i32 (0겹) 에서 출발
let a = b -> i32
let a = &b -> &i32 (+1)
let a = &&b -> &&i32 (+2)
--- r: &i32 (1겹) 에서 출발
let a = r -> &i32 (±0)
let a = *r -> i32 (−1)
let a = &r -> &&i32 (+1)
안 되는 것들도 전부 이 산수로 설명된다.
let b: i32 = 7; let a = *b; -> error[E0614]: type `i32` cannot be dereferenced
0겹에서 더 벗길 게 없다
let b: i32 = 7; let &a = b; -> error[E0308]: expected `i32`, found `&_`
왼쪽은 1겹을 기대, 오른쪽은 0겹
그리고 let *a = b가 안 되는 건 겹 계산 이전의 문제다 — 패턴 자리엔 *라는 문법이 아예 없다.
let b: i32 = 7; let *a = b; -> error: expected pattern, found `*`
4. 패턴 자리의 & — 표현식의 거울
발단이 여기였어요. for &i in indices에서 왜 &가 붙는지가 안 풀렸거든요. 저는 for 왼쪽의 i를 "변수 선언"으로 읽고 있었어요. 그러니 &i가 "참조를 선언한다"로 보여서 영영 이상했던 거예요. 게다가 저는 "&a는 *로 접근하는 것"으로 알고 있었는데, 패턴의 &가 그걸 정면으로 뒤집는 것처럼 보였어요.
두 혼동 다 같은 오해에서 나온다: for나 let의 왼쪽은 변수 선언이 아니라 패턴이다. 패턴은 이름을 짓는 게 아니라 모양을 맞추는 자리다.
그리고 패턴의 &가 "만들기"가 아니라 "벗기기"인 이유는 하나다 — 패턴은 표현식의 거울이다. 오른쪽에서 어떻게 만들어졌을 값인지를 왼쪽에 똑같이 그려놓으면, 컴파일러가 그걸 역으로 푼다.
// 만든다 (표현식) // 뜯는다 (패턴)
let e1 = &n; let &p1 = e1; // & 로 쌌으니 & 로 벗김
let e2 = Some(n); let Some(p2) = e2 else { return };
let e3 = (n, n + 1); let (p3, _) = e3;
그래서 앞 절의 "&a는 *로 접근"이라는 제 이해는 틀린 게 아니라 표현식 자리에서만 맞았던 것이다. 같은 벗기기를 두 세계가 각자의 문법으로 한다.
let a = *r // 표현식에서 −1
let &a = r // 패턴에서 −1 → 둘이 같은 값
for &i in indices가 곧 for i in indices { let i = *i; }인 이유가 이거다. indices.iter()의 아이템은 &usize(1겹)인데 get_mut은 usize(0겹)를 원하니, 어딘가에서 −1을 해야 한다. 받는 쪽(패턴)에서 하면 for &i, 쓰는 쪽(표현식)에서 하면 get_mut(*i). 같은 산수의 두 표기다.
5. 대입 왼쪽의 * — 갈아끼우기 vs 쓰기
원래 제 코드가 틀린 진짜 이유가 여기 있었어요. 저는 이렇게 썼어요.
let v = slice.get_mut(i);
v = value; // 이걸로 슬라이스가 바뀔 거라 믿었음
v = value면 원소가 바뀔 줄 알았는데, 슬라이스는 꿈쩍도 안 했어요. v는 바인딩이고, 바인딩에 새 값을 넣는 건 이름표를 딴 데 옮겨 붙이는 것이지 원본을 건드리는 게 아니었어요.
v = value와 *v = value는 겉보기만 비슷하고 하는 일이 정반대다.
v = value; // v 라는 바인딩을 갈아끼워라 → 원본과 무관
*v = value; // v 가 가리키는 자리에 써라 → 원본에 닿음
1절의 "읽을 때는 * 자동, 쓸 때는 수동"이 여기서 값을 한다. 읽기는 컴파일러가 알아서 벗겨주지만, 쓰기는 어느 자리에 쓸지를 사람이 *로 지목해야 한다. 그래서 대입문 왼쪽의 *는 "값을 꺼내는" 연산이 아니라 "쓸 자리를 고르는" 연산으로 읽는 게 정확하다. * 없이 v = value라 쓰면 컴파일러는 "아, 이름표를 옮기려는군" 하고 조용히 받아들이고, 슬라이스는 그대로 남는다.
다시 정리하면
결국 제 오답의 정답 코드는 이 세 줄인데, 셋이 전부 &/* 얘기의 변주였어요.
for &i in indices { // 패턴에서 −1 : &usize → usize
if let Some(v) = slice.get_mut(i) { // 패턴에서 껍데기 벗김 : Option → &mut i32
*v = value; // 표현식에서 쓸 자리 지목 : 원본에 쓰기
}
}
&와 *가 두 개의 문법이 아니라 하나의 눈금이라는 걸 잡고 나니까, 세 줄이 따로 외울 것 없이 같은 규칙으로 읽혔어요. 표현식에서 만들고(+1), 표현식·패턴 어디서든 벗기고(−1), 쓸 때만 *로 자리를 콕 집는다. C의 &/*를 이미 알고 있었으니 새로 배운 건 딱 두 가지 — 패턴이라는 거울, 그리고 겹을 센다는 감각이었어요.