주소는 같고 권한만 다르다: 내 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는 문법 설탕(syntactic sugar)이고, 실제로는 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바이트로, 데이터 그 자체예요.
이 차이가 슬라이스의 성격을 정해요. 슬라이스는 데이터를 소유하지 않아요. 다른 곳에 있는 데이터의 일부를 가리키고, "어디서부터 몇 개"라는 정보만 갖고 있어요.
원본 시작 0x4664aff5a8, 슬라이스 시작 0x4664aff5ac, 길이 2 // &v[1..3]
그러니 함수 시그니처의 slice: &mut [i32]는 "수정할 수 있게 빌린 슬라이스 하나"를 받는다는 뜻이에요. 소유하지 않으므로 돌려줄 것도 없어요. 맨 위 코드가 마지막에 slice를 반환하려 한 것은 그래서 틀렸어요.
error[E0308]: mismatched types
10 | slice
| ^^^^^ expected `()`, found `&mut [i32]`
제자리에서 고치는 함수라서 반환할 게 없어요.
2. 주소는 같고, 권한이 달라요
저는 iter_mut()을 써야 비로소 주소가 넘어오는 줄 알았어요. "iter()로 하면 item 수정 못하고 iter_mut()ㄹ 해야 item 수정할 수 있게 주소 받는건가?" 싶었거든요. 절반만 맞았어요.
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의 핵심은 쓰기 권한보다 독점적인 접근이에요. 세 경우를 컴파일해 보면 규칙이 보여요.
// &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가 실제로 부르는 것, 그리고 세 가지 메서드
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. 대입문 왼쪽의 *는 쓸 위치를 지정해요
"아 그럼 궁금한게 iter() 나 iter_mut() 하면 주소값이 오니까 실제 값 수정하려면 *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으로, 그리고 대입할 때 *로 역참조. 반환문은 뺐고요.
&i32와&mut i32는 같은 주소를 갖고, 다른 건 권한이에요.&mut은 독점적인 접근이에요. 그래서 동시에 읽는 참조가 있으면 만들 수 없어요.&mut [i32]를 받았어도iter()를 부르면 원소를 읽기 전용으로 받아요. 수정하려면iter_mut()으로 가변 권한을 넘겨야 해요.- 패턴의
&item은 참조를 벗겨 값을 복사해요.i32처럼Copy인 타입에서는 에러 없이 통과해서, 복사본만 고치는 버그가 생겨요. - 대입할 때는
*item처럼*로 쓸 위치를 지정해야 원본이 바뀌어요.