Rust에는 값 표현식과 장소 표현식이라는 두 종류의 표현식이 있습니다. 대부분은 자동 변환 덕분에 의식할 필요가 없지만, unsafe 코드에서는 두 표현식의 차이를 정확히 이해하는 것이 중요합니다. 이 글은 암묵적으로 일어나는 place-to-value 강제(coercion)를 드러내 보이며, 왜 어떤 코드는 UB가 되고 어떤 코드는 안전한지 예제를 통해 설명합니다.
Rust 언어의 미묘한 측면 가운데 하나는 실제로 표현식에 두 가지 종류가 있다는 점입니다: _값(value) 표현식_과 장소(place) 표현식. 대부분의 경우 프로그래머는 이 구분을 크게 의식하지 않아도 됩니다. 한쪽이 기대되는 곳에 다른 쪽 표현식이 오면 Rust가 친절히 자동 변환을 넣어주기 때문입니다. 하지만 unsafe 코드가 되면, 이 표현식의 이분법을 제대로 이해해야 할 때가 있습니다. 다음 예제를 보세요:
// 'packed' 구조체이므로, 이 타입의 정렬(alignment)은 1입니다.
#[repr(packed)]
struct MyStruct {
field: i32
}
let x = MyStruct { field: 42 };
let ptr = &raw const x.field;
// 이 줄은 괜찮습니다.
let ptr_copy = &raw const *ptr;
// 하지만 이 줄은 UB입니다!
// `ptr`는 `i32`에 대한 포인터이므로 메모리 접근 시 4바이트 정렬이 필요하지만,
// `x`는 정렬이 1일 뿐입니다.
let val = *ptr;
여기서는 아직 안정화되지는 않았지만 곧 안정화될 “원시(raw) 차용” 연산자 &raw const를 사용하고 있습니다. 안정된 형태로는 매크로 ptr::addr_of!로 알고 계실 텐데, & 구문을 사용하면 장소와 값의 상호작용이 더 명확해지므로 여기서는 이 표기를 쓰겠습니다.
마지막 줄이 UB인 이유는 ptr가 packed 구조체의 필드를 가리키고 있어서 정렬이 충분하지 않기 때문입니다. 그런데 어떻게 *ptr을 평가하는 것은 UB인데, &raw const *ptr을 평가하는 것은 괜찮을 수 있을까요? 표현식을 평가할 때는 일반적으로 먼저 부분 표현식을 평가한 후 그 결과를 가지고 무언가를 합니다. 하지만 *ptr은 &raw const *ptr의 부분 표현식이고, 방금 *ptr이 UB라고 했으니 &raw const *ptr도 UB여야 하지 않을까요? 이 글은 바로 그 주제에 대한 설명입니다.
(C와 C++에서 이를 접해 보신 분도 있을 겁니다. 그쪽에서는 장소/값 표현식을 각각 lvalue/rvalue 표현식이라고 부릅니다. 기본적인 문법적 개념은 Rust와 같지만, 정확히 어떤 경우가 UB인지는 다르므로 여기서는 Rust에만 집중하겠습니다.)
장소 표현식과 값 표현식의 이분법이 잡히지 않는 가장 큰 이유는 모든 것이 전부 암묵적으로 일어나기 때문입니다. 따라서 위와 같은 코드에서 실제로 무슨 일이 일어나는지를 이해하려면, 먼저 이 암묵적 구분을 코드에 명시적으로 드러낼 수 있는 새로운 문법을 도입해 보겠습니다.
보통 우리는 (일부) Rust 표현식의 문법을 대략 다음처럼 생각할 수 있습니다:
Expr ::=
Literal | LocalVar | Expr
+Expr |&BorMod Expr |*Expr |Expr
.Field | Expr=Expr | …BorMod ::=
|mut|raw``const|raw``mutStatement ::=
letLocalVar=Expr;| …
이 문법은 왜 *ptr = *other_ptr + my_var 같은 표현식을 쓸 수 있는지 직접 설명합니다.
하지만 장소와 값을 이해하려면, 두 종류의 표현식을 명시적으로 구분하는 또 다른 문법을 생각해 보는 것이 유익합니다. 먼저 문법을 제시하고, 예제로 설명하겠습니다:
ValueExpr ::=
Literal | ValueExpr
+ValueExpr |&BorMod PlaceExpr |PlaceExpr
=ValueExpr |loadPlaceExprPlaceExpr ::=
LocalVar |
*ValueExpr | PlaceExpr.FieldStatement ::=
letLocalVar=ValueExpr;| …
_값 표현식(ValueExpr)_은 값을 계산하는 표현식입니다. 예컨대 5 같은 리터럴, 5 + 7 같은 계산, 그리고 &my_var처럼 포인터 타입의 값을 계산하는 표현식이 여기에 속합니다. 하지만 이 문법에 따르면 my_var(지역 변수를 참조하는 표현식)는 값 표현식이 아닙니다. 이것은 _장소 표현식(PlaceExpr)_입니다. 그 이유는 my_var가 실제로는 메모리 속의 “장소”를 나타내기 때문입니다. 장소에 대해서는 여러 가지를 할 수 있습니다. (1) 그 장소의 내용을 메모리에서 불러와(load) 값으로 만들 수 있고, (2) 그 장소에 대한 포인터를 만들 수도 있으며(역시 값이지만 이 과정에서는 메모리에 접근하지 않습니다), (3) 어떤 값을 그 장소에 저장할 수도 있습니다(Rust에서는 () 값을 산출하지만, 실질적으로 중요한 것은 메모리 내용이 바뀌는 부작용입니다). 지역 변수 외에도 장소 표현식의 또 다른 주요 예시는 * 연산의 결과입니다. 이 연산은 값(포인터 타입)을 받아 장소로 바꿉니다. 또한 구조체 타입의 장소가 있으면 필드 선택을 통해 해당 필드만을 위한 장소를 얻을 수 있습니다.
이 설명이 다소 낯설게 들릴 수 있습니다. 이 문법대로라면 let new_var = my_var;는 실제로 유효한 문장이 아니기 때문입니다! 이런 코드를 받아들이기 위해 Rust 컴파일러는 필요할 때마다 load를 추가하여 문법에 맞는 형태로 자동 변환합니다.1 load는 장소를 받아들여, 이름 그대로 그 장소에서 메모리 로드를 수행해 현재 저장된 값을 얻습니다. 따라서 이 문장의 설탕을 걷어낸 형태는 let new_var = load my_var;가 됩니다.
좀 더 복잡한 예시로, 앞에서 언급한 대입 표현식 *ptr = *other_ptr + my_var는 *(load ptr) = load *(load other_ptr) + load my_var로 변환됩니다. load가 정말 많죠! 이 용어가 문법에 맞게 되려면 각 load가 왜 필요한지 직접 확인해 보면 도움이 됩니다. 특히 *는 값 표현식에 대해 동작하므로(그 장소에 저장된 포인터 값을 얻기 위해 load other_ptr이 필요) 장소 표현식을 만들어 내고, 그 결과를 +와 함께 사용하려면 다시 값을 얻기 위해 load가 필요합니다. 반면 =의 왼편에는 장소 표현식이 와야 하므로 거기서는 *의 결과를 load하지 않습니다.
load 연산자는 암묵적으로 도입되므로, 이를 “장소에서 값으로의 강제(place-to-value coercion)”라고 부르기도 합니다. 어디에 place-to-value 강제, 즉 load가 도입되는지를 이해하는 것이 이 글 맨 위 예제를 이해하는 핵심입니다. 그럼 더 명시적인 문법을 사용해 그 예제의 핵심 부분을 다시 써보겠습니다:
let ptr = &raw const x.field;
// 이 줄은 괜찮습니다.
let ptr_copy = &raw const *(load ptr);
// 하지만 이 줄은 UB입니다!
let val = load *(load ptr);
이제 왜 마지막 줄은 UB인데, 그 앞줄은 그렇지 않은지가 완벽히 설명됩니다! &raw const *(load ptr)는 단지 *(load ptr)라는 _장소를 로드 없이 계산_한 다음, &raw const로 그 장소를 값으로 바꿉니다. 이 점을 다시 강조합니다: 흔히 “포인터 역참조”라고 부르는 * 연산은 어떠한 방식으로도 메모리에 접근하지 않습니다. 이 연산은 포인터 타입의 값을 받아 그것을 장소로 바꾸기만 합니다. 실패할 일이 없는 순수 연산입니다. 마지막 줄에서는 *의 결과에 추가로 load가 적용되고, 그때 메모리 접근이 일어납니다. 그리고 이 경우에는 그 장소가 충분히 정렬되어 있지 않기 때문에 UB가 발생합니다.
정렬이 맞지 않은 장소를 만들어 내는 장소 표현식을 평가하는 것은 전혀 합법이며, 그런 정렬 불량 장소를 원시 포인터 값으로 바꾸는 것도 합법입니다. 일반적으로 UB 관점에서 장소는 원시 포인터와 거의 동일하다고 생각하시면 됩니다. 즉, 그것들이 유효한 값을 가리키거나 심지어 존재하는 메모리를 가리켜야 한다는 요구는 없습니다.2 그러나 정렬이 맞지 않은 장소에서 로드(또는 스토어)하는 것은 합법이 아니기 때문에 load *(load ptr)는 UB입니다.
다시 말해, *ptr이 값 표현식으로 사용될 때(우리의 예제처럼) 그것은 &raw const *ptr의 부분 표현식이 아닙니다. 왜냐하면 암묵적인 place-to-value 강제가 *ptr 주변에 추가 load를 더하는데, &raw const *ptr에서는 그 load가 추가되지 않기 때문입니다.
장소 표현식이 놀라운 동작을 보이는 또 다른 대표적 사례는 _ 패턴과의 조합입니다. 예를 들어:
let ptr = std::ptr::null::<i32>();
let _ = *ptr; // 이건 괜찮습니다!
let _val = *ptr; // 이건 UB입니다.
위에서 제시한 (단순화된) 문법으로는 이 프로그램을 표현할 수 없습니다. 실제 Rust의 전체 문법에서 let 구문은 대략 “letPattern=PlaceExpr;”와 같고, 그런 뒤 패턴 디슈거링이 그 장소 표현식을 어떻게 처리할지 결정합니다. 패턴이 바인더(일반적인 경우)라면, 그 바인더가 가리키는 지역 변수의 초기값을 계산하기 위해 load가 삽입됩니다. 그러나 패턴이 _라면, 장소 표현식은 여전히 평가되지만 그 평가 결과는 단순히 버려집니다. MIR는 이러한 의미를 나타내기 위해 PlaceMention 문장을 사용합니다.
특히 _ 패턴은 장소에서 값으로의 강제를 유발하지 않습니다! 이 코드의 핵심 부분을 디슈거링하면 다음과 같습니다:
PlaceMention(*(load ptr)); // 이건 괜찮습니다!
let _val = load *(load ptr); // 이건 UB입니다.
보시다시피 첫 번째 줄은 실제로 포인터에서 로드하지 않습니다(유일한 load는 포인터 자체를 보관하고 있는 지역 변수에서 그 값을 꺼내기 위한 것입니다). 장소 표현식이 _ 패턴과 함께 사용될 때는 어떤 값도 만들어지지 않습니다. 반대로 마지막 줄은 실제로 새로운 지역 변수를 만들므로, 그 변수의 초기값을 계산하기 위해 place-to-value 강제가 삽입됩니다.
match에서도 같은 일이 벌어집니다:
let ptr = std::ptr::null::<i32>();
match *ptr { _ => "happy" } // 이건 괜찮습니다!
match *ptr { _val => "not happy" } // 이건 UB입니다.
match 표현식의 검사 대상(scrutinee)은 장소 표현식이며, 패턴이 _라면 값이 결코 만들어지지 않습니다. 그러나 실제 바인더가 있는 경우에는 지역 변수가 도입되면서, 그 지역 변수에 저장할 값을 계산하기 위해 place-to-value 강제가 삽입됩니다.
주의: unsafe 블록에 대하여. 표현식을 블록으로 감싸면 그것은 강제로 값 표현식이 됩니다. 즉, unsafe { *ptr }는 항상 포인터에서 로드를 수행합니다! 다시 말해:
let ptr = std::ptr::null::<i32>();
let _ = *ptr; // 이건 괜찮습니다!
let _ = unsafe { *ptr }; // 이건 UB입니다.
중괄호가 값 표현식을 강제한다는 사실은 가끔 유용할 수 있지만, unsafe 블록까지 그렇게 되는 것은 꽤 아쉬운 일입니다.
지금까지는 값이 기대되는 곳에 장소 표현식이 오면 무슨 일이 일어나는지를 살펴보았습니다. 반대의 경우는 어떨까요? 다음을 보세요:
let x = &mut 15;
앞서의 문법에 따르면 &(여기서는 mut 수식과 함께)는 장소 표현식을 필요로 하지만, 15는 값 표현식입니다. Rust 컴파일러는 이 코드를 어떻게 받아들일까요?
이 경우 디슈거링은 새로운 “임시” 지역 변수를 도입하는 방식으로 일어납니다:
let mut _tmp = 15;
let x = &mut _tmp;
이 임시 변수의 정확한 유효 범위(scope)는 꽤 복잡한 규칙에 의해 정의되며, 이 글의 범위를 벗어납니다. 여기서 핵심은 이 변환을 통해 다시 한 번 더 명시적인 문법에 맞는 프로그램이 된다는 점입니다.
이 규칙에는 한 가지 예외가 있습니다. 대입 연산자의 왼편입니다. 만약 15 = 12 + 19처럼 작성한다면, 값 15가 임시 장소로 바뀌지 않고 프로그램은 거부됩니다. 이 경우 임시를 도입해도 의미 있는 결과가 나올 가능성이 매우 낮기 때문에, 그런 코드를 받아들일 이유가 없습니다.
값이 기대되는 곳에 장소 표현식을 쓰거나, 장소가 기대되는 곳에 값 표현식을 쓰면, Rust 컴파일러는 암묵적으로 앞서 제시한 문법에 부합하도록 우리 프로그램을 변형합니다. 안전한 코드만 작성할 때는 이 변환을 거의 완전히 잊어버려도 됩니다. 그러나 unsafe 코드를 작성하면서 어떤 프로그램은 UB가 되고 어떤 프로그램은 그렇지 않은지 이해하고자 한다면, 정확히 무슨 일이 일어나는지 아는 것이 중요합니다. 이 글에서 단 하나만 기억한다면, *는 포인터를 역참조하지만 _메모리에서 로드하지는 않는다_는 점입니다. 즉, *는 포인터를 장소로 바꿀 뿐이며, 실제 로드는 그 다음에 일어나는 암묵적 place-to-value 변환이 수행합니다. 이 암묵적 load 연산자에 이름을 붙여 주는 것이 장소와 값의 주제를 이해하는 데 도움이 되었기를 바랍니다. :)
Rust 컴파일러가 실제로 이렇게 노골적인 디슈거링을 수행하는 것은 아니지만, 프로그램을 MIR 형태로 컴파일하는 과정에서 암묵적으로 이런 일이 일어납니다.↩
다만 한 가지 미묘한 점은 PlaceExpr.Field 표현식이 offset 메서드의 규칙을 사용하여 경계 내(in-bounds) 포인터 산술을 수행한다는 것입니다. 장소 표현식이 기존 메모리를 가리키는지에 관심을 가지는 유일한 경우가 이것입니다. 이는 아쉬운 점이지만, 최적화에 큰 도움이 되며 offset_of! 매크로가 도입된 이후로는, 안전하지 않은 코드가 더 이상 존재하지 않는(dangling) 포인터에 대해 필드 선택을 해야 할 일은 극히 드뭅니다.↩