Rust의 unsafe 코드 맥락에서 안전 불변식과 유효성 불변식을 구분하고, 컴파일러와 안전한 코드가 각각 의존하는 성질을 예제로 살피며, 사용자 정의 타입과 최적화, 참조·함수 포인터·union 같은 사례를 통해 두 불변식의 역할과 경계를 설명한다.
Rust의 타입 시스템을 unsafe 코드 맥락에서 이야기할 때, 논의는 흔히 불변식(invariant)으로 귀결됩니다. 즉, 언어가 일반적으로 그렇게 가정하기 때문에 항상 성립해야 하는 성질들입니다. 사실, Unsafe Code Guidelines 태스크포스의 중요한 임무 중 하나가 바로 이러한 불변식이 무엇인지에 대한 이해를 심화하는 것입니다.
그런데 제 생각에는, 불변식이 하나만 있는 것이 아니라는 점도 중요합니다. 불변식을 신뢰하는 주체가 (적어도) 둘이기 때문입니다. 컴파일러, 그리고 안전하게 사용할 수 있는 코드(의 작성자)입니다. 이 구분은 최근 논의에서 충분히 자주 등장해서, 한 번 제대로 글로 정리해두고 앞으로는 여기를 링크해도 될 것 같습니다. :)
Rust에서 아마 가장 대표적인 불변식은 제가 안전 불변식(safety invariant)이라고 부를 것입니다. 이는 모든 안전한 함수가 성립한다고 가정할 수 있는 불변식입니다. 이 불변식은 타입에 의존하므로, 데이터가 타입 T에서 안전하다(safe at type T)라고도 말하겠습니다. (사실, 타입은 공유를 적절히 다루기 위해 한 가지 이상의 안전 불변식을 동반하지만, 이번 글의 주된 관심사는 아닙니다.)
예를 들어 fn foo(x: bool) 같은 함수를 작성할 때, x는 bool에 대해 안전하다고 가정할 수 있습니다. 즉, x는 true이거나 false입니다. 이는 bool에 대한 모든 안전한 연산(특히 분기)이 정의되지 않은 동작을 유발하지 않고 수행될 수 있음을 의미합니다.
사실, 이것이 안전 불변식의 핵심 요지입니다:
안전 불변식은, 안전한 코드가 무엇을 하든 안전하게 실행되도록 보장합니다. 즉, 이 데이터로 작업할 때 정의되지 않은 동작을 일으킬 수 없습니다.
이를 바탕으로, 다른 타입들의 불변식도 채워 넣기 어렵지 않습니다. 예를 들어 &bool은 정렬된(aligned), NULL 이 아니고(non-NULL), 할당된 읽기 전용 메모리를 가리키는 참조이며(안전한 코드는 역참조할 수 있으므로), 그 메모리에 저장된 데이터는 bool에서 안전합니다(역참조 후 그 bool로 안전한 작업을 할 수 있으므로). 이런 "구조적 재귀"는 안전 불변식에서 곳곳에 나타납니다. x가 (T, U)에서 안전하려면 x.0이 T에서 안전하고 x.1이 U에서 안전하면 됩니다. enum의 경우, 예를 들어 x가 Option<T>에서 안전하려면 판별자(discriminant)가 None이거나, Some이고 그 데이터가 T에서 안전해야 합니다. 비슷하게, x가 !에 대해 안전한 경우는 결코 없습니다. x가 가질 수 있는 유효한 판별자가 존재하지 않기 때문입니다.
i32 같은 정수 타입을 이야기할 때는, 모든 데이터가 i32에서 안전하다고 말하고 싶어질 수 있습니다. 하지만 실제로는 그렇지 않습니다. 이전 글에서 설명했듯이, 데이터를 구성하는 "바이트"는 단지 0..256 범위보다 더 많은 가능한 상태를 가집니다. 특히, 초기화되지 않은 상태(LLVM에서는 undef 또는 poison)가 있습니다. if x != 0 { ... } 같은 일부 연산은 x가 초기화되지 않았으면 정의되지 않은 동작입니다. 그러므로 안전 불변식이 안전한 코드의 안전한 실행을 뒷받침하도록 충분히 강해야 한다는 주제에 맞춰, x가 i32에서 안전하려면 초기화되어 있어야 한다고 선언해야 합니다.
참조 타입의 경우, 세부 사항 중 일부는 정의하기가 더 어렵지만, 대략적으로는 다음과 같이 말할 수 있습니다. 포인터가 &'a mut T에서 안전하려면 정렬되어 있고, NULL이 아니며, 할당된 메모리를 가리키고, 수명 'a 동안에는 그 메모리를 다른 포인터가 접근하지 않아야 하며, 그 위치의 메모리에 저장된 데이터가 T에서 안전해야 합니다. 유사하게(그리고 내부 가변성은 무시하면), 포인터가 &'a T에서 안전하려면 정렬되어 있고, NULL이 아니며, 수명 'a 동안에는 그 메모리가 변경되지 않아야 하고, 그 메모리에 저장된 데이터가 T에서 안전해야 합니다. 앞서 논의한 &bool의 안전성은 이러한 일반 정의의 한 특수한 경우일 뿐입니다. 특히, 안전한 &!는 존재할 수 없습니다. 그것은 안전한 !를 가리켜야 하는데, 그런 값은 존재하지 않기 때문입니다.
함수 포인터나 트rait 객체 같은 고계(higher-order) 데이터에서는 안전 불변식이 특히 흥미롭습니다. 예를 들어, f가 fn(bool) -> i32에서 안전한 경우는 언제일까요? 다시 물어볼 수 있습니다. 안전한 코드가 f로 무엇을 할 수 있나요? 유일하게 할 수 있는 것은 그 함수를 호출하는 것입니다! 따라서, f가 fn(bool) -> i32에서 안전하려면, 그 함수를 bool에서 안전한 데이터로 호출할 때마다 정의되지 않은 동작 없이 실행되고, 그리고 반환한다면(발산하거나 언와인딩하지 않고) i32에서 안전한 데이터를 반환해야 합니다.1 이는 일반적인 방식으로 임의의 함수 타입으로 일반화됩니다.
지금까지는 내장 타입만을 이야기했습니다. 하지만 사용자 정의 타입을 이야기할 때 안전 불변식은 정말로 흥미로워집니다. Rust 타입 시스템의 핵심 속성은 사용자들이 자신만의 안전 불변식을 정의할 수 있다는 것입니다. (물론 이는 Rust에만 국한된 것은 아니지만, 흔한 "타입 건전성(type soundness)" 논의에서는 다루지 않는 성질입니다. 더 깊이 파고들고 싶다면 Derek Dreyer의 이 강연을 보세요.)
예시로 하나를 들어보겠습니다(이는 제 초기 블로그 글 중 하나에서도 사용한 바 있습니다). Vec을 보죠. Vec은 대략 다음과 같이 정의됩니다(실제 정의는 앞의 두 필드를 RawVec으로 묶습니다):
pub struct Vec<T> {
ptr: Unique<T>, // 힙에 할당된 데이터 저장소를 가리킴
cap: usize, // 저장소에 들어갈 수 있는 원소의 개수
len: usize, // 저장소에서 실제로 초기화된 원소의 개수
}
지금까지 말한 바에 따르면, Vec<T>의 안전 불변식을(필드의 안전성을 구조적으로 요구하는 수준에서) 정하면 별로 유용하지 않을 것입니다. Unique<T>는 단지 NULL 이 아닌(raw) 포인터이고, usize는 초기화만 되어 있으면 무엇이든 될 수 있으니까요.
하지만 Vec이 동작하려면, ptr는 크기 cap * mem::size_of::<T>()의 유효한 메모리를 가리켜야 하고, 그 메모리의 앞 len개 원소는 T에서 안전해야 합니다. 이는 Vec을 구현하는 unsafe 코드가 유지하는 불변식이며, Vec의 안전한 API 표면이 외부 세계가 지켜주기를 기대하는 불변식입니다. 이것이 가능한 이유는 이 불변식에 언급된 필드들이 모두 private이기 때문입니다. 따라서 안전한 코드는 이 불변식을 깨뜨릴 수 없고, 깨진 불변식을 이용해 난장판을 만들 수도 없습니다. 다시 말해, 안전 불변식은 안전한 코드의 안전한 실행을 보장하기 위한 것입니다. 물론 unsafe 코드는 이 불변식을 깰 수 있지만, 그러면 잘못한 것(Doing It Wrong(TM))일 뿐입니다.
프라이버시와 추상화 장벽 덕분에, Rust의 타입은 자신만의 안전 불변식을 정의할 수 있고, 그 불변식을 외부 세계가 존중해주기를 기대할 수 있습니다. Vec에서 보았듯이, 제네릭 타입이 관련되면 이런 사용자 정의 안전 불변식은 종종 구조적 요소를 가지게 됩니다. 즉, Vec<T>에서 안전하다는 것이 T에서 안전하다는 것으로 정의되는 식입니다.
우리는 컴파일러가 데이터에 대해 어떤 타입 기반 가정이라도 하기를 원합니다. 사실 이미 그렇게 하고 있습니다. Option<bool>은 bool이 가능한 값이 두 개뿐이라는 사실을 활용하는 방식으로 저장됩니다. repr(u32)(혹은 다른 정수 타입)을 가진 enum을 전달할 때, 우리는 LLVM에 그 값이 enum 변형 중 하나라고 가정해도 된다고 알려줍니다. 참조 타입에 대해서는 항상 정렬되어 있고 역참조 가능하다고 LLVM에 알립니다. 아마 제가 모르는 더 많은 속성들도 사용 중일 겁니다.
그래서, 컴파일러가 항상 성립한다고 가정해도 되는 불변식이 필요합니다. 우리가 이미 수행 중인 최적화(그리고 앞으로 하고 싶은 더 많은 최적화)를 정당화하기 위해서요. 컴파일러가 의존하는 불변식이, 안전한 코드가 의존하는 안전 불변식과 같아야 할 선험적 이유는 없습니다. 이 구분을 쉽게 이야기하기 위해, 컴파일러 입장의 불변식을 유효성(validity)이라고 부르겠습니다. 그러면 어떤 데이터가 타입 T에서 유효한지(valid at type T)를 물을 수 있게 됩니다. 컴파일러는 모든 데이터가 주어진 타입에서 항상 유효하다고 가정하므로, 유효성을 위반하는 순간 그것은 즉시 정의되지 않은 동작이 됩니다.
유효성 불변식은 컴파일러가 최적화(예: enum 레이아웃 최적화)를 수행하는 데 활용됩니다.
그렇다면 유효성 불변식을 안전 불변식과 같다고 정의하고 끝내면 안 될까요? 그 접근에는 몇 가지 문제가 있다고 생각합니다.
우선, 사용자 정의 불변식에 대해서는 명백히 작동하지 않습니다. 예를 들어, Vec의 용량을 늘리는 코드를 보면, 먼저 ptr를 갱신하고 그 다음에 cap을 갱신합니다. 이 두 갱신 사이에는 Vec의 안전 불변식이 깨집니다. 하지만 괜찮습니다. 이는 주의 깊게 통제된 unsafe 코드이고, Vec이 다시 안전한 코드로 돌아갈 때쯤에는 불변식이 재확립되기 때문입니다. (데이터 레이스가 없다고 가정할 수 있으므로 동시성 문제도 없습니다.)
unsafe 코드는 안전한 코드의 경계에서만 안전 불변식을 지키면 됩니다.
여기서 "경계"가 꼭 unsafe 블록이 끝나는 지점이라는 뜻은 아닙니다. 경계는 unsafe 코드가 안전한 코드가 사용하도록 의도한 공용 API를 제공하는 지점에서 생깁니다. 그 지점은 모듈 경계일 수도 있고, 심지어 크레이트 수준일 수도 있습니다. 그곳에서 안전한 코드는 안전성에 의존할 수 있어야 하며, 따라서 unsafe 코드와 상호작용해도 정의되지 않은 동작을 유발하지 않게 됩니다. 그리고 바로 그곳에서 안전 불변식이 중요한 역할을 합니다.
이는 항상 성립해야 하는 유효성과 강한 대조를 이룹니다. 레이아웃 최적화와 LLVM의 속성들은 unsafe 코드 전반에 걸쳐 효력을 발휘하므로, 유효하지 않은 데이터가 존재해도 되는 순간은 결코 없습니다. (단 하나의 예외는 컴파일러가 정적으로 초기화되지 않았음을 아는 데이터입니다. 예를 들어 let b: bool;을 작성하면, b의 데이터는 unsafe 코드에게조차 접근 불가능하게 유지되며 어떤 불변식도 만족할 필요가 없습니다. 컴파일러가 b가 초기화되지 않았음을 알고 있기 때문에 가능한 일입니다.)
unsafe 코드는 유효성 불변식을 항상 지켜야 합니다.
따라서 두 불변식을 동일하게 선택할 수는 없습니다. 그렇게 하면 Vec을 작성하는 것이 불가능해집니다. 유효성에 관해서는 사용자 정의 불변식을 그냥 무시하고 싶을 수도 있지만, 그건 현명하지 않다고 봅니다.
우선, 유효성은 정의되지 않은 동작의 정의 일부입니다. 언젠가는 가능한 한 많은 정의되지 않은 동작을 검사하고 싶고, 정의를 내릴 때는 가능한 한 검사 가능(checkable)하게 만들고자 합니다. 위로 올라가 fn(bool) -> i32 같은 타입의 안전성을 보면, 그것은 본질적으로 검사 가능하지 않습니다. f가 fn(bool) -> i32에서 안전한지 확인하려면 정지 문제를 풀어야 하니까요!
게다가, 제가 작년에 Types as Contracts를 평가했을 때(이는 프로그램 전체 실행 동안 꽤 강한 불변식을 강제하려는 시도였지만, 그래도 위에서 정의한 안전성보다는 약했습니다), 안전 불변식을 일시적으로 위반하는 unsafe 코드 사례를 많이 발견했습니다. 예를 들어 Arc의 소멸자는, 할당 해제된 Box를 포함하는 구조체에 대한 참조를 가지고 Layout::for_value를 호출합니다. 많은 unsafe 코드는 T가 아직 완전히 초기화되지 않은 상태에서 &mut T를 전달합니다. 우리는 그 모든 코드가 잘못되었고 raw 포인터를 써야 한다고 결정할 수도 있지만, 그러기에는 너무 늦었다고 생각합니다.
대신 제가 가까운 미래에 하고 싶은 일 중 하나는 우리의 유효성 불변식이 무엇인지 더 잘 이해하는 것입니다. 우선, 이는 unsafe 코드 논의에서 계속 반복해서 등장하는 주제입니다. 둘째로, 유효성 불변식은 검사 가능해야 하므로, (위에서 링크한 이전 글들처럼 안전 불변식에 대해 이야기할 때처럼 모든 이에게 분리 논리의 박사과정 수준 과목을 가르치지 않고도) 정확히 이야기할 수 있습니다.2 예를 들어 Miri에서 이 불변식을 강제하려면 어떤 검사를 해야 하는지에 대해 이야기할 수 있습니다. 마지막으로, 유효성 불변식에는 "여지(wiggle room)"가 있습니다.
이 마지막 점은, 지금 보수적인 선택을 할 수 있다는 뜻입니다(유효하다고 간주하는 데이터를 최소화). 그리고 나중에 기존 코드를 깨뜨리지 않고 유효하다고 간주하는 데이터의 범위를 넓힐 수 있습니다. 불변식을 약화시키면 이전에는 정의되지 않은 동작이었던 코드가 이제는 잘 정의되는 방향으로만 영향을 줍니다. 이는 안전 불변식과 대조적입니다. 안전 불변식은 약화하거나 강화할 수 없습니다. 어떤 unsafe 코드는 그 불변식을 가정하고 데이터를 "소비"할 수 있고, 우리가 불변식을 약화하면 그 코드는 깨질 수 있습니다. 또 다른 코드 조각은 그 불변식을 만족하는 데이터를 "생산"할 수 있고, 우리가 불변식을 강화하면 그 코드는 깨질 수 있습니다.
그럼 몇 가지 예를 보면서, 우리가 이미 수행 중인 최적화가 유효성 불변식을 어떻게 제약하는지 살펴보겠습니다. Rust의 레이아웃 알고리즘이 enum 최적화를 위해 어떤 불변식을 활용한다면(예: Option<&i32>), 우리는 그 부분을 유효성의 일부로 삼아야 하며, 그렇지 않으면 그 레이아웃은 잘못된 것입니다. 이미 언급했듯이, 우리는 변수의 타입을 바탕으로 알고 있는 여러 사실을 LLVM에 속성(attribute)으로 전달하고 있습니다. 그리고 마지막으로, 모든 안전한 데이터는 유효해야 하므로 안전 불변식 역시 제약을 가합니다.
bool에 대해서는 안전성 때와 같은 불변식을 유지합시다. 즉 true나 false여야 합니다. 이 불변식은 이미 enum 레이아웃 최적화에서 활용되고 있으므로, 이를 어느 때라도 위반하면 코드가 깨질 수 있습니다. 더 일반적으로 말해, 유효성은 올바른 enum 판별자를 요구합니다. 그 결과, !에 대해 유효한 데이터는 존재하지 않습니다.
안전성과 유사하게, 유효성도 구조적입니다. 따라서 (T, U)의 유효성은 첫 번째 필드가 T에서 유효하고, 두 번째 필드가 U에서 유효해야 합니다.
참조는 어떨까요? 여기서 흥미로워집니다. &i32 같은 타입에 대해 우리는 그것이 정렬되어 있고 NULL이 아님을 LLVM에 알려주고, enum 레이아웃 최적화에서도 이를 일부 활용합니다. 따라서 유효성이 이를 요구해야 합니다. 하지만 &bool이 항상 유효한 bool을 가리켜야 한다고 요구할까요? 앞 절에서 언급한 예시들에서 보았듯이, 그렇게 하면 깨질 unsafe 코드가 존재합니다(그리고 초기화되지 않은 데이터가 논의될 때마다 사람들이 이런 예시를 가져옵니다). 같은 크레이트에 속한 메서드가 무엇을 하는지 알고 있을 때, 부분적으로 초기화된 자료구조에 대해 &mut을 받는 메서드를 호출할 수 있으면 종종 유용합니다. 이 모든 것을 raw 포인터로 하려면 훨씬 덜 인체공학적이며, 사람들에게 덜 인체공학적인 코드를 쓰게 하면 부작용으로 버그가 더 생기게 마련입니다. 또한 참조의 유효성을 구조적으로 만들었을 때(즉, &T가 유효하려면 유효한 T를 가리켜야 한다고 할 때) 얻는 _이득_은 미미해 보입니다(다만 @comex가 이를 요구하는 최적화 하나를 제시하긴 했습니다).
그 결과로, &!는 안전한 거주자(inhabitant)가 없지만, &!에 대해 유효한 데이터를 갖는 것은 _가능_하다는 결론이 나옵니다. 결국 유효한 !를 가리킬 필요는 없기 때문입니다(그건 불가능합니다).
우리가 이미 가정하고 있는 한 가지는 참조가 역참조 가능하다는 것입니다. 우리는 LLVM에도 그렇게 알려줍니다. 이를 유효성의 일부로 삼는 것이 자연스러워 보입니다. 하지만 Stacked Borrows를 채택하면 그럴 필요가 없다는 사실이 드러납니다. 별칭 모델이 이미 모든 참조가 역참조 가능하도록 암묵적으로 강제하기 때문입니다(구체적으로, 참조는 스코프로 들어올 때 "활성화"되며, 이는 메모리의 각 바이트와 함께 저장되는 "그림자 데이터"에 영향을 미치고, 그 참조가 할당된 메모리를 가리키는 경우에만 성공할 수 있습니다). 이론적인 이유로, 역참조 가능성을 유효성의 일부로 만들고 싶지 않습니다. 어떤 시점에 유효했던 값은 프로그램 실행의 나머지 동안 항상 유효하다고 간주되는 것이 바람직하다고 생각합니다. "bool은 true나 false여야 한다" 같은 불변식에는 해당되지만, 참조가 역참조 가능해야 한다고 요구하고 그 메모리가 이후에 해제되는 경우에는 그렇지 않습니다. 어떤 수준에서는, 참조가 역참조 가능하다는 사실이 어디에서 오든 중요하지 않으니, 이 논의에 이렇게 많은 지면을 할애하지 않아야 할지도 모르겠습니다. ;)
또 다른 흥미로운 경우는 union의 유효성입니다. 이는 많이 논의되어 왔고, 개인적인 의견으로는 유효성이 union에 대해 어떤 제약도 가하지 않아야 한다고 봅니다. 즉, 어느 변형에 대해서도 유효할 필요가 없고, 임의의(심지어 초기화되지 않은) 데이터를 포함할 수 있습니다. 다만 아직 확고한 결정은 내려지지 않았습니다.
마지막 예로, i32처럼 단순한 타입의 유효성에도 열린 질문들이 있습니다. 안전성에 대해 이야기할 때, 초기화되지 않은 데이터는 i32에서 안전하지 않다고 설명했습니다. 하지만 유효할까요? 제 심증은 그렇지 않아야 한다는 것입니다(즉, 유효성은 i32가 어떤 값으로든 초기화되어야 함을 요구해야 한다는 것). 하지만 이를 통해 얻는 구체적인 이점을 보여주지 못하고 있고, u8에서 초기화되지 않은 데이터를 허용해야 한다는 좋은 예시도 있습니다.
저는 모든 타입이 동반하는 두 가지 불변식, 안전 불변식과 유효성 불변식에 대해 이야기했습니다. unsafe 코드 작성자들을 위한 이 글의 슬로건은 다음과 같습니다:
항상 유효해야 하지만, 안전 코드는 안전하기만 하면 됩니다.
이제 우리는 unsafe 코드를 작성한 경험이 충분히 쌓였다고 생각합니다. 따라서 어떤 유효성 불변식이 말이 되고 어떤 것은 아닌지 합리적으로 논의할 수 있습니다. 그리고 지금이 바로 그런 논의를 해야 할 때라고 생각합니다. 많은 unsafe 코드 작성자들이 정확히 이러한 문제들을 늘 궁금해하기 때문입니다.
계획은 가까운 시일 내에 UCG RFCs 저장소에 이슈를 여는 것입니다. 유효성과 관련해 결정이 필요한 각 타입 계열마다 하나의 이슈를 열 예정입니다. 그동안 의견이나 질문이 있다면 포럼에 자유롭게 참여해 주세요!