주소는 같고 권한만 다르다: 내 Rust 슬라이스가 안 바뀌던 이유
Rust를 배우다가 슬라이스 원소를 고치는 간단한 연습 문제를 풀었는데, 컴파일러가 에러를 하나씩 던지길래 시키는 대로 다 고쳤더니 컴파일은 되는데 아무것도 안 바뀌는 코드가 나왔어요. 왜 그런지 파고들다 보니 결국 &와 &mut이 뭐가 다른가로 내려가게 됐습니다.
이 글은 슬라이스가 실제로 뭔지, iter()와 iter_mut()이 왜 갈리는지, 그리고 *item의 *가 왜 꼭 필요한지를 다룹니다. split_at_mut과 unsafe, reborrow, 인덱스 루프와의 비교는 다루지 않아요. 아래 나오는 에러 메시지와 주소, 크기는 전부 rustc 1.96.0에서 직접 돌려본 값입니다.
시작은 이 코드였습니다.
pub fn transform_even_odd(slice: &mut [i32]) {
// Your code here: iterate over the mutable slice and modify its elements.
for &item in slice.iter() {
if item % 2 == 1 {
item -= 1
}
}
slice
}
들어가기 전에 — 용어 짚기
대여(borrow)와 참조(reference). Rust에서 모든 값에는 주인이 하나 있다. 주인은 값을 남에게 잠시 빌려줄 수 있고, 빌려간 쪽이 손에 쥐는 것이 참조다. &T는 읽기용으로 빌린 참조, &mut T는 고칠 수 있게 빌린 참조다. 빌린 것은 언젠가 돌려줘야 하므로, 참조는 원본보다 오래 살 수 없다.
Copy 트레이트. 값을 옮길 때 원본을 그대로 두고 사본을 만들어도 되는 타입에 붙는 표시다. i32처럼 크기가 작고 자기 밖의 자원을 안 붙들고 있는 타입이 여기 속한다. String은 힙에 데이터를 붙들고 있어서 Copy가 아니다. 이 구분이 뒤에서 이 글의 핵심이 된다.
unsized 타입과 fat pointer. 컴파일 타임에 크기를 알 수 없는 타입을 unsized라고 한다. [i32]가 그렇다 — i32가 몇 개인지 타입에 안 적혀 있기 때문이다. 이런 타입은 변수에 담을 수 없고 항상 참조 뒤에서만 산다. 그 참조는 주소만으로는 부족해서 길이를 함께 들고 다니는데, 이렇게 두 칸짜리가 된 포인터를 fat pointer라 부른다.
IntoIterator와 auto-deref. for x in y는 문법 설탕이고, 실제로는 y.into_iter()를 부른 뒤 나오는 이터레이터를 돌린다. auto-deref는 메서드를 부를 때 컴파일러가 *를 알아서 채워 넣는 편의 기능이다. item.abs()가 (*item).abs()로 조용히 번역되는 것이 그 예다.
1. 슬라이스는 데이터가 아니라 빌린 창이다
[i32]와 &[i32]는 비슷하게 생겼지만 다른 타입이다. 앞의 것을 변수에 담으려 하면 거절당한다.
error[E0277]: the size for values of type `[i32]` cannot be known at compilation time
| ^^ doesn't have a size known at compile-time
= help: the trait `Sized` is not implemented for `[i32]`
[i32]는 "i32가 연속으로 놓여 있는데 몇 개인지는 모르는 것"이다. 크기를 모르니 스택에 자리를 잡아줄 수 없고, 그래서 언제나 참조 뒤에서만 존재한다. 실제로 크기를 재보면 이렇게 갈린다.
size_of::<&i32>() = 8 (얇은 포인터: 주소만)
size_of::<&[i32]>() = 16 (뚱뚱한 포인터: 주소 + 길이)
size_of::<&mut [i32]>() = 16
size_of::<[i32; 3]>() = 12 (배열은 크기를 안다)
&[i32]가 16바이트인 이유는 주소 8바이트에 길이 8바이트를 얹었기 때문이다. [i32; 3]처럼 개수가 타입에 박힌 배열은 12바이트로, 데이터 그 자체다.
이 차이가 슬라이스의 성격을 정한다. 슬라이스는 데이터를 소유하지 않는다. 남의 데이터 위에 얹은 창(window)이고, "어디서부터 몇 개"라는 정보만 들고 있다.
원본 시작 0x4664aff5a8, 슬라이스 시작 0x4664aff5ac, 길이 2 // &v[1..3]
그러니 함수 시그니처의 slice: &mut [i32]는 "고칠 수 있게 빌린 창 하나"를 받는다는 뜻이다. 소유하지 않으므로 돌려줄 것도 없다. 맨 위 코드가 마지막에 slice를 반환하려 한 것은 그래서 틀렸다.
error[E0308]: mismatched types
10 | slice
| ^^^^^ expected `()`, found `&mut [i32]`
제자리에서 고치는 함수다. 반환할 게 없다.
2. 주소는 같다. 다른 건 권한이다
저는 iter_mut()을 써야 비로소 주소가 넘어오는 줄 알았어요. iter()는 값을 주고, iter_mut()은 주소를 줘서 고칠 수 있게 되는 거라고요. 절반만 맞았습니다.
iter()도 주소를 준다. 찍어보면 바로 드러난다.
배열 시작 주소 = 0xf350ddf49c
iter() item = 0xf350ddf49c (값 10)
iter() item = 0xf350ddf4a0 (값 20)
iter() item = 0xf350ddf4a4 (값 30)
iter_mut() item = 0xf350ddf49c (값 10)
iter_mut() item = 0xf350ddf4a0 (값 20)
iter_mut() item = 0xf350ddf4a4 (값 30)
주소가 완전히 같다. 크기도 같아서 &i32도 8바이트, &mut i32도 8바이트다. 기계 입장에서 둘은 구별되지 않는 포인터다.
그러니 차이는 주소를 받느냐 마느냐가 아니라, 그 주소에 어떤 권한이 붙어 있느냐다.
&i32— 이 주소로 읽기만 해라&mut i32— 이 주소로 읽고 쓰기 해라
권한은 타입에만 존재한다. &i32와 &mut i32는 서로 다른 타입일 뿐이고, 컴파일러는 *x = ...를 볼 때 x의 타입이 &mut _인지만 확인한다. 검사가 끝나면 그 정보는 실행 파일에 아무 흔적도 남기지 않는다. 런타임 비용이 없다.
읽기 전용 주소에 쓰려고 하면 이렇게 막힌다.
error[E0594]: cannot assign to `*item`, which is behind a `&` reference
2 | for item in slice.iter() {
| | help: use mutable method: `iter_mut()`
| this iterator yields `&` references
3 | *item -= 1;
| ^^^^^^^^^^ `item` is a `&` reference, so it cannot be written to
주소는 손에 있다. 역참조도 된다. 쓰는 것만 막힌다. 주소가 없어서 못 쓰는 게 아니라 권한이 없어서 못 쓴다.
3. &mut의 진짜 뜻은 "쓰기 가능"이 아니라 "나 혼자 본다"
그런데 왜 굳이 권한을 나누는 걸까요. mut 붙은 건 고쳐도 되고 안 붙은 건 안 되고, 이 규칙이 어디서 오는 건지 신기했거든요.
&mut의 의미는 쓰기 권한이 아니라 독점권이다. 세 경우를 컴파일해보면 규칙이 드러난다.
// &mut 두 개를 동시에
error[E0499]: cannot borrow `*v` as mutable more than once at a time
// & 가 살아있는데 &mut 을 또
error[E0502]: cannot borrow `*v` as mutable because it is also borrowed as immutable
// & 두 개를 동시에
(통과)
읽기 참조는 몇 개든 공존한다. 쓰기 참조는 하나뿐이고, 그것이 살아있는 동안에는 읽기 참조조차 함께 있을 수 없다. 공유하거나 변경하거나, 둘 중 하나다.
두 번째 경우가 왜 위험한지는 Vec을 키워보면 보인다. 용량이 차면 데이터는 힙에서 통째로 이사한다.
이사 #1: len 5 에서 0x1c18595a9f0 -> 0x1c185956460
이사 #2: len 9 에서 0x1c185956460 -> 0x1c1859529c0
이사 #3: len 17 에서 0x1c1859529c0 -> 0x1c185954480
20000번 push 하는 동안 데이터가 이사한 횟수: 10
이사 가기 전에 &v[0]으로 참조를 받아뒀다면, 그 참조는 이사 후에 아무도 살지 않는 빈 집 주소를 가리킨다. C++에서 iterator invalidation이라고 부르는 버그이고, Rust는 이것을 E0502로 컴파일 단계에서 막는다. 읽는 사람이 있는 동안에는 아무도 구조를 바꿀 수 없다.
여기서 한 가지 짚어둘 것이 있다. 위 이사는 항상 일어나지 않는다. realloc은 뒤 공간이 비어 있으면 제자리에서 늘리고, 실제로 Vec::with_capacity(2)에 세 번 밀어 넣어도 주소가 그대로였다. 20000번을 밀어붙이고 나서야 열 번의 이사를 잡았다.
즉 주소는 항상 바뀌는 것이 아니라 바뀔 수 있을 뿐이다. 컴파일러는 그 "수 있다"만으로 거절한다. 위험한 순간을 골라내려 애쓰지 않고 위험이 가능한 코드를 전부 거절한다. Rust가 가끔 답답하게 느껴지는 지점의 정체가 이것이다.
이제 함수 시그니처가 다르게 읽힌다. slice: &mut [i32]를 받았다는 것은 "이 슬라이스를 지금 너 혼자 보고 있다"는 보증을 넘겨받았다는 뜻이다. 고쳐도 되는 이유는 그 보증에서 따라 나온다. 혼자 보고 있으니 고쳐도 놀랄 사람이 없다.
4. for가 실제로 부르는 것, 그리고 3형제
iter_mut()이 궁금했어요. iter()도 있는데 저건 mut 슬라이스 전용인가, 아니면 뭔가 다른 건가 싶었거든요.
전용이 아니다. 가르는 것은 슬라이스의 타입이 아니라 **메서드가 요구하는 self**다.
pub fn iter(&self) -> Iter<'_, T> // 공유 참조만 있으면 된다
pub fn iter_mut(&mut self) -> IterMut<'_, T> // 가변 참조가 있어야 한다
그래서 규칙이 대칭이 아니다. iter()는 어디서나 부를 수 있고, iter_mut()은 가변 접근권을 쥐고 있을 때만 부를 수 있다.
| 손에 쥔 것 | iter() | iter_mut() |
|---|---|---|
&[i32] | 통과 | error[E0596]: cannot borrow *slice as mutable, as it is behind a & reference |
&mut [i32] | 통과 | 통과 |
let arr = [1,2,3] | 통과 | error[E0596]: cannot borrow arr as mutable, as it is not declared as mutable |
주목할 칸은 &mut [i32]에서 iter()가 통과하는 자리다. 독점권을 쥔 쪽이 스스로 공유 참조로 낮춰 쓰는 것은 자유다. 권한은 센 쪽에서 약한 쪽으로 내려올 수 있고, 그 반대는 안 된다.
같은 E0596이지만 문구가 갈리는 것도 눈여겨볼 만하다. &[i32]에서는 "behind a & reference"라며 빌린 경로가 불변임을 지적하고, let arr에서는 "not declared as mutable"이라며 소유한 변수가 불변임을 지적한다. mut은 타입에 붙은 딱지가 아니라 그 순간 손에 쥔 접근권이다.
for는 이 위에 얹힌 문법 설탕이다. for x in y는 y.into_iter()를 부른다. 그리고 into_iter()는 받는 사람의 종류를 그대로 따라간다.
슬라이스 s: &mut [i32] 에서
s.iter() -> &i32
s.iter_mut() -> &mut i32
s.into_iter() -> &mut i32 // 슬라이스에선 iter_mut()과 같다
&mut *s -> &mut i32 // 이렇게 써도 같다
소유한 대상에서
[String; 1] .into_iter() -> String
Vec<String> .into_iter() -> String
&[i32] .into_iter() -> &i32
T를 소유했으면 T를, &T를 쥐었으면 &T를, &mut T를 쥐었으면 &mut T를 내놓는다. iter()와 iter_mut()은 그중 두 경우를 명시적으로 고르는 지름길일 뿐이다.
5. 겉모습이 같고 결과가 정반대인 &와 *
맨 위 코드로 돌아옵니다. 저건 그냥 틀린 코드가 아니라 컴파일러가 안 막아주는 코드였어요. 에러를 순서대로 지워나가면 조용히 통과합니다.
반환문을 지우면 두 번째 에러가 드러난다.
error[E0384]: cannot assign twice to immutable variable `item`
2 | for &item in slice.iter() {
| ---- first assignment to `item`
4 | item -= 1
| ^^^^^^^^^ cannot assign twice to immutable variable
help: consider making this binding mutable
컴파일러가 mut을 붙이라고 한다. 시키는 대로 하면 컴파일이 통과한다. 그리고 아무 일도 일어나지 않는다. 같은 컴파일에서 나온 경고가 진짜 원인을 가리키고 있었다.
warning: value assigned to `item` is never read
slice.iter()는 &i32를 내놓고, 패턴 &item이 그것을 구조분해해 i32 값을 복사해 온다. item은 원본이 아니라 복사본이다. 주소를 찍어보면 명확하다.
iter() item = 0xf350ddf49c // 원본을 가리킨다
&item 패턴 복사본 주소 = 0xf350ddf5ac // 딴 데. 세 바퀴 내내 같은 자리
복사본은 스택 한 칸을 매 바퀴 재활용한다. 원본과는 아무 상관이 없다.
여기서 &가 두 자리에서 정반대로 동작한다는 것이 드러난다. 타입 자리의 &는 참조를 만들고, 패턴 자리의 &는 참조를 벗긴다. 그래서 for &item in은 참조를 받는 문법이 아니라 참조를 풀어서 알맹이를 꺼내는 문법이다.
세 갈래를 나란히 놓으면 이렇게 된다.
| 쓴 것 | 손에 들어오는 것 | 원본 수정 | 컴파일러 반응 |
|---|---|---|---|
for item in slice.iter() | 읽기 전용 주소 | 불가 | E0594로 막아준다 |
for &item in slice.iter() | 값 복사본 | 불가 | 조용히 통과한다 |
for item in slice.iter_mut() | 읽기·쓰기 주소 | 가능 | 통과한다 |
첫 줄을 골랐다면 컴파일러가 use mutable method: iter_mut()까지 알려줬을 것이다. &를 붙이는 순간 문제가 "권한 부족"에서 "복사본을 고쳤다 버림"으로 바뀌면서, 에러가 경고로 강등됐다.
이것이 이 버그의 성격이다. &item 패턴은 안전망을 뚫는 것이 아니라 안전망이 걸린 문제 자체를 다른 문제로 바꿔버린다. 참조를 벗겨 값으로 만드는 순간 소유권 문제가 사라지고, 지역 변수를 고쳤다 버리는 평범한 논리 버그가 된다. 소유권 검사기는 논리 버그를 잡는 도구가 아니다.
그리고 이 변환은 i32가 Copy이기 때문에 조용히 일어난다. 원소가 String이었다면 같은 패턴이 이렇게 막힌다.
error[E0507]: cannot move out of a shared reference
7 | for &item in v.iter() { ... }
| ---- ^^^^^^^^
| data moved here because `item` has type `String`,
| which does not implement the `Copy` trait
help: consider removing the borrow
Copy 타입에서만 열리는 함정이다. String이었다면 컴파일러가 처음부터 막아섰을 것이고, 이 글을 쓸 일도 없었을 것이다.
6. *는 값을 꺼내는 게 아니라 자리를 지목한다
그럼 주소가 넘어오니까 실제 값을 고치려면 *item으로 해야겠구나 — 여기까지 짚고 넘어가려 했어요. 맞는 말인데, 확인해보니 쓸 때와 읽을 때가 갈리더라고요.
쓸 때는 예외가 없다.
error[E0368]: binary assignment operation `-=` cannot be applied to type `&mut i32`
3 | item -= 1;
help: `-=` can be used on `i32` if you dereference the left-hand side
읽을 때는 봐주는 범위가 있는데, iter()냐 iter_mut()이냐에 따라 다르다.
| 표현 | &i32 (iter()) | &mut i32 (iter_mut()) |
|---|---|---|
*item % 2 | 통과 | 통과 |
item % 2 | 통과 | E0369 |
*item > 0 | 통과 | 통과 |
item > &0 | 통과 | E0308 |
item.abs() | 통과 | 통과 |
item.pow(2) | 통과 | 통과 |
메서드 호출은 둘 다 봐준다. auto-deref가 (*item).abs()로 바꿔주기 때문이다. 반면 연산자는 &i32만 봐준다. 표준 라이브러리에 &i32 % i32 구현은 있지만 &mut i32용은 없기 때문이다. 그래서 고친 코드에서 if *item % 2 != 0의 *가 실제로 필요했다.
쓰기에만 예외가 없는 이유는 item이 그 자체로 하나의 변수이기 때문이다. 안에 주소가 담긴 변수다. item = ...이라고 쓰면 "이 변수에 다른 주소를 넣어라"가 되어, 가리키는 대상이 아니라 가리키는 방향을 바꾸는 문장이 된다. item -= 1이 E0368인 것도 주소에서 1을 빼라는 말이 되기 때문이다.
*item -= 1이라고 써야 비로소 "그 주소가 가리키는 칸에서 1을 빼라"가 된다. 그러니 *는 값을 꺼내는 연산자라기보다, 참조 말고 참조가 가리키는 자리를 지목하는 연산자로 보는 편이 정확하다. 대입문 왼쪽에 왔을 때 값을 꺼내는 것이 아니라 쓸 자리를 고르는 것을 보면 그렇다.
for &item in의 &와 *item의 *는 둘 다 참조를 벗기는 모양을 하고 있다. 하나는 사본에 닿고 하나는 원본에 닿는다.
7. 곁가지: %는 modulo가 아니다
-3 % 2 != 0으로 홀수를 판별하는 게 좀 크랙 같았어요.
크랙이 아니라 관용 표현이다. 틀린 쪽은 == 1이다. Rust의 %는 modulo가 아니라 나머지(remainder)라서 부호가 피제수를 따라간다.
n=-3 n%2!=0 -> true n&1==1 -> true rem_euclid -> true
n=-2 n%2!=0 -> false n&1==1 -> false rem_euclid -> false
n= 3 n%2!=0 -> true n&1==1 -> true rem_euclid -> true
-3 % 2는 1이 아니라 -1이다. 그래서 item % 2 == 1은 음수 홀수를 통째로 놓친다. n % 2 != 0, n & 1 == 1, n.rem_euclid(2) == 1 셋 다 음수에서 맞게 동작한다. is_multiple_of라는 메서드도 있는데 u32에는 있고 i32에는 없다.
다시 정리하면
고친 코드는 이렇게 됩니다.
pub fn transform_even_odd(slice: &mut [i32]) {
for item in slice.iter_mut() { // item: &mut i32
if *item % 2 != 0 {
*item -= 1;
}
}
}
세 군데가 바뀌었어요. iter()를 iter_mut()으로, 패턴 &item을 그냥 item으로, 그리고 대입할 때 *로 역참조. 반환문은 뺐고요.
결국 제가 오해한 건 하나였습니다. &mut [i32]를 받았으니 그 안의 원소도 자동으로 고칠 수 있다고 생각했어요. 그런데 mut은 데이터에 붙은 딱지가 아니라 그 순간 내가 쥐고 있는 접근권이고, 순회할 때 그 권한을 이터레이터에게 명시적으로 넘겨줘야 하더라고요. iter()를 부르는 순간 스스로 권한을 낮춰 쓴 셈이었습니다.
그리고 &mut이 "고쳐도 된다"가 아니라 "지금 이걸 보는 사람은 너뿐이다"라는 뜻이라는 걸 알고 나니, 왜 컴파일러가 그렇게까지 깐깐한지 납득이 됐어요. 고칠 수 있는 건 그 독점에서 따라 나오는 결과지 그 자체가 목적이 아니었던 거죠.
제일 무서웠던 건 컴파일러가 안 막아준 그 지점이에요. Rust는 다 잡아준다고 생각했는데, &item 패턴 하나로 소유권 문제가 평범한 논리 버그로 바뀌면서 검사기의 사정거리 밖으로 빠져나갔거든요. i32가 Copy라서 열린 문이었고, String이었으면 애초에 컴파일이 안 됐을 겁니다. 언어가 지켜주는 범위를 안다는 건, 지켜주지 않는 범위를 안다는 뜻이기도 한 것 같아요.