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는 C의 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는 *로 접근한다"는 제 이해는 표현식 자리에서만 맞았던 거예요. 같은 −1을 표현식과 패턴이 각자의 문법으로 해요.
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. 대입 왼쪽의 * — 바인딩을 바꾸는 것과 값을 쓰는 것
원래 제 코드가 틀린 진짜 이유가 여기 있었어요. 저는 이렇게 썼어요.
let v = slice.get_mut(i);
v = value; // 이걸로 슬라이스가 바뀔 거라 믿었음
v = value면 원소가 바뀔 줄 알았는데, 슬라이스는 그대로였어요. v는 바인딩이고, 바인딩에 새 값을 넣는 건 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 분해 : Option → &mut i32
*v = value; // 표현식에서 쓸 위치 지정 : 원본에 쓰기
}
}
- 표현식에서
&는 참조 단계를 +1,*는 −1 해요. C와 같아요. - 패턴에서
&는 참조 단계를 −1 해요. 패턴에는*가 없어요. let a = b는 참조 단계를 바꾸지 않아요.- 대입문 왼쪽의
*는 값을 쓸 위치를 지정해요. 빠뜨리면 바인딩만 바뀌고 원본은 그대로예요. - C의
&,*에 더해 새로 알게 된 건 두 가지예요. 패턴 자리의&가 표현식과 반대로 동작한다는 것, 그리고 참조 단계를 세면 세 가지 경우를 같은 방식으로 읽을 수 있다는 것이에요.