Rust 프로그램 전반에서 반복적으로 필요한 공통 컨텍스트를 안전하고 효율적으로 전달하기 위한 ‘스코프 제네릭’ 제안을 설명한다. 기존 대안(불완전/완전 객체, 상수/전역, const generics, 생성성)과의 비교, 사례 분석, 설계·구현 아이디어, 향후 확장 가능성을 다룬다.
태그: rustprogrammingmemory | 작성: Alex Smith, 2024-08-26
목차
이 블로그의 대부분 글은 C 프로그래밍을 다루지만, 최근에는 제 프로그래밍 대부분을 Rust로 하고 있습니다. 오랫동안, 저를 전환시키기에 C보다 “충분히 더 나은” 언어가 없었죠(조금 더 나은 언어는 많았지만, 전환에는 비용이 듭니다). 그런데 Rust는 그 기준을 꽤 큰 차이로 넘어섰습니다.
Rust의 큰 매력 중 하나는, 컴파일 타임에 프로그램이 특정 부류의 버그를 포함하지 않는다는 것을 증명해낼 수 있는 폭과 깊이입니다. 가장 눈에 띄는 측면은 많은 범주의 보안 버그를 아예 차단한다는 점입니다(물론 전부는 아닙니다). 다만 어떤 경우엔, 이를 위해 런타임 패닉으로 바꾸기도 하므로, 보안성은 올라가도 신뢰성은 그대로일 수 있습니다. 제가 더 흥미롭게 보는 부분은, 단지 보안 영향만 막는 것이 아니라 애초에 버그가 발생하지 않음을 증명해 주는 경우의 수가 넓고, 종종 런타임 오버헤드도 없다는 점입니다. “사운드한 코드”도 좋지만 — 사운드하면서 패닉도 없고, 그래도 여전히 효율적으로 돌아가는 코드라면 더 훌륭합니다.
Rust는 이미 이런 의미의 신뢰성을 크게 도와주고 있고, 그래서 부족한 지점이 더 아쉽게 느껴집니다. 특히 언어가 도와줄 수 있으리라 기대되는 지점에서요. 제가 쓰는 거의 모든 프로그램에서(아마 다른 사람들의 프로그램에서도) 반복해서 마주치는 문제가 하나 있습니다. 여러 부정적 효과를 낳는데, 바로 같은 값을 반복적으로 저장해 메모리를 낭비하는 문제입니다. 그 결과, 사실 컴파일 타임에 같다는 것을 판별할 수 있을 법한 값들에 대해 런타임 검사로 정말 같은지 확인해야 하거나, 검사를 생략하고 코드가 정확하길 기도해야 하죠(컴파일러가 잡아주지 못해 틀리면 쓰레기 결과가 나오고, 심지어 보안 버그를 막기 위해 컴파일러가 어차피 검사 코드를 추가해야 할 수도 있습니다).
이 문제를 정확히 정의하기는 어렵습니다. 그래서 이 글의 상당 부분을 그 정의에 할애했습니다(요약하자면 “여러 값이 있고, 그것들이 의미를 갖거나 안전하게 쓰이려면 추가 컨텍스트가 필요하다. 그리고 그 컨텍스트는 전부 같거나, 적어도 컴파일 타임에 추적 가능한 하위 집합 내에서는 같아야 한다(혹은 같다고 가정한다)” 정도입니다). 가능한 해법도 여럿 있지만, 어느 것도 제대로 먹히지 않습니다.
이 글은 두 가지가 주제입니다. 하나는 문제를 설명하고, 기존 해법들이 왜 충분치 않은지 이유를 밝히는 것. 다른 하나는 Rust 언어에 비교적 쉽게 구현 가능하고(하위 호환도 되는) 변화 하나를 제안해, 이 문제를 거의 완전히 없애는 법을 보여주는 것입니다. 저는 이 새 언어 기능을 임시로 “스코프 제네릭(scoped generics)”이라 부르겠습니다. 이것을 구현할 가치가 있음을 독자에게 설득하려 합니다. 그 과정에서 신비로운 주제인 “생성성(generativity)”도 다루고(이 기능은 문제의 부분적 해법이지만 불완전하며, 스코프 제네릭이 이를 훨씬 인체공학적으로 만듭니다), Rust의 기존 동작을 재맥락화합니다(수명은 사실 크기 0의 스코프 제네릭일 뿐!). 그리고 새 기능 덕분에 표준 라이브러리의 어떤 API를 전부 안전 코드로(어느 정도) 구현할 수 있음을 발견합니다. 일반 상식으로는 그게 불가능해야 하는 API임에도 말이죠.
(대상 독자에 대한 메모: 이 글은 독자가 이미 Rust를 이해하고 있다고 가정합니다 — 글이 이미 충분히 길고, 언어 개선에 관심 있는 분들은 대체로 제가 개선하려는 언어를 알고 계시리라 생각합니다.)
해법을 설명하려면 먼저 풀려는 문제를 설명해야 합니다. 문제는 너무 흔하게 나타나기 때문에, 그걸 보여줄 쉬운 방법은 실제로 겪었던 사례를 드는 것입니다(제가 이미 쓴 코드나, 쓰려고 고민한 코드). 예시는 꽤 많았지만, 여기선 세 가지 예시로 문제의 본질을 보여 보겠습니다 — 이해하기 쉽고(희망사항), 사안의 다양한 측면을 드러내도록 골랐습니다.
제 취미 중 하나는 “투링 타핏(Turing tarpit)” 연구입니다 — 이론상 어떤 언어로 쓰인 어떤 프로그램이든 같은 계산을 할 수 있지만, 실전으로는 엄청 못하는 언어 말이죠. 이런 언어는 극단적으로 단순해서(예: FRACTRAN이나 I/D machine), 또는 애초에 프로그래밍을 염두에 두지 않은 것에서 자연스럽게 발견되었기 때문에 흥미롭습니다(예: _매직: 더 개더링_의 규칙과 카드 풀은 투링 타핏을 이룹니다, _Netrunner_의 규칙과 카드 풀도 마찬가지). 타핏의 프로그램이 뭘 하는지 알아보려면, 가장 단순한 방법은 디버깅 기능이 몇 가지 달린 인터프리터를 만들어 그 위에서 돌려보는 겁니다.
하지만 문제가 하나 있습니다. 타핏 프로그램은 때때로 끔찍하게 비효율적입니다. 언어가 보통은 제대로 된 데이터 저장 수단을 갖고 있지 않기 때문이죠. 간단한 예로, 숫자 _n_을 저장하는 가장 쉬운 방법(혹은 유일한 방법)이 길이가 2 _n_인 문자열로 표현하는 것일 수 있습니다. 이런 숫자를 한 번에 한 명령씩 해석하는 건 실전에서는 너무 느립니다. 그래서 타핏 인터프리터를 실용적으로 쓰려면 최적화가 필요합니다. 프로그램 속의 단순 루프를 감지해서 루프 전체의 효과를 계산하고, 각 반복을 일일이 해석하지 않는 식이죠.
최근 타핏 실험을 하면서, 아주 큰 수에 데이터를 인코딩해 저장하는 경향이 있는 언어들을 분석하고 있었습니다. 주어진 진법의 거듭제곱에 작은 배수를 더하고 빼서 만든 수들입니다. 예컨대 어떤 프로그램은 260 318−11 같은 수를 쓰고, 다른 프로그램은 2 11−2 6−2 4 같은 수를 씁니다. 그래서 이런 류의 숫자를 효율적으로 다루는 인터프리터를 쓰고 싶었습니다. 즉, 어떤 진법에서 지수와 계수의 작은 집합으로 수를 저장합니다. 예컨대 후자는 2진법에서 {(11, 1), (6, -1), (4, -1)}로 표현할 수 있겠죠.
Rust에서 이걸 표현하는 가장 눈에 띄는 방법은 지수에서 계수로의 희소 맵을 저장하고, 거기에 진법을 함께 저장하는 자료구조를 쓰는 것입니다. 저는 아래처럼(명확성을 위해 몇몇 세부는 생략) 구현했습니다:
pub struct SparseBigInt {
base: i32,
digits: BTreeMap<i64, i32>
}
이 정의는 어느 정도 잘 동작해, 실제로 사용할 수 있는 프로그램을 쓸 수 있었습니다(그 후로 코드를 다시 보진 않아 아직도 이런 식으로 SparseBigInt를 정의합니다). 하지만 base 구현 방식이 뭔가 어색하다는 느낌이 들었습니다.
첫 문제는 산술 연산을 구현하려 할 때 나왔습니다. 모든 연산에 해당했지만, 제가 가장 먼저 구현하려 한 덧셈 얘기를 하죠. 이런 형태의 무한 정밀 정수를 더하는 효율적이고 단순한 알고리즘이 있습니다(초등학생이 배우는 세로셈과 거의 동일하지만, 두 수의 같은 자리에서 0이 길게 이어지는 구간은 건너뛸 수 있어, 비영(0이 아닌) 자리수 개수에 선형 시간). 불행히도 두 수의 base가 다르면 이 알고리즘이 먹히지 않습니다(그리고 base가 다를 때의 덧셈 알고리즘은 비교할 수 없을 정도로 느려져, 실제 사용에선 성능을 망칩니다). 다행히, 실제론 이 케이스가 나오지 않을 것이었습니다. 제가 분석하던 타핏에서는 모든 숫자가 자연스럽게 같은 진법으로 저장되고(프로그램 초반에 연산 전에 파악 가능), 더 다행히도 Add와 AddAssign은 패닉을 허용합니다(심지어 i32 같은 일반 타입에서도 오버플로 검사 켜면 패닉할 수 있죠). 그래서 저는 base가 같은지 검사하고, 다르면 패닉하는 체크만 넣었습니다.
그럼에도 뭔가 세련되지 않았습니다. 프로그램의 모든 패닉은 버그 가능성을 경고합니다(“이게 발생하면 프로그램에 버그가 있다는 뜻”이자, “컴파일러가 이게 절대 발생하지 않음을 증명하지 못했다”는 뜻). 전자가 패닉의 본질이고, 후자는 그런 증명을 못했을 때 패닉이 필요하기 때문입니다. 지금은 제 프로그램에 진법 불일치는 없다고 꽤 확신하지만, SparseBigInt를 다른 타핏 분석에도 재사용할 계획이라, 그 요구사항은 쉽게 발목을 잡을 수 있습니다 — 기계가 읽을 수 있는 방식으로 문서화되어 있지 않고, 구현과 주석에만 암시되어 있으니까요.
(코드를 다시 보니, 결국 다른 진법 간 산술을 위한 비효율 알고리즘도 구현했습니다. 연산자가 모든 경우에 동작하도록요! 그래도 성능 함정이 될 수 있습니다. 그런 케이스가 나오면 느려지니까요. 또, 테스트에서만 실행되도록 “죽어 있어야 할” 코드가 늘었을 수도 있습니다. 운 좋게도 그 알고리즘은 관계없는 용도로도 필요해져서 시간 낭비가 되진 않았지만, 이런 운을 기대할 수는 없겠죠.)
이건 제가 썼던 해법이 서툴다는 유일한 징후가 아니었습니다. Rust에서 새 타입을 만들면 표준 트레이트를 최대한 많이 구현하는 것이 보통입니다. 표준 라이브러리에 이미 구현된 연산을 재사용할 확률이 올라가니까요. 0을 표현할 수 있는 수치 타입인 SparseBigInt의 기본값은 명백합니다. 당연히 Default를 구현하려 했죠. 하지만 불가능했습니다. digits의 기본값은 간단합니다(빈 맵). 하지만 base의 기본값은 뭐여야 할까요? 제가 그때 생각한 유일한 방법은 “base에 유효하지 않은 센티넬 값을 넣고, 처음으로 수를 0이 아니게 만드는 연산을 할 때 상대 수의 진법으로 바꾼다”였습니다. 하지만 모든 연산에 센티넬 체크가 추가되어 느려집니다. 단지 Default를 위해 감수할 일은 아니죠.(이 글을 쓰면서 떠올린 “0의 진법을 맞춰서 바꾸기”도 되긴 합니다만, 비슷한 단점이 있습니다.) 그래서 결국 포기했습니다.
프로그램은 어쨌든 완성했고, 처음 목표했던 타핏 관련 문제를 푸는 데 도움이 되었습니다. 제 역할은 했죠. 그래도 base 구현 방식이 뭔가 잘못된 건 아닌지 계속 마음에 걸렸습니다 — 당시로서는 이게 유일한 선택지 같았지만요.
최근 저는 “도달 가능성(reachability)” 문제에 관심이 있습니다. “A에서 B로 갈 수 있다”, “B에서 D로 갈 수 있다” 같은 진술이 많이 주어졌을 때, 어떤 지점에서 다른 지점까지(중간 지점을 거쳐) 갈 수 있는지를 묻는 문제죠. 연결은 단방향이라 A→B가 가능해도 B→A는 아닐 수 있습니다. 단일 질의 하나를 “효율적으로” 풀려면 진술 수에 비례한 시간으로 가능합니다. 하지만 제가 이 문제를 마주치는 상황에서는 하나의 그래프에 대해 이런 질의를 아주 많이 해야 하고, 이를 정말 효율적으로 하는 것은 아직 그래프 이론에서 미해결 문제에 가깝습니다. 저도 아직 만족할 해법을 찾지 못했습니다.
어쨌든 일반 문제에 대한 좋은 알고리즘을 찾는 중이지만, 이미 더 쉬운 특수 경우는 몇 가지 구현을 시작했습니다(예: “A에서 B로 갔다가 다시 A로 돌아갈 수 있는가, 가능하면 경로는?” 같은 순환 경로만 다루면 알고리즘이 훨씬 단순해져 효율적인 자료구조를 만들 수 있습니다). 해당 자료구조는 기본적으로 맵 하나를 갖고, 키는 각 지점이고 값은 그 지점에 대한 정보입니다(그 안에는 다른 지점에 대한 참조 같은 게 포함됩니다).
Rust에서는 이런 걸 어떻게 구현할까요? 주어진 자료구조에 대해 그래프는 고정되어 있으니, 지점을 0 이상 점수-1 미만의 정수로 표현하는 게 가장 자연스럽습니다. 맵은 배열 비슷한 객체로 효율적으로 구현할 수 있고, 맵 값에 점을 참조로 저장할 때도 적절한 정수를 저장하면 되니, 순환 참조 문제(Rust에선 아주 힘든)를 피할 수 있습니다. 간단히 쓰면 아래 같습니다:
struct PointInformation {
// 이 모든 `usize`는 지점 인덱스
root: usize,
path_to_root: usize,
path_from_root: usize,
}
pub struct ReachabilityData {
point_info: Box<[PointInformation]>
}
앞선 예시처럼, 이 코드도 실제로 동작하는 프로그램을 만들 만큼은 잘 돌아갔습니다(A→B→A 경로 찾기 특수 문제). 하지만 역시나 만족스럽지 못한 지점이 여럿 있었습니다.
첫 번째 문제는 usize가 타입 안전하지 않다는 것입니다. 추상적으로 usize는 뭐든 의미할 수 있습니다. 실수로 다른 의미의 usize 변수를 잘못 썼을 때(오타 등), 타입 시스템은 잡아주지 못합니다. Rust에선 이를 조금이나마 완화하는 방법이 있습니다. 가장 흔한 건 새 래퍼 타입을 쓰는 것이죠. 내부 표현은 같지만 컴파일러는 다른 타입으로 취급합니다:
pub struct PointIndex(usize);
이렇게 하면 점 인덱스를 다른 usize 용도와 혼동하진 않습니다. 하지만 저는 동시에 여러 도달 가능성 문제의 데이터를 저장해야 했습니다. 위 PointIndex 정의는 전역적이어서 — 값이 “어떤” 도달 가능성 문제의 점 인덱스라는 건 확인하지만, “지금 조회 중인 특정” 문제의 인덱스인지 확인하진 못합니다. 따라서 다른 문제의 인덱스를 잘못 전달해도 탐지하지 못하고, 패닉도 없이 조용히 엉뚱한 결과를 낼 수 있습니다. 문서화가 “이런 사소한 실수나 오타로도” 잡기 힘든 버그를 만들 수 있는 API가 됩니다(두 자료구조가 테스트된 인덱스에 우연히 같은 값을 돌려준다면 테스트로도 안 잡힐 수 있죠).
아마 더 큰 문제는, 점 인덱스가 실제로는 인덱스로 쓰인다는 점입니다. 즉 거의 모든 사용에서 배열에 인덱싱하게 됩니다(위 예에선 point_info). 그러면 반드시 경계 내여야 합니다. 점 인덱스를 올바르게 쓰면 — 즉, 함수 인자로 받은 모든 인덱스와 PointInformation 내에 저장된 인덱스가 해당 문제의 인덱스라면 — 당연히 항상 경계 내입니다. 하지만 컴파일러는 그 사실을 모르고, 모든 인덱스 사용에 경계 검사를 넣습니다. 경계 검사는 아주 큰 시간은 들지 않습니다(실패 분기가 절대 안 타면 현대 CPU에선 매우 빠릅니다). 그래도 약간의 시간과 I-캐시 압력을 유발하니, 가능하다면 피하는 게 낫죠. 또한 unsafe로 경계 검사를 생략하면 라이브러리 전체가 언사운드해집니다. 앞선 문제 때문에 인덱싱이 항상 경계 내임을 보장할 수 없어서입니다 — 다른 문제의 인덱스를 전달했을 수도 있고, 그땐 정말로 범위를 벗어날 수 있습니다.
이 둘을 합하면, ReachabilityData의 메서드 문서는 지저분해집니다. 거의 모든 메서드가 이런 주석을 달아야 합니다:
Panics
주어진 점 인덱스가 이 도달 가능성 문제에 속하지 않으면, 이 메서드는 잘못된 결과를 반환하거나 패닉할 수 있습니다.
이건 “이 메서드는 정의되지 않은 동작을 갖는다”와 거의 비슷합니다 — 메모리 안전을 깨지는 않으니 완전히 정의되지 않진 않지만, 그건 버그의 결과가 덜 치명적일 뿐, 애초의 버그 발생을 막아주진 않습니다.
같은 프로그램에서 표시 코드를 쓰려 할 때 별도의 문제가 생겼습니다. 효율과 구현 편의상 ReachabilityData와 PointInformation은 점 인덱스를 씁니다. 하지만 최종 사용자는 자신이 지정한 실제 지점 단위로 생각하지, 인덱스로 생각하지 않습니다. A→B→A 경로를 찾았고, 이를 사용자에게 보여주고 싶다고 합시다. 당연한 해결책은 경로를 나타내는 객체를 하나 만들고, 그 Display 구현이 경로를 출력하는 것입니다 — 그러면 내부적으로 경로상의 각 지점도 Display 하겠죠. 하지만 지점이 인덱스로 저장되어 있다면, Display는 무엇을 출력해야 할지 맥락이 없습니다. Display::fmt는 self와 formatter 외엔 인자가 없고(formatter는 도움이 안 됩니다), 예컨대 PointIndex(5)를 출력할 때 사용자 관점에서 이게 무엇인지 알 길이 없습니다.
Display에 대해 통하는 해법(제가 예전에 쓴 적도 있음)은, 점 인덱스와, 어떤 점들이 존재하며 그 이름이 뭔지를 설명하는 구조체에 대한 참조를 함께 묶는 구조체를 두는 것입니다. 아마 아래처럼요:
struct PointIndexDisplay<'a> {
point_names: &'a [String],
point_index: usize,
}
이는 앞서 말한 API 문제들을 풀 씨앗을 줍니다. ReachabilityData의 각 메서드가 PointIndex나 usize 대신 (적절한 이름의) PointIndexDisplay를 받도록 할 수 있습니다. 아이디어는 각 문제마다 고유한 점의 집합이 있으므로, 서로 다른 point_names 참조를 갖는다는 것입니다. 만약 ReachabilityData가 자신의 필드로도 point_names 참조를 들고 있다면, 메서드 인자의 point_names와 저장된 point_names를 비교해(슬라이스 시작 주소와 길이가 같은지) 런타임에 올바른 문제인지 확인할 수 있습니다. 여기에 PointIndexDisplay 생성자에서 인덱스 경계 내 여부도 체크하고, 범위 밖이면 생성 자체를 거부할 수 있죠. 그러면 point_info에 대한 인덱싱이 별도 검사 없이도 항상 경계 내임을 보장합니다.
이 기법이 모든 문제를 풀까요? 안타깝게도 아닙니다. 경계 검사를 아껴도, 이번엔 point_names 비교가 추가되었으니 절약이 없습니다. 또한 PointInformation 내부에 PointIndexDisplay 값을 저장하는 건 그리 말이 되지 않습니다. 기술적으로는 되지만, 엄청난 메모리 부풀림을 야기합니다. 모든 인덱스에 슬라이스 참조(현대 기계에선 보통 16바이트)를 동반하게 되고, 그 참조들은 전부 동일하니 중복 데이터를 대량으로 저장해 메모리를 낭비하죠. 따라서 이 기법은 실수를 잡는 데 어느 정도 도움은 됩니다(인덱스를 망치면 조용히 잘못된 결과 대신 결정적으로 패닉). 그리고 Display 구현도 가능하게 하지만, 비교 비용 때문에 더 광범위하게 쓰기에는 비효율적이고, 오류 탐지는 런타임에 일어납니다(가능하다면 컴파일 타임에 잡는 게 바람직합니다).
그래도 여기엔 좋은 아이디어의 씨앗이 있는 듯합니다. 비록 이 대안도 만족스럽진 않지만요.
이번엔 제가 직접 코드를 쓰진 않았지만 — 많은 사람이 쓰고 싶어하는 — 예시입니다.
어떤 프로그램은 힙에 수명이 짧은 데이터를 아주 많이 할당해야 합니다. 일반적으로 힙 할당은 오버헤드가 큽니다. 데이터가 자주 할당·해제되면 병목이 되기 쉽습니다.
한 가지 대안은 가비지 컬렉터를 쓰는 것입니다(이 경우에 잘 최적화할 수 있음). 하지만 Rust 같은 시스템 언어에선 통합이 쉽지 않습니다. 게임 개발에서 흔히 보이는 다른 대안은 “로컬 할당자”입니다. 특정 스코프(특정 함수/메서드의 본문 등)에서만 존재하는 객체의 할당을 더 최적화해 주는 할당자죠. 제한된 수의 할당 지점만 처리하면 되므로, 실제 사용 패턴에 맞춰 빠르게 동작하도록 기능을 희생해 특화할 수 있습니다. (간단히, “이 할당자가 할당하는 모든 객체는 같은 타입이고, 전부 동시에 해제된다” 같은 조건을 상상해 봅시다. 이런 할당자는 범용 할당자보다 몇 배는 단순하고 빠를 수 있습니다. 어디에 할당할지 계산이 매우 단순하고, 최소한의 정보만 기록하면 되니까요.)
자연스럽게 보이는 Rust 코드와 상호 운용되려면 로컬 할당자는 무엇을 해야 할까요? 할당자의 주업무는 메모리 할당과 해제입니다. 가장 큰 문제는 가시성입니다. 할당자와 상호 작용하는 코드는, 메모리를 달라고 요청하고, 할당된 객체가 메모리를 반환해도 된다고 알려줄 수 있어야 합니다. 즉 할당자를 참조할 수 있어야 합니다.
할당 자체는 아주 쉽습니다. 할당자가 필요한 모든 함수/메서드에 참조를 넘기면 됩니다(개념적으로는 가변 참조가 필요하지만, Rust는 매 호출 때마다 참조를 재차 대여(reborrow)하므로 참조가 함수로 이동(move)하지 않습니다). 이건 때때로 거슬리긴 합니다. 모든 함수에 같은 인자가 추가되고, 모든 호출에서 그 인자를 언급해야 하니까요. 하지만 그 자체로는 크게 문제 되진 않습니다(이 글의 주제와 별 상관없이도 같은 문제가 생깁니다 — 언젠가 따로 글을 쓸지도요). 대략 이런 식입니다:
// 이 함수의 스코프에 할당자가 존재
fn function_that_uses_a_local_allocator() {
// 지나치게 단순한 생성이지만, 여기선 이렇게 된다고 가정
let mut allocator = MyLocalAllocator::new();
do_something(&mut allocator, /* other args */);
}
fn do_something(
allocator: &mut MyLocalAllocator<'_>, /* other args */) {
/* ... */
// 축약 문법: do_something_else(&mut *allocator, /* other args */);
do_something_else(allocator, /* other args */);
/* ... */
}
fn do_something_else(
allocator: &mut MyLocalAllocator<'_>, /* other args */) {
/* ... */
// Box::new_in은 실제 존재하지만 여기처럼 동작하진 않음
let my_object = Box::new_in(/* value to box */, allocator);
/* ... */
}
불행히도 해제에도 같은 해법을 쓰려 하면 문제가 생깁니다. 개념적으로는 할당과 해제가 거울처럼 대응되면 아주 매력적입니다:
let my_object = Box::new_in(/* value to box */, allocator);
/* ... */
// 제가 만든 가상의 메서드; 아래 이유로 Rust에는 없습니다
Box::drop_in(my_object, allocator);
이런 방식은 C 같은 언어에서 실제로 쓰이기도 합니다. 하지만 보안 구멍을 낳기 쉽습니다. 코드를 정확히 쓰면 안전하지만, 실제로는 여러 할당자를 쓰는 프로그램에서 다른 할당자로 잘못 해제하는 버그가 자주 생깁니다. 대체로 이는 정의되지 않은 동작이며, 자주 보안 버그죠. Rust의 할당자가 이런 식이었다면 사운드니스 구멍이 생깁니다:
fn main() {
let mut allocator1 = MyLocalAllocator::new();
let mut allocator2 = MyLocalAllocator::new();
wrong(&mut allocator1, &mut allocator2);
}
fn wrong<'a, 'b>(allocator1: &'a mut MyLocalAllocator<'b>,
allocator2: &'a mut MyLocalAllocator<'b>) {
let my_object = Box::new_in(0u64, allocator1);
Box::drop_in(my_object, allocator2); /* 어이쿠 */
}
타입 시스템은 잘못된 할당자로 해제하려는 시도를 막지 못했습니다. 타입이 같은 할당자가 둘 있었기 때문이죠(둘은 같은 수명을 갖고, 참조도 같은 수명). 타입 시스템이 체크할 수 있는 건 많습니다(예: my_object가 드롭되기 전에 할당되었는지, 이후 사용하지 않는지). 하지만 “이게 올바른 할당자다”는 체크할 수 없습니다.
이 문제를 무시한다 해도(예: Box::drop_in을 unsafe로 만들고, 프로그래머가 올바른 할당자인지 직접 확인하게 두는 식), 또 다른 문제가 있습니다. Rust에서 수동으로 드롭하는 일은 흔치 않습니다(물론 가능합니다). 보통은 파괴자에서만 객체가 드롭됩니다. 그걸 쓰려 하면 어떻게 될까요? 가장 단순한 케이스에서도 꼬이기 시작합니다. Box 하나만 가진 struct가 파괴자에서 그걸 해제하려고 할 때 말이죠:
struct BoxedError<'a> {
my_field: Box<dyn Error, MyLocalAllocator<'a>>
}
impl<'a> Drop for BoxedError<'a> {
fn drop(&mut self) {
Box::drop_in(self.my_field, /* ??? */);
}
}
할당자를 필요한 모든 함수와 메서드에 전달하는 기법은, 인자를 하나 더 추가하는 방식으로 동작합니다. 하지만 이는 Drop 같은 표준 트레이트(고정된 인자 목록)를 쓰기엔 그다지 호환되지 않습니다.
이 모든 이유로, 실제로는 다른 구현이 필요합니다. 할당자와 상호작용하는 함수들이 해제 시점마다 할당자를 아는 방식 대신, 할당된 모든 객체가 자신을 만든 할당자 를 추적하는 방식입니다. 그러면 파괴자든 아니든 해제는 매우 쉬워집니다. 객체에게 누가 자신을 할당했는지 물어보고, 그 할당자에게 해제를 요청하면 됩니다. 예컨대 Rust의 현재(unstable) 비전역 할당자 구현은 이런 방식을 씁니다. Box::new_in으로 특정 할당자를 써서 Box를 만들면, Box는 할당에 대한 참조와 할당자 자신을 함께 저장하고, <Box as Drop>::drop이 저장된 할당자로 해제합니다.
불행히도 이 대안은 기술적으로 동작하긴 해도, 로컬 할당자에는 실전적으로 쓰기 어렵게 만드는 문제가 적어도 두 개 있습니다. 더 눈에 띄는 건 성능 문제입니다. 로컬 할당자를 쓰는 주된 이유가 성능인데, 모든 객체 옆에 할당자를 저장해야 한다면 메모리를 상당히 많이 먹습니다. (할당자를 표현하는 가장 자연스러운 방법은, 할당자를 설명하는 메모리에 대한 참조입니다. 하지만 그러면 Box의 크기가 두 배가 됩니다. 이제 참조가 두 개니까요. 어떤 프로그램에선 데이터 구조가 거의 전적으로 Box로 이뤄지기도 하니, 프로그램 전체 메모리를 두 배로 늘릴 수 있습니다.) 이 추가 메모리(읽고 쓰는 데 드는 시간 포함)는 개념적으로 필수적이 아닙니다. 이 경우 어느 할당자로 해제해야 하는지를 추적하는 것은 그리 어렵지 않습니다. 저장된 할당자는 a) 올바른 할당자인지 검사/실수로 잘못된 할당자를 썼는지 잡아내기, b) 이례적인 맥락(파괴자 내부 등)에서 할당자에 접근하기 위한 용도로만 쓰입니다.
덜 눈에 띄는 문제는 할당자 자신의 사운드니스를 검증하는 일과 관련 있습니다. 개념적으로, 할당과 해제는 할당자를 변이시킵니다(어떤 메모리가 할당되었는지 내부 상태를 바꿔 기억해야 하니까요). Rust에서 이것을 표현하는 자연/표준적인 방법은 할당과 해제가 할당자의 가변/유일 참조를 받도록 하는 것입니다. 하지만 이는 “모든 Box가 자신을 만든 할당자를 저장”하는 아이디어와 충돌합니다. 할당자는 상태를 가지므로 그대로 저장할 수 없습니다. 참조를 저장해야 하지만, Rust에서는 가변 참조가 하나라도 있으면 동일한 것에 대한 참조가 둘일 수 없습니다. 그러면 도대체 무엇을 저장해야 할까요? Rust의 실제 Box::new_in 구현엔 이를 우회하는 기법이 있고, 덕분에 호출 모습이 꽤 이상해집니다:
// Allocator는 할당자 자신이 아니라 공유 참조에 구현됨
impl<'a> Allocator for &'a MyLocalAllocator<'a> {
/* ... */
}
// 이 함수의 스코프에 할당자가 존재
fn function_that_uses_a_local_allocator() {
let mut allocator = MyLocalAllocator::new();
do_something(&allocator, /* other args */);
}
// do_something은 구체 타입으로 받을 수도 있지만,
// 이 제네릭 예시가 더 이해에 도움됨
fn do_something<'a, T>(allocator: &'a T, /* other args */)
where &'a T: Allocator {
/* ... */
// Box::new_in은 실제로 이렇게 동작함
let my_object = Box::new_in(/* value to box */, allocator);
/* ... */
}
다시 말해, 할당·해제에 쓰이는 값(그리고 Allocator를 구현하고 Box에 저장되는 값)은 실제 할당자에 대한 공유 참조입니다. (꼭 리터럴 공유 참조일 필요는 없고, 전역 변수의 “참조처럼” 동작하는 크기 0 타입 등도 가능합니다. 하지만 Copy가 아니면 Box 안으로 move되어 첫 할당이 끝나면 할당자를 더 쓸 수 없으니, 실전적으로는 공유 가능해야 합니다.) 이는 Allocator 트레이트를 올바르게 구현하기 까다롭게 만듭니다. “재귀적으로 쓰이지 않는다” 같은 컴파일 타임 체크가 없기 때문입니다. 그 결과, 할당자는 자신의 내부 상태나 할당하려는 메모리에 대해 유일 접근을 부여받지 못합니다. 이 문제는 안전 코드로도 Cell을 써서 풀 수 있긴 합니다. 하지만 그 경우 런타임 체크가 들어가 재귀적 사용을 막습니다(안전한 Cell 변형들은 내부 값을 이중 가변 차용하지 못하게 막습니다. 주로 패닉하거나 더미 값을 반환하는 식이지, 컴파일 타임에 시도를 거부하지는 않습니다). 따라서 런타임 패닉 지점이 많은 할당자가 되고, 컴파일러가 이들의 불필요함을 입증하지 못합니다.
정리하면, 현재 Rust에서 로컬 할당자는 잘 안 맞습니다. 효율적인 해법인 “할당자 참조를 필요 함수에 인자로 넘겨 할당과 해제 모두에 사용” 방식은 컴파일러가 사운드니스를 명확히 체크하기 어렵고 파괴자와 궁합도 나쁩니다. 거의 되는데 아깝게 안 되고, 됐다면 효율적이었을 텐데요. 대안인 “각 객체에 할당자를 저장” 방식은 비효율적이고, 할당자에 대한 참조가 너무 많아져 컴파일러가 할당자의 올바른 구현을 증명하기 어려워집니다. 그 결과 런타임 체크(또는 unsafe)로 이어지고, 더 느려지죠. 로컬 할당자로 Rust 프로그램을 더 빠르게 만들려는 시도가 막다른 길이 되지 않으려면 다른 해결책이 필요합니다.
앞선 예시들이 보여준 상황은, Rust가 현재 표현하기 어려워하는 전형적인 경우입니다. 어떤 타입의 정보가 객체를 이해하거나 안전하게 쓰는 데 필요합니다(진법, 어느 인덱스가 어느 점인지의 목록, 할당자). 그리고 그 정보는 관련된 객체들 사이에서 같아야 합니다(효율적인 덧셈을 위해, 올바른 배열 접근을 위해, 올바른 할당자로 해제하기 위해). 흔히 표준 트레이트 구현이 어려워집니다(첫 예시는 Default, 두 번째는 Display, 세 번째는 Drop).
이 절에서는 가능한 해법을 몇 가지 살펴보겠습니다. 어느 것도 이상적이지 않지만, 왜 실패하는지 보는 일은 유익합니다. 문제를 좁혀가며 해법에 필요한 요건을 명확히 할 수 있으니까요. (의도적으로 하나는 누락했습니다. 뒤에서 다룹니다. 다른 걸 빼먹었다면 알려주세요.) 먼저 가장 자명한 두 가지 — 위에서 두 번씩 시도했던 — 해법을 보고, 덜 자명한 가능성으로 넘어갑니다.
C 같은 저수준 언어에서 시스템 프로그래밍을 배운 프로그래머(저 포함)에게 가장 자연스러운 접근은 “불완전 객체(incomplete object)” 방식입니다. 여기서는 객체들 사이에 공통인 정보를 각 객체에 저장하지 않습니다. 대신 그 객체들을 사용하는 코드가 따로 저장합니다. 두 번째 예시의 PointIndex(점을 나타내지만, 인덱스가 어느 점을 뜻하는지 정보가 빠져 있음), 세 번째 예시의 할당자가 돌려주는 메모리(같은 할당자가 해제해야 하지만, 어느 할당자인지 자체적으로 저장하지 않음)가 여기에 해당합니다.
이 방식은 보통 꽤 효율적입니다. 정보가 객체 집합 전체에서 한 번만 저장되니까요. 이 정보를 쓰는 메서드의 시그니처가 복잡해지는 건 단점입니다. 하지만 둘 이상의 객체가 같은 정보를 필요로 할 때는 장점이 되기도 합니다. 예컨대 첫 예시의 SparseBigInt에서, base를 생략해 불완전 객체로 표현했다면, 남는 것은 자리수 목록뿐입니다. 덧셈 함수는 이렇게 생겼을 겁니다:
fn add_bigint(x: DigitList, y: DigitList, base: i32) -> DigitList { ... }
시그니처가 “같은 진법이어야만 더할 수 있다”는 사실을 분명히 보여줍니다. 자리수 목록은 두 개지만, base는 한 번만 받으니까요.
안타깝게도 이 접근에는 큰 단점이 두 가지 있습니다. 첫째, 공통 정보를 잘못 지정하는 실수를 막을 방법이 없습니다. 맥락에 따라 잘못된 결과를 낼 수 있고(예: 자리수를 다른 진법으로 해석), 심하면 사운드니스 구멍이 됩니다(예: 배열 경계 밖 인덱싱, 잘못된 할당자로 해제).
둘째, 트레이트 구현이 매우 어렵습니다. 첫 예시에서 저는 이 방식을 쓰지 않았습니다. 썼다면 Add를 구현할 수 없다는 것을 깨달았을 겁니다(한 DigitList에 +를 적용해 다른 DigitList와 더하려 해도, 어디에도 진법을 지정할 자리가 없으니 올바른 캐리를 계산할 수 없습니다). 두 번째 예시에서, 불완전 PointIndex에는 Display를 구현할 수 없어서 PointIndexDisplay로 우회했습니다. 그리고 불완전 객체는 Drop을 유용하게 구현하기 어렵습니다. 세 번째 예시에서 큰 문제였죠.
반대 접근은 필요한 모든 객체에 공통 정보를 저장하는 것입니다. DigitList 대신 SparseBigInt, PointIndex 대신 PointIndexDisplay, 할당자를 저장하는 현재의 unstable Box 같은 방식이죠. 이는 가시성 문제를 “완력”으로 푸는 셈입니다. 항상 보이게 하려면, 필요할 수 있는 모든 것에(또는 그에 대한 참조를) 저장하라는 겁니다!
SparseBigInt에서는 이 방식이 크게 힘들진 않습니다. 공통 정보 base(an i32)를 저장하는 건 쉽습니다. 메모리를 많이 먹지 않고, Copy라 수명 문제도 없고, 변하지 않으니 어느 버전을 저장해야 하는지도 고민할 필요가 없습니다. 그럼에도 base를 여러 사본으로 뿌리는 문제는, 어떤 값에 진실의 출처(source of truth)가 여럿이면, 그 모든 사본이 같음을 증명해야만 정확성을 보장할 수 있다는 점입니다. 두 SparseBigInt를 더하려면 진법이 같은지 검사해야 하고, 이 방식은 그것을 컴파일 타임에 할 수 없게 만듭니다. (앞선 방식과 흥미로운 대칭이 있습니다. 거기선 base를 한 번만 지정해, 잘못 지정되면 문제가 되지만 “값이 하나”라는 건 보장됩니다. 여기선 base를 잘못 지정할 수 없지만, 두 base가 같은지 증명할 수 없고 같지 않으면 망합니다.)
불행히도 공통 정보가 작거나 Copy이거나, 심지어 clone 가능한지 보장되지 않습니다. 두 번째 예시에서 point_names는 문자열 리스트에 대한 공유 참조입니다 — 그리고 그 예시는 축약 버전입니다. 실제로는 공통 정보가 더 복잡하곤 합니다. 세 번째 예시에선 공통 정보가 개념적으로 “할당자에 대한 가변 참조”이고, 그런 것을 둘 저장하는 건 문제가 되는 게 알려져 있습니다. Rust의 현재 unstable 할당자 구현은 의미가 상당히 다른 타입으로 표현해 우회합니다. 그 탓에 컴파일러가 그 할당자를 추론하기 어려워 안전 속성을 입증하기 힘들죠. 불완전 객체를 쓰면 이런 건 문제가 아닙니다 — 공통 정보를 한 번만 만들고 필요할 때마다 빌리면 됩니다. 하지만 여기선 거의 넘기 힘든 문제입니다.
첫 예시에서 보았듯, 이 접근은 새 객체를 생성하는 메서드를 담은 트레이트(예: Default, From)와도 잘 맞지 않습니다. 대부분 트레이트에선 self로 공통 정보를 노출할 수 있지만, self가 없는 메서드에서는 여전히 가시성 문제가 남습니다.
자주 재컴파일하는 게 불편하지 않은 사람에게 맞는 방식입니다 — 그리고 C 코드(포트란 같은 더 오래된 언어에선 더더욱)에서 흔히 보이는 기법이죠. 범용적이진 않지만, 통하는 곳에서는 문제를 전부 혼자 해결합니다.
아이디어는, 프로그램 전체에서 딱 한 종류의 공통 정보만 필요할 때가 있다는 점입니다. 예컨대 제가 타핏을 분석할 때, 프로그램의 모든 SparseBigInt에 하나의 진법만 있으면 충분했습니다(제가 관심 있던 분석은 대체로 260진법을 썼습니다). 프로그램이 한 입력에 대해서만 돌아가면 되는 상황이라면, 공통 정보를 상수로 만들면 어떨까요(Rust에선 const 혹은 불변 static)? 장점이 많습니다:
객체별 오버헤드가 없습니다. 상수는 개별 객체에 저장되는 게 아니라 코드에 박혀 들어가니까요.
함수를 호출할 때 구문 오버헤드가 없습니다. 함수가 쓰는 상수를 시그니처에 언급할 필요가 없으니까요.
상수는 하나뿐이고 항상 같으니, 둘의 공통 정보가 불일치하는 사태가 애초에 불가능합니다. 앞선 섹션의 언사운드 가능성을 피합니다.
상수는(혹은 될 수 있습니다) 전역 가시성을 가지므로, 표준 트레이트 구현 내부에서도 접근 가능합니다.
단점은 분명합니다. a) 프로그램 전체에 오직 하나의 공통 정보만 가질 수 있고, b) 바뀔 때마다 재컴파일해야 합니다. 그래도 혼자 쓸 프로그램이거나, 여러 상황에 쓰기 위한 범용 프로그램이 아니라면 b)는 생각보다 문제 되지 않습니다.
흥미롭게도, Rust 자체에서도 이 접근이 때때로 등장합니다. 위 할당자 예시에서는 주로 unstable 할당자 API를 얘기했지만, stable API도 있습니다. 이렇게 생겼죠:
#[global_allocator]
static GLOBAL_ALLOCATOR: MyAllocator = /* ... */;
할당자 불일치로 인한 언사운드 문제를 막으면서도 할당자 참조를 저장하는 바이트를 아끼기 위해, stable Rust는 프로그램 전체에 하나의 상수 할당자를 둡니다. Box, Vec 같은 것을 쓰면 이 할당자가 메모리를 할당하고, 드롭하면 이 할당자로 메모리가 반환됩니다. 다소 단순하지만, 현재 대부분의 프로덕션 Rust 코드를 지탱하는 방식이기도 합니다.
앞선 해법은 컴파일 타임 상수를 썼지만, 비슷한 걸 가변 전역 변수로도 할 수 있습니다. 기본 아이디어는, 관련 객체가 생성되기 전에 전역 변수에 공통 정보를 초기화하고, 관련 객체가 살아 있는 동안 그 값이 유지되게 하는 것입니다.
프로그램의 라이프타임 전체에서 공통 정보가 하나뿐인 경우(저의 SparseBigInt 예시처럼), cell::OnceCell로 간단히 할 수 있습니다. 사운드니스 요구사항은 둘입니다(전역 변수가 제때 초기화될 것, 그리고 충분히 오래 값을 유지할 것). OnceCell은 둘째 요건을 자명하게 만족합니다(값을 영원히 유지하니까요). 불행히도 첫째 요건은 비효율을 부릅니다. 컴파일러가 전역 변수가 첫 객체 생성 전에 초기화되었음을 정적으로 확인하지 못해, 매번 값을 요청할 때마다 초기화 여부를 검사하게 됩니다. (전부 제거하려면 unsafe밖에 없어 보입니다.)
전역 변수를 쓰는 방식은 값이 바뀌는 경우에도 가능할 수 있습니다(동시에 존재 가능한 공통 정보마다 전역 변수가 하나씩 필요합니다. 특히 재귀 코드에는 작동할 수 없습니다. 중첩된 “공통 정보 라이프타임”이 얼마든 만들어질 수 있으니까요). 전역/내부가변 변수의 상태를 컴파일 타임에 체크하는 도구가 Rust엔 많지 않아서, 올바른 값으로 초기화되고 충분히 오래 유지되는지 컴파일러의 도움을 받긴 어려워 보입니다. 한편, 불가능하다고 단정하긴 어렵습니다. 이런 스킴을 생각해 보세요:
각 전역 변수에 대응하는 크기 0 타입을 둡니다. “전역 변수에 대한 참조”처럼 동작하는 타입입니다. 유효 수명을 명시하는 라이프타임 파라미터를 갖고, 트레이트 메서드(아마 Deref도 가능)를 통해 전역 변수에 접근합니다(참조를 값으로 저장할 필요가 없습니다. 참조는 해당 메서드 코드에 하드코딩되어 있습니다).
이 크기 0 타입은 “전역 변수를 잠그고 값을 쓰는” 과정을 통해서만 생성됩니다. 항상 초기화된 상태이며, 잠금이 풀리기 전에 존재할 수 없도록 라이프타임을 부여합니다.
크기 0 타입이므로, 이를 객체의 필드로 포함할 수 있습니다 — 위 “완전 객체” 해법처럼요 — 하지만 메모리를 더 쓰지 않습니다. 또한 타입이기 때문에, 두 객체가 같은 타입이라면 동일한 공통 정보를 사용함을 컴파일 타임에 증명할 수 있습니다.
잠금 스킴을 구현하려면 unsafe가 필요할 수 있지만, 결과적으로는 사운드하고 안전한 인터페이스를 제공할 수 있어 보입니다 — 새로운 라이브러리 크레이트로 괜찮은 출발점일 수 있겠네요! 다만 전형적인 코드와는 꽤 다를 겁니다. 이 크기 0 타입이 꽤 특이하게 동작하니까요. 현재 Rust에서 가장 비슷한 것은 &sync::RwLockReadGuard지만 크기 0은 아니며, 크기 0 버전은(저장 위치가 하드코딩된) 바닥부터 새로 써야 할 듯합니다.
이는 “완전 객체” 해법의 모든 문제를 풀지도 못합니다. 오히려 하나는 더 악화시킵니다. 그 해법은 공통 정보가 가변 참조일 때 어려웠는데, 이번 해법은 공유 참조를 공통 정보로 쓰는 것조차 어렵습니다(전역에 참조를 저장하는 건 대체로 'static이어야 해서 아주 어렵습니다). 실전에서 공통 정보는 참조인 경우가 많으므로, 이는 심각한 단점입니다.
이 절의 모든 내용은 스레드 로컬 변수로도 비슷하게 할 수 있지만, 이 목적에 전역 대비 특별한 장점이 있는지는 불분명합니다.
앞서 크기 0 타입 아이디어는 또 다른 가능성을 시사합니다. 공통 정보가 컴파일 타임에 알려져 있다면, 전역 변수에 대한 참조를 들고 다니는 대신 아예 그 값을 들고 다니면 어떨까요? 전역 변수 역참조 트레이트를 구현해서 값을 가져오는 대신, 역참조된 값을 하드코딩합니다. SparseBigInt 예로 보죠:
#[derive(Default)]
pub struct SparseBigInt<Base: Deref<Target=i32>> {
base: Base,
digits: BTreeMap<i64, i32>
}
fn main() {
#[derive(Default)]
struct Base260 {}
impl Deref for Base260 {
type Target = i32;
fn deref(&self) -> &i32 { &260 }
}
let zero_in_base_260: SparseBigInt<Base260> = Default::default();
}
하지만 이 구현은 base가 항상 같은 값을 역참조한다는 보장을 주지 않습니다(deref가 결정적일 필요는 없으니까요). 다행히 Rust에는 이 문제를 푸는 기능이 이미 있습니다. 바로 const generic 입니다:
#[derive(Default)]
pub struct SparseBigInt<const BASE: i32> {
digits: BTreeMap<i64, i32>
}
fn main() {
let zero_in_base_260: SparseBigInt<260> = Default::default();
}
이 해법의 장점은 많습니다:
타입 안전하며 컴파일 타임에 체크할 수 있습니다. 예컨대 Add 구현을
impl<const BASE: i32> Add<SparseBigInt<BASE>> for SparseBigInt<BASE>
로 쓰면, Rust 컴파일러는 같은 base의 SparseBigInt끼리만 덧셈을 허용합니다.
트레이트 메서드의 가시성 문제가 없습니다.
impl<const BASE> Default
for SparseBigInt<BASE>
는 완전히 합법인 Rust 코드이며, default 구현 내부에서 BASE 값을 볼 수 있습니다.
객체별 오버헤드가 없습니다.
언어에서 직접 지원하므로 문법이 아주 깔끔합니다.
즉, 공통 정보의 값은 객체의 필드나 메서드 인자로 지정하지 않고, 제네릭에 저장됩니다.
그래서 안타깝습니다. 대다수 경우에 이 해법을 쓸 수 없거든요. 두 가지 큰 문제가 있습니다. 하나는 const 제네릭은 컴파일 타임 상수여야 하는데, 공통 정보는 런타임까지 몰라야 하는 경우가 흔합니다. 다른 하나는 현재 const 파라미터의 타입에 매우 엄격한 제한이 있다는 점입니다(원시 정수형 같은 타입). 미래에 일부 완화 계획이 있긴 하지만, 그래도 공통 정보의 타입이 const 제네릭으로 부적합한 경우가 빈번히 남습니다(예: 앞선 할당자 예시는 개념적으로 런타임에 만들어진 값에 대한 가변 참조를 원합니다).
기존 언어 기능 중 어느 것도 완벽한 해법이 아니니, 새 언어 기능으로 푸는 걸 고민하는 게 타당합니다(그리고 이 글은 그럴 기능을 하나 제안하려 합니다). 앞서 부분 해법들을 봤으니, 완벽한 해법은 어떤 모습이어야 할까요?
새 언어 기능을 구현할 때 결정해야 할 두 가지는 a) 의미론(그 기능을 쓰는 프로그램이 어떻게 동작하는가?), b) 문법(그 기능을 쓰는 프로그램이 어떻게 보이는가?)입니다. 앞선 시도 중 하나가 다른 것보다 훨씬 깔끔해 보였습니다. 바로 const generics입니다. 쓰기도 이해하기도 쉽죠. 이는 우리가 찾는 언어 기능이 공통 정보를 나타내는 새 종류의 제네릭이어야 함을 시사합니다. 그러면 특정 공통 정보로 해석되어야 하는 구조체, enum 등이 그것을 제네릭 파라미터로 선언하고, 그 위에서 동작하는 함수와 메서드도 같은 방식으로 공통 정보에 대해 제네릭이 됩니다.
의미론은 어떨까요? 공통 정보의 중요한 성질은 여러 객체에 공통이라는 점이며, 종종 컴파일 타임에 “여러 객체들 사이에서 같은 것”임을 증명해야 한다는 것입니다. 따라서 공통 정보가 제네릭 파라미터로 제공될 때, 해당 제네릭 파라미터를 가진 특정 객체의 모든 사용, 해당 제네릭 파라미터를 가진 함수 호출 전체에서 같게 유지되어야 합니다 — 즉, 그런 경우에는 상수처럼 취급되어야 합니다(프로그램 전체에서 상수로 유지되는 const generics가 이걸 만족하죠). 한편, 실제 상수와 달리 어떤 지점에서는 값이 없을 수도 있고(활성 사용이 없을 때), 예컨대 루프 반복마다 다른 값을 가질 수도 있습니다.
이는 “스코프 상수(scoped constant)” 같은 것이 올바른 해법임을 시사합니다 — 프로그램 전체가 아니라 특정 코드 영역에서만 상수인 것. 기본 아이디어는, 코드가 “이 블록과 그가 호출하는 모든 함수 등에서, 이 값을 이 이름으로 부르겠고, 블록 전체에서 그 이름은 계속 그 값을 뜻한다”고 선언할 수 있어야 한다는 것입니다 — 그리고 그 이름을 const generic처럼 써서 객체를 만들거나 함수를 호출할 수 있어야 합니다.
문법적으로는 대략 이렇게 보일 수 있습니다:
#[derive(Default, Debug)]
pub struct SparseBigInt<const 'a BASE: i32> {
digits: BTreeMap<i64, i32>
}
// 아마 트레이트 구현으로 쓰겠지만, 예시로 함수로 씁니다
fn add_sparse_big_ints<const 'a BASE: i32>(
x: &SparseBigInt<BASE>, y: &SparseBigInt<BASE>) -> SparseBigInt<BASE> {
/* 덧셈 알고리즘, BASE를 일반 상수처럼 사용할 수 있음 */
}
fn main() {
let base = /* 사용자 입력에서 진법 읽기 */;
{
let const BASE: i32 = base;
let zero: SparseBigInt<BASE> = Default::default();
// 축약: let twice_zero = add_sparse_big_ints::<BASE>(zero, zero);
let twice_zero = add_sparse_big_ints(zero, zero);
println!("{:?}", twice_zero);
}
// 이제 BASE는 다시 미정의. 이를 사용하는 객체나 실행 중 함수가 없음이 보장됨
}
핵심 아이디어는 let const BASE: i32 = base;가 현재 스코프 동안 살아 있는 스코프 상수를 정의한다는 것입니다. 이는
let
base_copy: i32 = base;
가 현재 스코프 동안 살아 있는 불변 변수를 정의하는 것과 유사합니다. 그다음 스코프 상수는 일반 상수와 거의 같은 방식으로 제네릭 파라미터에 사용할 수 있습니다. 차이는 스코프 상수는 “수명”을 가진다는 점입니다. 스코프가 끝나면 더는 유효하지 않으므로, 그 상수를 const generic으로 쓰는 객체는 상수의 스코프가 끝나기 전에 파괴되어야 하고, 그 상수를 쓰는 함수는 끝나기 전에 반환되어야 합니다. (상수가 수명을 가진다는 것을 나타내기 위해, 제네릭에 const만이 아니라 const 'a를 썼습니다 — 이는 &'a 참조를 담는 것과 마찬가지로 객체의 수명을 최대 'a로 제한합니다.)
일반 const generics와의 또 다른 차이는, 스코프 상수의 “타입 평등성” 판단입니다. 같은 let const 문으로 만들어진 상수인 경우에만 같은 것으로 간주합니다. 우연히 같은 값을 가지는 스코프 상수 둘을 만들고, 각각을 쓰는 객체 둘을 같은 배열에 넣는 건 허용되지 않습니다. (런타임에 계산된 값이 우연히 같음을 타입 시스템이 증명하기는 어렵습니다 — 당연한 일입니다.) “같은 선언에서 만들어졌다”는 것이 == 비교가 아니라 평등성의 정의로 쓰이므로, 스코프 상수 자신은 Eq를 구현할 필요가 없습니다(일반 상수와 다릅니다).
언제나 그렇듯, 이 “스코프 제네릭”에 쓸 수 있는 타입에는 제약이 있습니다. 주된 제약은 스코프 상수의 타입입니다. 단순 케이스는 Copy인 타입입니다(예: 공유 참조). 가변 참조도 가능하지만 추가 제약이 붙습니다(다음 소절 참고).
또한 스코프 제네릭을 가진 객체를 트레이트 객체로 쓰는 것(그리고 스코프 제네릭을 가진 트레이트를 트레이트 객체로 쓰는 것), 스코프 제네릭이 있는 함수/메서드를 함수 포인터로 쓰는 것에는 제약이 필요할 수 있습니다. 저는 이런 사용이 언제나 사운드하다는 증명을 찾지 못했고, 경우에 따라 언사운드할 수도 있다고 봅니다(최소한 트레이트 객체의 수명을 스코프 상수의 수명에 제한해야 할 텐데, 그것만으로 충분한지는 확신이 없습니다). 이런 사용은 비교적 주변부에 해당하므로, 초기 구현에서는 완전히 금지하는 것이 합리적입니다. 이후 어떤 사용은 사운드하다는 게 밝혀지면 허용해도 되겠지요.
함수/메서드가 스코프 제네릭 파라미터를 가지면, 일반 인자처럼 행동할 수 있습니다:
// "i32 제곱" 함수의 일반적인 형태
fn square(x: i32) -> i32 { x * x }
// 스코프 제네릭으로 같은 함수 쓰기
fn square(x: i32) -> i32 {
let const X: i32 = x;
square_generic::<X>()
}
fn square_generic<const 'a NUM: i32>() -> i32 { NUM * NUM }
스코프 상수는 보통 Copy여야 합니다 — 사실상 메서드 인자로 전달되는 셈이고, 대부분의 타입은 인자로 쓰면 move되어 이후 사용할 수 없습니다(하지만 스코프 제네릭은 본질적으로 여러 번 재사용되어야 합니다).
하지만 Rust에는 인자로 넘겨도 소비되지 않는 타입이 하나 더 있습니다. 바로 가변 참조입니다. 이 또한 스코프 제네릭으로 쓸 수 있다면 아주 유용합니다. 예컨대 안전 Rust로 구현 가능하면서 기존 unsafe 할당자와도 호환되는 할당자 API는 대략 이렇게 생길 수 있습니다:
/// 수명 `'a`의 객체를 할당하는 할당자
struct MyAllocator<'a> { /* ... */ }
/// `'a` 동안 유효하며, `ALLOC`이 만든 타입 `T`의 할당물
struct MyAllocation<const 'a ALLOC: &mut MyAllocator<'a>, T> {
// drop 중일 때만 `allocation`이 None. Drop 트레이트의 알려진
// 제한을 우회하기 위한 Option 래퍼
allocation: Option<&'a mut MaybeUninit<T>>
}
impl<const 'a ALLOC: &mut MyAllocator<'a>, T> MyAllocation<ALLOC, T> {
fn allocate() -> Result<Self, AllocError> {
// alloc::Layout을 사용한 예시 구현
let layout = Layout::new::<T>();
// 스코프 제네릭에서 “재차 대여”된, 길어야 현재 함수까지인
// `ALLOC`에 대한 가변 참조를 얻음
let allocator: &mut MyAllocator<'a> = ALLOC;
let allocation = allocator.allocate(layout)?;
/* ... allocation으로 MyAllocation 생성 ... */
}
}
impl<const 'a ALLOC: &mut MyAllocator<'a>, T> Drop for MyAllocation<ALLOC, T> {
fn drop(&mut self) {
let allocation = self.allocation.take();
/* ... ALLOC에 allocation 반환(ALLOC이 변이됨) ... */
}
}
MyAllocation은 참조 하나만 저장하므로 효율적입니다(할당자는 스코프 제네릭이며, 참조 필드로 저장되지 않습니다). 또한 MyAllocation의 스코프 제네릭은 드롭 시 항상 자신을 만든 할당자에게 반환됨을 보장합니다. 같은 할당 수명을 갖는 다른 할당자에게 잘못 반환되지 않습니다(unsafe 코드가 그 가정을 사운드하게 할 수 있도록). 그리고 할당의 수명을 스코프 상수의 수명에 묶어, 할당자보다 할당물이 길게 살 수 없도록 합니다.
이런 종류의 사용을 사운드하게 만들려면 제약이 몇 가지 더 필요합니다. 하나는 가변 참조 타입 스코프 상수(또는 스코프 제네릭 값)의 사용에 관한 것입니다. 스코프 상수의 전체 수명 동안 유효한 참조를 얻는 대신, 길어야 현재 함수/메서드 동안만 유효한 참조를 얻습니다. 그리고 그 참조가 드롭될 때까지 스코프 상수는 “가변 차용 중”으로 간주됩니다. 이는 일반(비 스코프 상수) 가변 참조보다 조금 더 제한적이지만, 여전히 유용한 코드 범위에서 충분히 강력합니다.
이 때문에 스코프 제네릭의 타입을 다음처럼
&mut
MyAllocator<'a>
(가변 참조에 라이프타임을 명시하지 않고) 썼습니다.
스코프 상수를 만들 때, 그 참조의 수명은 스코프 상수의 수명보다 짧지 않은 아무 수명이나 될 수 있습니다.
스코프 상수를 사용할 때, 재차 대여의 수명은 현재 함수/메서드로 제한됩니다.
따라서 참조 값의 수명을 스코프 상수 자체의 수명과 별도로 추적할 고정 수명이 없습니다. (반면 스코프 상수가 공유 참조라면, 참조 대상의 수명을 추적할 의미가 있습니다. 그 수명은 참조 자체보다 길 수 있으니까요.)
또 하나의 제약은, 서로 다른 제네릭 파라미터 두 개를 통해 같은 가변 참조를 이중으로 얻는 것을 막기 위해 필요합니다(스코프 제네릭이든 일반 타입 제네릭이든). 충분한 제약(초기 구현에 추천할 제약)은 “스코프 제네릭이 가변 차용되는 동안에는, 그 스코프 제네릭 자체의 타입과 그 제네릭 파라미터를 제외한 다른 모든 제네릭 타입 파라미터나 스코프 제네릭을 사용할 수 없다(라이프타임/상수 제네릭은 사용 가능)”입니다. 즉 제네릭으로도 쓰면 안 되고, 예컨대 트레이트 메서드를 호출할 객체의 타입으로도 쓰면 안 됩니다 — 이 제약을 달리 표현하면 “차용이 유지되는 동안 사용하는 모든 타입은 스코프 제네릭 자체의 타입, 그 타입에 포함된 타입, 또는 함수/메서드에 하드코딩된 타입이어야 한다”입니다(참고로 코드 실행 없이 타입 제네릭을 다루는 일 — move/copy, 참조를 raw 포인터로 변환 — 은 가능합니다). 이 제약은 꼭 필요보다 약간 더 엄격하지만, 이해와 구현이 쉽다는 장점이 있고, 미래에 완화될 여지도 있습니다. 그리고 생각보다 덜 제한적입니다. 차용의 수명이 아주 짧은 경우가 많기 때문입니다.
위 문법의 세부(예: 굳이 let const가 필요할까? 그냥 let이면 안 되나? 전체 프로그램 상수가 아니니 제네릭에서 const라는 키워드는 맞나?)는 얼마든지 논쟁거리입니다. 하지만 의미론적으로는 이 해법이 맞다고 꽤 확신합니다. 이유는 여럿인데, 이 절에서는 그중 하나를 다루고, 나머지는 뒤에서 더 얘기하겠습니다.
언어 기능이 아주 흔한 문제를 푼다고 할 때, “왜 이 문제가 그동안 해결될 필요가 없었을까?”를 자문해 볼 가치가 있습니다. 많은 경우, 이미 어떤 특수한 형태로 해결되어 왔지만, 더 일반적인 문제 전체를 다루지는 않았다는 것을 발견하게 됩니다. 특수해법을 들여다보면 일반해법에 비교하는 데 도움이 됩니다.
이 글이 다루는 문제는 매우 일반적이어서, Rust의 핵심 기능 중 하나가 사실상 “스코프 제네릭 문제”의 특수해법으로 볼 수 있습니다. 참조와 포인터의 차이를 생각해 보세요:
#[derive(Default)]
struct Thing { /* ... */ }
let my_thing = Thing::default();
let reference: &Thing = &my_thing; // 완전 객체
let pointer: *const Thing = reference as *const Thing; // 불완전 객체
실행 중 컴퓨터 메모리에 저장되는 값 관점에서 보면, 이 둘에는 차이가 없습니다! 둘 다 my_thing의 주소를 저장합니다. 차이는, 생 포인터는 사운드하게 쓰려면 주의를 기울여야 한다는 것입니다(예: 역참조하려면 대상이 할당·초기화되어 있고, 아직 살아 있으며, 현재 가변 차용 중이 아닌지 등을 보장해야 합니다). 반면 참조는 추가 정보를 동반합니다(수명). 이는 a) 어떤 조건에서 사용이 사운드한지 명시하고, b) 참조를 사용할 수 있는 기간에 한계를 둡니다. 수명의 본질은 참조를 축약하지 않고 타입 별칭으로 쓰면 더 명확해집니다:
type ThingRef<'a> = &'a Thing;
fn pick_a_thing<'a>(x: ThingRef<'a>, y: ThingRef<'a>) -> ThingRef<'a> { /* ... */ }
이 수명은 a) 제네릭이며, b) 특정 스코프 동안 지속되고, c) 서로 다른 객체들에서 비교할 수 있습니다. 이는 “완전/불완전 객체”를 가르는 공통 정보와 매우 유사합니다. 그리고 실제로, 수명은 참조와 포인터의 차이 그 자체입니다.
사실, Rust의 기존 수명과 제가 위에서 정의한 스코프 상수의 차이는 거의 하나뿐입니다. 스코프 상수는 런타임에 존재하는 비자명한 값을 가지지만, 수명은 컴파일 타임 구성요소로만 존재합니다. 어떤 의미에서, Rust의 수명은 “크기 0 타입으로 제한된 스코프 상수”로 볼 수 있습니다. (비슷하게, Rust의 상수는 “'static 수명의 스코프 상수”로 볼 수 있고요.)
스코프 제네릭이 올바른 해법이라고 확신하는 이유 중 하나는, 이미 Rust에 존재하는 것을 단순 일반화(generalization)한 것에 불과하기 때문입니다. “크기 0 타입의 스코프 제네릭은 곧 수명이며, 따라서 수명을 구현하는 데 쓸 수도 있다”라고 생각해 보면, 결국 Rust의 기존 수명 구현으로 이어집니다. 이는 고무적인 신호입니다.
앞서 “스코프 제네릭 문제”의 모든 해법을 나열한다고 했지만 하나는 빼 두었습니다. 이 절은 그 남은 해법에 관한 것입니다. Rust에서 사운드하게 무언가를 하는 법을 다루는 글들을 보면, 가끔 “이 코드는 런타임 체크로 사운드니스를 보장하고, 인자가 잘못되면 패닉합니다. 컴파일 타임 보장을 원한다면 ‘생성성(generativity)’을 쓰세요”라는 말을 봅니다. 그리고 컴파일 타임 해법은 너무 어렵거나 복잡하거나 헷갈린다는 뉘앙스를 풍기죠(실제로 실전에서는 잘 쓰지 않는 듯합니다!). 원천은 Gankra의 학위 논문(PDF) 6.3절로 보입니다. 거기에도 “매우 섬세한 트릭이라, 실전에서 많이 쓰일 것 같진 않다”는 단서가 달려 있습니다.
이 절에서는 생성성이 무엇인지, 왜 널리 쓰이지 않는지, 그리고 기존 Rust 컴파일러에서 스코프 제네릭을 이와 유사한 방식으로 구현하는 것이 어떻게 가능한지 설명합니다.
그렇다면 생성성이란 무엇일까요? 글들을 보면 프로그래밍 기법처럼 들리지만, 사실은 추상화 메커니즘이 가질 수도, 갖지 않을 수도 있는 성질입니다. 즉 어떤 추상화는 생성적(generative)이고, 어떤 추상화는 비생성적(non-generative)입니다(둘 중 어느 쪽이 “더 낫다”기보단 상황에 따라 유용합니다). 기본 아이디어는, 같은 것에 대한 추상화를 여러 번 적용(예: 루프에서 호출)했을 때 컴파일러가 이를 서로 다른 것으로 취급하면(“이 둘은 같다”는 관계까지 추상화해 버리면), 그 추상화 메커니즘이 생성적이라는 것입니다.
예를 들어 Rust에서 impl Trait은 비생성적입니다:
trait DebugGenerator {
fn gen_debug(&self) -> impl Debug; // 반환 타입을 추상화
}
fn debug_generator_test<T: DebugGenerator>(t: &T) -> impl Debug + '_ {
[t.gen_debug(), t.gen_debug()] // 하지만 매번 같은 타입
}
배열에 두 객체를 담으려면 타입이 같아야 합니다. 즉 impl Debug라는 추상화는 정확한 타입을 숨기긴 했지만, 같은 타입이 반복된다는 사실까지 숨기진 못합니다(“이 메서드는 Debug를 구현하는 어떤 타입을 반환하며, 같은 타입에 대해 호출하면 항상 같은 타입을 반환한다”는 보장이 남습니다).
한편, 수명은 생성성 정의에 아주 가까운 행동을 보입니다:
#[derive(Clone, Copy, Debug)]
struct Foo<'a>(
PhantomData<*mut &'a ()> // Foo를 'a에 대해 불변(invariant)하게 만듦
);
impl<'a> Foo<'a> {
fn accept(&self, _ref: &'a i32) {}
}
let x = Foo(PhantomData);
for y in 0..10
{
x.accept(&y); // "borrowed value does not live long enough"
}
문법적으로는 호출이 하나뿐입니다. 넘긴 데이터는 a) y의 메모리 위치, b) y에 대한 참조의 수명입니다. 하지만 이 호출은 루프 안에서 일어나고, 루프를 돌 때마다 수명이 달라집니다. 비록 수명은 값이라기보다 타입에 가까운 개념(컴파일 타임에만 존재하고 크기 0)이지만요. 결과적으로 컴파일 실패입니다. x의 타입은 Foo<'a>인데, 'a에 유효한 수명이 없습니다. 10번의 각 루프 반복마다 그 반복 내부에 완전히 속해야 하는데, 그 요구 사항들을 동시에 만족시킬 수가 없으니까요.
“생성성” 용어의 원래 정의가 이런 동작을 포함했는지는 확실치 않지만(수명은 추상화 메커니즘이라기보다 메타 정보니까요), Rust 커뮤니티에서는 대체로 이런 뜻으로 쓰니 여기서도 그렇게 하겠습니다.
Rust 관련 글에서 “생성성” 언급은 대체로 수명(특히 불변(invariant) 수명 제네릭)의 이런 동작을 이용해, 사실상 “유일 타입(unique type)” 값을 만들 수 있다는 문맥에서 등장합니다. 공통 정보를 구현하는 데 쓰면 대략 아래 같습니다. 그리고 컴파일러가 “두 개의 동일 공통 정보”를 요구하는 함수에 다른 공통 정보를 넘기려는 시도를 잡는 예시도 함께 보겠습니다:
#[derive(Debug, Clone, Copy)]
// `*mut &'a ()`가 'a에 대해 불변인 가장 단순한 타입
struct CommonInfo<'a, T>(T, PhantomData<*mut &'a ()>);
fn let_const<T, R>(x: T, f: impl for<'a> Fn(CommonInfo<'a, T>) -> R) -> R {
f(CommonInfo(x, PhantomData))
}
fn assert_type_eq<T>(_x: T, _y: T) {}
let_const(1, |sg1| {
let_const(2, |sg2| {
assert_type_eq(sg1, sg2); // "borrowed data escapes out of closure"
dbg!(sg1, sg2);
});
});
핵심은 let_const가 수명 'a를 가진 CommonInfo 객체를 만들고, 그 수명이 모든 다른 CommonInfo의 수명과 다르다는 점입니다. Rust에서 유일 수명을 만드는 일은 놀랍도록 어렵습니다(컴파일러는 프로그램의 수명이 올바르게 작동하도록 굉장히 노력하고, 종종 여러 수명을 합쳐 버립니다). 저도 유일 수명을 만들려는 시도에서 꽤 많이 실패했습니다. 이 구현은 상위 랭크 트레이트 바운드(HRTB, for<'a> Fn)로 수명을 만듭니다. f는 임의의 수명의 공통 정보를 받을 수 있는 함수여야 하고, 따라서 받은 구체 수명이 다른 어떤 특정 수명과도 다를 수 있음을 가정해야 합니다. 불행히도 이 방식은 스코프의 끝을 컴파일러가 추론하지 못하고 명시해야 하는 대가를 치릅니다(보통 let 바인딩은 스코프의 끝을 추론해 줍니다). 더 나은 해법도 있는 듯하지만, 더 복잡하고 Rust의 파괴자 동작 세부에 의존합니다.
서로 다른 T 값을 가진 CommonInfo가 비교될 수 없게 하려면, 구조체를 불변(invariant)으로 만들어야 합니다. 이를 위해 PhantomData로 분산(variance)을 바꿉니다. (PhantomData는 주어진 타입의 분산을 따라가도록 바꿉니다. *mut &'a ()는 'a에 대해 불변이므로, PhantomData는 CommonInfo의 분산을 불변으로 바꿉니다.) Rust는 보통 “같아야 하는” 두 수명을 보면 그들의 분산에 따라 합쳐 버리는데, CommonInfo의 불변성이 이를 막습니다.
이 기법은 “불완전/완전 객체” 해법을 모두 개선하는 데 쓸 수 있습니다:
// 완전 객체
pub struct SparseBigInt<'a> {
base: CommonInfo<'a, i32>,
digits: BTreeMap<i64, i32>
}
// 이제 같은 base를 가진 두 SparseBigInt에만 Add가 동작
impl<'a> Add for SparseBigInt<'a> { /* ... */ }
// 불완전 객체
pub struct DigitList<'a> {
digits: BTreeMap<i64, i32>
}
impl<'a> DigitList<'a> {
fn new(base: CommonInfo<'a, i32>) -> Self { /* ... */ }
// base가 self와 inc 모두에 대해 올바를 때만 컴파일됨
fn add_bigint(self, inc: Self, base: CommonInfo<'a, i32>) -> Self {
/* ... */
}
}
얻는 이익은 “공통 정보가 일치하지 않음” 문제를 푸는 데 있습니다. 완전 객체의 경우, 이제 다른 base를 가진 객체 둘을 섞을 수 없습니다 — Add 트레이트는 기본적으로 같은 타입의 두 객체만 더합니다(재정의할 수는 있지만 위 코드는 그렇지 않습니다). 불완전 객체의 경우, 여전히 호출자가 공통 정보를 지정해야 하지만, 틀리면 컴파일이 되지 않습니다. 주어진 DigitList<'a>에 대해 같은 수명 'a를 가진 CommonInfo<'a, _>는 정확히 하나뿐입니다. 즉 생성 시 지정한 그것일 수밖에 없습니다.
이 생성성 기반 해법은 어떤 의미에서 “스코프 제네릭으로 스코프 제네릭을 구현”하는 셈입니다. 현재 Rust에는 크기 0의 스코프 제네릭(수명)만 있지만, 그래도 그것의 “이 둘은 호환되지 않는다”는 성질을 이용해, 각기 유일 타입을 갖는 값들의 범위를 만들어 비영(0이 아닌) 스코프 제네릭로 쓸 수 있습니다.
이는 컴파일 타임 안전성 면에서 확실히 “완전/불완전 객체”보다 낫습니다. 기본적으로 원래 해법과 같은 것을 얻되, 컴파일러가 잘못된 코드를 거부하도록 조금 도와주는 셈이죠. 불행히도 여전히 몇 가지 문제를 풀지 못합니다. 특히 트레이트 구현에는 도움이 되지 않습니다(완전 객체는 여전히 Default/From을, 불완전 객체는 Add/Display/Drop을 구현하기 어렵습니다). 또한 인체공학도 꽤 나쁩니다. 위의 let_const 함수는 쓰기에 좀 못생겼지만, 더 나은 방법을 찾지 못했습니다. 그리고 구조체에 달린 수명 파라미터도 Rust에서 보통 수명이 동작하는 것과는 좀 다르게 느껴질 수 있어 혼란을 부를 수 있습니다. 그래서 더 안전한 장점에도 불구하고, 프로그래머들이 실전에서 보통 이 기법을 쓰지 않는 게 놀랍지 않습니다.
생성성은 스코프 제네릭 문제의 완전한 해법은 아니지만 — 그래서 새 언어 기능이 필요하지만 — 구현의 로드맵은 제공합니다. “수명은 기본적으로 크기 0의 스코프 제네릭”이고 “불변 수명으로 타입에 태그를 달아 타입 일치를 강제할 수 있다”는 핵심 통찰이 있으면, 컴파일러 확장으로 스코프 제네릭을 구현하는 건 꽤 쉽습니다. 구현해야 할 두 가지는 let const(또는 자전거 쉐딩 끝에 정해질 어떤 이름)와 제네릭 자체의 구현입니다. 둘 다 대체로 컴파일러가 이미 아는 것으로 디슈거(탈당의)함으로써 구현할 수 있습니다(단, 이 디슈거는 다른 크레이트의 코드에도 영향을 줄 수 있어, 프로그래머가 직접 구현할 수는 없습니다).
let const는 비교적 단순하고, 생성성 해법과 똑같이 동작합니다. 스코프 제네릭은 본질적으로 한 쌍으로 디슈거됩니다. 한 요소는 변수 또는 함수/메서드 인자이고, 다른 하나는 수명입니다. 변수 쪽은 기존 let으로 만들면 됩니다. 그리고 거기에 유일 수명을 짝지어야 합니다. “다른 어떤 수명과도 같지 않은 유일 수명을 만든다”는 것은 현재 Rust 코드로 쓰기엔 어렵지만, 컴파일러에 원시 기능으로 추가하는 건 쉽습니다(현재 컴파일러가 수명에 대해 하는 일보다 오히려 구현이 쉬워 보입니다).
타입의 스코프 제네릭 파라미터는 불변 수명으로 디슈거됩니다 — “불완전 객체” 해법에서처럼, 제네릭의 값은 객체 자체에 저장되지 않습니다(저장하면 비효율적이고 — 같은 데이터를 여러 사본 저장 —, 값이 가변 참조인 경우 언사운드합니다).
이를 보완하려고, 메서드/함수의 스코프 제네릭 파라미터는 두 가지로 디슈거됩니다. a) 타입의 경우처럼 제네릭 수명 파라미터, b) 메서드/함수에 추가 인자 하나. 이 인자는 함수/메서드에게 스코프 제네릭의 값을 알려 주는 데 쓰입니다. 아래는 프로그래머가 스코프 제네릭으로 쓴 코드 예시입니다:
#[derive(Default, Debug)]
pub struct SparseBigInt<const 'a BASE: i32> {
digits: BTreeMap<i64, i32>
}
fn double<const 'a BASE: i32>(x: &SparseBigInt<BASE>) -> SparseBigInt<BASE> {
// 축약: add::<BASE>(x, x)
add(x, x)
}
그리고 이는 다음처럼 디슈거됩니다:
#[derive(Default, Debug)]
pub struct SparseBigInt<'a> {
digits: BTreeMap<i64, i32>
// 구조체를 'a에 대해 불변으로 만드는 부가 코드
}
fn double<'a>(x: &SparseBigInt<BASE>, BASE: i32) -> SparseBigInt<'a> {
add::<'a>(x, x, BASE)
}
이 디슈거는 스코프 제네릭 파라미터가 직접 쓰이지 않고 타입 제네릭에 의해 암묵적으로 쓰이는 경우에도 적용됩니다(해당 타입이 스코프 제네릭을 갖고 있을 때). 예컨대 트레이트 메서드는 이 목적을 위해 “수신자의 타입에 대해 사실상 제네릭”입니다. 예를 들면:
// 표준 라이브러리 트레이트
pub trait Drop {
fn drop(&mut self);
}
impl<const 'a BASE: i32> Drop for SparseBigInt<BASE> {
fn drop(&mut self) {
eprintln!("Dropping a SparseBigInt with base {}": BASE);
}
}
이는 다음처럼 디슈거됩니다:
// 디슈거는 모노모픽화 이후에 적용. 트레이트 메서드는 해당 트레이트를
// 구현한 구체 타입 하나를 받는 함수로 대체된 상태
fn SparseBigInt::drop<'a>(self: &mut SparseBigInt<'a>, BASE: i32) {
eprintln!("Dropping a SparseBigInt with base {}": BASE);
}
표준 라이브러리 트레이트 메서드에 이런 추가 인자를 암시적으로 붙이는 일은, 현재 Rust 코드로는 불가능합니다. 하지만 바로 이것이 전체를 작동하게 만드는 핵심입니다. 트레이트 메서드의 호출자 — 이 경우 SparseBigInt 값을 드롭하려는 모든 곳 — 는 언제나 올바른 BASE 값을 갖고 있습니다( SparseBigInt<BASE>를 조작하려면 'BASE 수명과 BASE 값 둘 다 있어야 합니다 — 직접
let
const
에서 얻거나, 인자로 전달받았겠죠). 그래서 디슈거는 그 인자를 채워 넣기만 하면 됩니다.
가변 참조 스코프 제네릭도 거의 같은 방식으로 구현됩니다(차이점은 스코프 제네릭을 값으로 사용할 때 재차 대여가 삽입된다는 점 하나뿐입니다. &mut *REF). 컴파일러가 같은 스코프 제네릭의 사용을 하나의 인자로 병합해 주기만 하면(스코프 제네릭의 수명이 같으니 항상 감지할 수 있고, 이 구현에선 서로 다른 스코프 제네릭은 항상 서로 다른 수명을 가집니다), “가변 차용 중에 허용되는 것”에 대한 제약이 자동으로 보장합니다. 즉, 동시에 하나의 차용만 존재합니다(첫 차용이 존재하는 한, 차용 대상의 이름을 언급하거나 참조할 수 있는 모든 언어 구성요소 — 제네릭 등 — 가 금지되므로 두 번째 차용을 만들 길이 없습니다).
이 구현이 올바르다고 느끼는 또 다른 이유는, 구현이 매우 깔끔하다는 점입니다. 가시성 문제를 풀기 위한 “메서드에 추가 인자를 암시적으로 붙인다”와, 수명에 대한 기존 사운드니스 분석의 결합일 뿐입니다. 또한 사실상 기존의 안전 Rust 코드로 디슈거되므로, 사운드니스 구멍을 만들 위험이 거의 없습니다. 어떤 것을 안전 Rust로 표현한다는 건 사운드함을 보이는 방법 중 하나입니다. 스코프 제네릭 메커니즘이 충분한 컴파일러 보조와 함께 안전 Rust로 디슈거될 수 있으니, 이것이 사운드함을 보여줍니다.
마지막 점검을 해 봅시다. 새 언어 기능은 종종 언어에 새로운 능력을 더하고, 그 덕에 이전엔 쓸 수 없던 프로그램을 쓸 수 있게 합니다. 하지만 기능 정의에서 실수를 하면, 의도치 않은 것까지(심지어 언사운드한 것까지) 가능하게 될 수 있습니다. 그러니 새 기능을 정의할 땐, 불가능한 것을 시도해 어디서 막히는지 보는 게 좋습니다.
이번엔 안전 Rust에서(표준 라이브러리 내부가 unsafe인 API를 제외하고) 가장 근본적으로 불가능한 일을 보겠습니다. 바로 “동시에 가변적이면서 공유 가능한 것”을 만드는 일입니다. Rust에서는 보통 공유 참조 &와 가변 참조 &mut 중 하나를 선택해야 하며, 같은 데이터에 두 종류의 참조가 동시에 존재할 수 없습니다.
여러 프로그램에서 이 제약은 걸림돌이 되고, 흔히 시도하는 우회는 “참조를, 가변 참조로 전달되는 벡터에 대한 인덱스”로 표현하는 것입니다. 함수가 참조를 역참조해야 할 때, 적절한 인덱스의 원소에 접근합니다. 이는 원소를 바꿀 수 있으니 가변 참조처럼 동작합니다. 동시에 인덱스를 여러 곳에 저장할 수 있으니 공유 참조처럼도 동작합니다(그저 정수니까요).
이런 얘기는 스코프 제네릭이 너무 강력해졌다는 의심을 부릅니다 — 벡터가 공통 정보처럼 행동하고, 벡터에 대한 가변 참조는 스코프 제네릭에 허용되는 타입이니까요. 그럼 이 규칙을 깨려 시도해 봅시다.
첫 시도는, 사실은 벡터에 대한 인덱스인 “참조” 타입을 만들고, 벡터는 스코프 제네릭으로 두는 것입니다:
struct OmniReference<T, const 'a VEC: Vec<T>>(usize, PhantomData<&'a mut T>);
// Rust는 비-Copy 제네릭을 가진 타입에 #[derive(Copy)]를 허용하지 않지만,
// 수동 구현은 허용합니다:
impl<T, const 'a VEC: &mut Vec<T>> Clone for OmniReference<T, VEC> {
fn clone(&self) -> Self { *self }
}
impl<T, const 'a VEC: &mut Vec<T>> Copy for OmniReference<T, VEC> {}
impl<T, const 'a VEC: &mut Vec<T>> OmniReference<T, VEC> {
fn new(t: T) -> Self {
let reference = VEC.len();
VEC.push(t);
OmniReference(reference, PhantomData)
}
fn set(self, t: T) {
VEC[self.0] = t;
}
fn get(self) -> T where T: Copy {
VEC[self.0]
}
}
지금까지 스코프 제네릭 규칙은 깨지지 않았습니다( VEC.push(t)가 VEC가 가변 차용 중일 때 제네릭 T를 참조하지만, T는 Vec 타입에 언급되어 있으므로 허용됩니다). get 메서드에는 흥미로운
T:
Copy
제약이 붙습니다. 벡터에서 T를 복사해 내야 하기 때문입니다. 참조를 반환할 수는 없습니다. &mut 참조가 스코프 제네릭으로 쓰일 때, 스코프 제네릭의 수명이 해당 메서드의 수명으로 제한되기 때문입니다. 이 API는 어딘가 매우 익숙해 보입니다. “쓸 수 있고 복사해 가져올 수 있지만, 내부 값에 대한 참조는 얻을 수 없는 참조”를 어디서 본 것 같지 않나요? Cell이 딱 그렇습니다.
그럼 Cell이 할 수 없는 일을 해 보며 규칙을 깨 보죠. Copy 제약을 회피하는 한 가지 방법은 콜백 함수입니다. 벡터 원소의 수명 안에서 동작하게 할 수 있죠. 아니면 clone으로 Copy 부재를 우회할 수도 있습니다:
impl<T, const 'a VEC: &mut Vec<T>> OmniReference<T, VEC> {
fn map(self, mapper: impl FnOnce(&mut T)) {
(mapper)(&mut VEC[self.0])
}
fn get_clone(self) -> T where T: Clone {
VEC[self.0].clone()
}
}
둘 중 하나는 컴파일러가 거부합니다. map에서 인자 자리의 impl Trait은 타입 제네릭의 축약입니다. 즉, 시그니처는 다음으로 디슈거됩니다:
fn map<F: FnOnce(&mut T)>(self, mapper: F)
하지만 VEC가 차용 중일 때 mapper의 메서드를 호출하려 합니다. 이는 타입 제네릭 F의 사용이 필요한데, 가변 참조 스코프 제네릭이 차용 중인 동안엔 함수의 관련 없는 타입/스코프 제네릭을 사용할 수 없습니다(F는 Vec 타입에 언급되어 있지 않습니다).
반면 get_clone은 더 흥미롭습니다. 이는 타입 제네릭에 대해 메서드를 호출하지만, 이 경우 타입 제네릭은 스코프 제네릭의 타입 내부에 언급됩니다. 그러므로 허용됩니다. 보통 Cell의 내용을 clone하는 일은 금지됩니다. Clone 구현이 같은 셀에 대한 공유 차용을 갖고 있으면서 이것을 clone 도중에 쓰기 시도하는 경우가 있기 때문입니다. 하지만 이번 경우 그 실패 케이스는 불가능합니다. 만약 T가 스코프 제네릭을 포함한다면, 그 수명은 반드시 'a보다 엄격히 앞서 시작했어야 합니다( T에 언급된 스코프 제네릭은 let const VEC가 실행될 때 이미 살아 있어야 하니까요). 즉 그들 중 어느 것도 VEC에 대한 참조를 포함할 수 없습니다. 그리고 VEC의 값에 접근하는 메커니즘은 스코프 제네릭뿐이며, 현재는 가변 차용 중입니다. 따라서 get_clone은 사운드하며, 사운드하다는 것을 증명할 수 있습니다. 비슷한 연산이 Cell에서는 허용되지 않는데도요.
결론적으로 OmniReference는 a) 사운드하고, b) 사실상 Cell입니다. 단지 컴파일러가 어떤 연산이 사운드한지 더 잘 이해할 수 있게 되었을 뿐입니다. 스코프 제네릭을 단서로 “이중 가변 참조” 위험을 판단할 수 있어서죠. Cell보다 살짝 더 강력한 것을 구현할 수 있었고(아주 약간이지만), Rust의 도구 상자에 유용한 추가가 될 수도 있습니다. Rust 입문자는 흔히 Rc와 RefCell을 많이 쓰는 가변 구조를 시도하는데, RefCell의 런타임 체크를 줄일 수 있다면 런타임 패닉 기회를 줄여 프로그램을 더 신뢰성 있게 만드는 데 도움이 될 수 있습니다. 물론 간접층이 하나 더 생겨 덜 효율적이긴 합니다.
더 극적인 성과가 하나 더 있습니다. 이것은 전적으로 안전 코드로 구현한 Cell입니다. 이는 본질적으로 “불가능하다”고 의도된 것은 아닙니다(사운드니스 구멍을 여는 건 아니니까요). 하지만 대부분의 Rust 프로그래머가 언어 차원에서 가능하다고 기대하지는 않았을 일입니다(스코프 제네릭이나 유사 기능 없이 가능해 보이진 않습니다).
이를 그대로 Cell 대체에 쓰면 좋겠지만 — 언어 복잡도를 꽤 줄일 수 있습니다(UnsafeCell 관련한 특수 처리가 꽤 많습니다) — 아마 실제로 그렇게 하긴 어려울 겁니다. 덜 효율적이고, Cell을 쓰는 프로그램의 모든 함수/메서드/타입에 스코프 제네릭을 추가해야 해서(상당히 못생기고 번거롭습니다). 그래도 언어를 단순화하는 실마리는 됩니다. 저는 특수 케이스(UnsafeCell처럼 나머지 언어와 다르게 행동하며 특별 취급이 필요한 것)보다, 다양한 문제를 푸는 몇 가지 깊은 아이디어에 기반한 언어를 선호합니다.
스코프 제네릭 메커니즘을 더 발전시킬 아이디어 몇 가지를 적습니다.
Rust의 제네릭은 때로 문법 소음이 많습니다. 그리고 스코프 제네릭은 기존에는 제네릭이 없던 곳에도 제네릭을 도입합니다. 어떤 분들은 안전성 향상이 문법 복잡도 증가를 상쇄하지 못한다고 느낄 수 있습니다. 따라서 제네릭 문법을 더 좋게 만드는 방안을 고민할 가치가 있습니다(스코프 제네릭을 쓰지 않는 프로그램에도 도움이 됩니다).
위에서 언급한 가변 참조 스코프 제네릭의 제약은 실제 필요보다 엄격합니다. 정말로 관련이 전혀 없음을 증명 할 수 있다면, 관련 없는 제네릭의 사용을 허용해도 안전합니다(겉보기엔 관련 없어 보여도 같은 스코프 상수에 접근 가능한 경우가 있을 수 있으니 조심). 아마 “두 제네릭이 공통 스코프 상수를 공유하지 않는다”는 새 where 절을 고안하는 쪽이 될 듯합니다. 그러면 스코프 제네릭 기반 셀의 map도 동작하도록 만들 수 있겠죠.
또 하나, 스코프 제네릭의 흥미로운 확장 방향이 있습니다. 아주 큰 토끼굴을 열 것이고, 초기 구현에 포함해선 안 됩니다(영향이 너무 광범위하고, 구현도 아주 어려울 겁니다). 하지만 스코프 제네릭을 이해하는 사람에게는 배우고 이해하기 매우 쉬울 것이며, 안전 Rust로 가능한 프로그램의 범위를 크게 넓힐 수 있습니다. 다음 가상의 코드를 보세요:
// const generic을 정의해, 이 스코프에서 SIZE를 상수처럼 취급
let const SIZE: usize = /* 사용자 입력으로 읽은 어떤 크기 */;
// SIZE를 상수처럼 취급하므로, 배열 차원에 사용할 수 있음
let array = [0u32; SIZE];
// 비록 동적 크기 배열이지만 Sized를 구현함
println!("Array size in bytes: {}", mem::size_of::<[u32; SIZE]>());
// Sized를 구현하는 가변 크기 구조체를 만들어 보자
#[derive(Default)]
struct Stretchy<const 'a SIZE: usize> {
a: [bool; SIZE],
b: [f32; SIZE],
}
let stretchy = Stretchy::<SIZE>::default();
위 정의의 스코프 제네릭 의미론에서는 이건 동작하지 않습니다. 배열 크기는 const 제네릭이며, 스코프 제네릭이 아닙니다(즉 실제 상수여야 하고, 스코프 상수여선 안 됩니다). 그래도 사운드니스 관점에서는 잘 동작할 듯합니다. 코드 효율성도 올릴 수 있겠죠(슬라이스 참조는 배열 참조보다 두 배 큽니다. 같은 스코프 상수를 크기로 쓰는 가변 배열 둘 이상이 있다면, 슬라이스 두 개를 쓰는 것보다 메모리를 아낍니다). 또한 “Rust에서 alloca를 어떻게 하지?” 같은 문제에도 쉬운 해법이 됩니다(별도의 “unsized locals” 기능이 필요 없습니다 — 그냥 sized로 만드세요). C 포팅도 쉬워집니다(C에서는 위 같은 가변 길이 배열 선언이 합법입니다. 다만 컴파일 타임 체크가 부족해 구현은 덜 어렵습니다). 구현은 아주 어려울 것 같긴 합니다(그동안 컴파일 타임 상수였던 많은 것이 런타임에 바뀔 수 있게 되니까요).
Rust에는 스코프 제네릭이 필요합니다. 이는 매우 자주 — 제가 쓰는 거의 모든 Rust 프로그램에서 — 등장하는 문제를 풀어 줍니다. 현재 좋은 해법이 없던 문제를 말이죠. 그리고 언어의 나머지와 일관된 방식으로 풉니다(수명과 const generics는 스코프 제네릭의 특수 케이스입니다). 또한 “생성성”으로 풀려던 같은 문제를, 사람들이 실제로 사용할 만큼 충분히 단순하고 인체공학적인 방식으로 해결합니다. 그리고 이런 모든 장점이 상대적으로 낮은 비용으로 옵니다 — 이 정도 범위와 힘을 가진 언어 기능치고는, 구현이 꽤 쉬운 편일 겁니다(이미 Rust가 구현한 수명과 아주 유사하니까요).