겹을 센다: Rust에서 &와 *가 자리에 따라 하는 일

Rust 슬라이스 문제를 하나 풀다가 컴파일러한테 세 번 혼났어요. 그런데 세 에러가 결국 한 얼굴이더라고요 — 전부 &*를 제자리에 못 놓은 거였어요. &는 C에서 하던 그대로 주소를 주는 줄로만 알았는데, Rust에선 같은 글자가 자리에 따라 반대로 작동하거든요. 그게 안 잡히니 for &i도, *v = value도, let a = *b도 죄다 흐릿했어요.

이 글은 그 &* 딱 두 개가 자리에 따라 뭘 하는지만 다뤄요. lifetime, match ergonomics, &mut의 독점권(공유냐 변경이냐)은 다루지 않아요 — 마지막 건 지난 글에서 이미 팠고, 앞의 둘은 이번에 제가 안 걸린 곳이라 다음으로 미뤘어요. 발단이 된 get_mutOption을 돌려주는 얘기도 도입으로만 쓰고 지나갈게요.

들어가기 전에 — 용어 짚기

  • 표현식(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는 그 주소를 따라가 값을 꺼낸다.

Rust
let x: i32 = 42;
let p = &x;    // C 의 &x : x 의 주소
TEXT
&x  타입 &i32   값(주소) 0x16f336644
x 의 진짜 주소            0x16f336644     // 완전히 같음
*p  = 42                                 // C 의 *p 와 같다

&i32int*, 그 이상도 이하도 아니다. 딱 하나 Rust가 얹은 편의가 있는데, 읽을 때는 *를 자주 생략할 수 있다. C라면 *p라 써야 할 자리를 그냥 p로 넘어간다.

Rust
println!("{}", p);   // C: printf("%d", *p) — 여기선 *p 안 써도 42
let s = p + 1;       // &i32 + i32 도 됨 → 43

이 "읽을 때 자동, 쓸 때 수동"이 나중에 *v = value에서 발목을 잡는데, 그건 5절에서.

2. &&는 주소의 주소

여기서 제가 실제로 물었던 게 "&&는 주소의 주소야?"였는데, 맞았어요. 그리고 이걸 확인하고 나서야 "겹"이라는 게 머릿속 비유가 아니라 진짜 메모리 사슬이라는 게 잡혔어요.

&&x는 "x의 주소를 담은 변수"의 주소다. C의 이중 포인터(int**)와 같다.

Rust
let x: i32   = 42;
let p: &i32  = &x;    // p = &x     (x 의 주소)
let pp: &&i32 = &p;   // pp = &p    (p 가 놓인 자리의 주소)
TEXT
x  가 놓인 주소   0x...5e4
p  가 담은 주소   0x...5e4      ← p ──▶ x
p  가 놓인 주소   0x...5e8
pp 가 담은 주소   0x...5e8      ← pp ──▶ p

pp ──▶ p ──▶ x
0x5e8  0x5e4  42

벗길 때도 사슬을 따라 한 칸씩 간다.

TEXT
*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):

TEXT
--- 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)

안 되는 것들도 전부 이 산수로 설명된다.

TEXT
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가 안 되는 건 겹 계산 이전의 문제다 — 패턴 자리엔 *라는 문법이 아예 없다.

TEXT
let b: i32 = 7; let *a = b;   -> error: expected pattern, found `*`

4. 패턴 자리의 & — 표현식의 거울

발단이 여기였어요. for &i in indices에서 왜 &가 붙는지가 안 풀렸거든요. 저는 for 왼쪽의 i를 "변수 선언"으로 읽고 있었어요. 그러니 &i가 "참조를 선언한다"로 보여서 영영 이상했던 거예요. 게다가 저는 "&a*로 접근하는 것"으로 알고 있었는데, 패턴의 &가 그걸 정면으로 뒤집는 것처럼 보였어요.

두 혼동 다 같은 오해에서 나온다: forlet의 왼쪽은 변수 선언이 아니라 패턴이다. 패턴은 이름을 짓는 게 아니라 모양을 맞추는 자리다.

그리고 패턴의 &가 "만들기"가 아니라 "벗기기"인 이유는 하나다 — 패턴은 표현식의 거울이다. 오른쪽에서 어떻게 만들어졌을 값인지를 왼쪽에 똑같이 그려놓으면, 컴파일러가 그걸 역으로 푼다.

Rust
// 만든다 (표현식)      // 뜯는다 (패턴)
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*로 접근"이라는 제 이해는 틀린 게 아니라 표현식 자리에서만 맞았던 것이다. 같은 벗기기를 두 세계가 각자의 문법으로 한다.

TEXT
let a = *r    // 표현식에서 −1
let &a = r    // 패턴에서 −1     → 둘이 같은 값

for &i in indices가 곧 for i in indices { let i = *i; }인 이유가 이거다. indices.iter()의 아이템은 &usize(1겹)인데 get_mutusize(0겹)를 원하니, 어딘가에서 −1을 해야 한다. 받는 쪽(패턴)에서 하면 for &i, 쓰는 쪽(표현식)에서 하면 get_mut(*i). 같은 산수의 두 표기다.

5. 대입 왼쪽의 * — 갈아끼우기 vs 쓰기

원래 제 코드가 틀린 진짜 이유가 여기 있었어요. 저는 이렇게 썼어요.

Rust
let v = slice.get_mut(i);
v = value;                  // 이걸로 슬라이스가 바뀔 거라 믿었음

v = value면 원소가 바뀔 줄 알았는데, 슬라이스는 꿈쩍도 안 했어요. v는 바인딩이고, 바인딩에 새 값을 넣는 건 이름표를 딴 데 옮겨 붙이는 것이지 원본을 건드리는 게 아니었어요.

v = value*v = value는 겉보기만 비슷하고 하는 일이 정반대다.

Rust
v  = value;   // v 라는 바인딩을 갈아끼워라   → 원본과 무관
*v = value;   // v 가 가리키는 자리에 써라    → 원본에 닿음

1절의 "읽을 때는 * 자동, 쓸 때는 수동"이 여기서 값을 한다. 읽기는 컴파일러가 알아서 벗겨주지만, 쓰기는 어느 자리에 쓸지를 사람이 *로 지목해야 한다. 그래서 대입문 왼쪽의 *는 "값을 꺼내는" 연산이 아니라 "쓸 자리를 고르는" 연산으로 읽는 게 정확하다. * 없이 v = value라 쓰면 컴파일러는 "아, 이름표를 옮기려는군" 하고 조용히 받아들이고, 슬라이스는 그대로 남는다.

다시 정리하면

결국 제 오답의 정답 코드는 이 세 줄인데, 셋이 전부 &/* 얘기의 변주였어요.

Rust
for &i in indices {                      // 패턴에서 −1 : &usize → usize
    if let Some(v) = slice.get_mut(i) {  // 패턴에서 껍데기 벗김 : Option → &mut i32
        *v = value;                      // 표현식에서 쓸 자리 지목 : 원본에 쓰기
    }
}

&*가 두 개의 문법이 아니라 하나의 눈금이라는 걸 잡고 나니까, 세 줄이 따로 외울 것 없이 같은 규칙으로 읽혔어요. 표현식에서 만들고(+1), 표현식·패턴 어디서든 벗기고(−1), 쓸 때만 *로 자리를 콕 집는다. C의 &/*를 이미 알고 있었으니 새로 배운 건 딱 두 가지 — 패턴이라는 거울, 그리고 겹을 센다는 감각이었어요.