Rust가 반복적으로 동일한 공통 문맥 정보를 공유하는 값들을 안전하고 효율적으로 다루기 어려운 이유와, 기존 해법들의 한계를 정리한 뒤, "스코프 제네릭"이라는 새로운 언어 기능으로 문제를 해결하는 방법과 그 타당성, 생성성(generativity)과의 관계, 구현 아이디어 및 향후 발전 가능성을 제안한다.
Tags: rustprogrammingmemory | Written by Alex Smith, 2024-08-26
Contents
이 블로그의 대부분 글은 C로 프로그래밍하는 내용이지만, 최근에는 제 프로그래밍의 상당 부분을 Rust로 하고 있습니다. 수년 동안 C보다 “충분히 더 나은” 언어를 찾지 못해(조금 더 나은 언어는 많았지만 전환 비용이 있죠) 바꾸지 않았는데, Rust는 그 문턱을 크게 넘었습니다.
Rust의 매력 중 하나는 컴파일 타임에 프로그램이 특정 부류의 버그를 포함하지 않음을 증명할 수 있는 범위가 매우 넓다는 점입니다. 가장 눈에 띄는 점은 많은 범주의 보안 버그를 완전히 막을 수 있다는 것이죠(물론 전부는 아닙니다). 다만 어떤 경우에는 그 대가로 런타임 크래시로 대체되는데, 이는 프로그램을 더 안전하게 만들지만 더 신뢰성 있게 만들지는 않습니다. 제게 더 흥미로운 점은 보안에만 그치지 않고, 아예 버그가 발생하지 않는다는 것을 증명해 주는 많은 경우들이 있다는 것입니다. 그것도 종종 런타임 오버헤드 없이요. 사운드한 코드만으로도 훌륭하지만, 사운드하면서 패닉도 없고 효율적으로 동작하는 코드는 더더욱 좋습니다.
Rust는 이미 이런 의미에서 신뢰성을 높이는 데 상당히 기여하고 있습니다. 그래서 Rust가 도움을 줄 것이라 기대되는 부분에서 부족함이 느껴질 때 더욱 아쉬움이 큽니다. 특히 제가 작성하려는 거의 모든 프로그램에서(아마 다른 많은 사람들의 프로그램에서도) 반복적으로 나타나는 문제가 하나 있는데, 이는 여러 부정적 결과로 이어집니다. 바로 동일한 값을 중복 저장하며 메모리를 낭비하는 문제입니다. 그러다 보니 컴파일 타임에 동일함을 판정할 수 있어야 함에도, 모든 복사본이 실제로 동일한지 런타임에 검사해야 하거나, 아니면 검사를 생략하고 구현이 맞았기를 바라는 수밖에 없습니다(컴파일러가 이런 실수를 잡아내지 못해 엉터리 결과를 내게 되고, 심지어 보안 버그로 이어지지 않도록 컴파일러가 추가 검사를 넣어야 할 수도 있죠).
이 문제를 정확히 정의하기는 어렵습니다. 이 글의 상당 부분을 그 설명에 할애했습니다(요약하면 “여러 값이 의미를 갖거나 안전하게 사용되기 위해 추가 맥락이 필요한데, 그 맥락은 전체 혹은 컴파일 타임에 추적 가능한 부분집합에서 동일해야 한다”). 가능한 해법도 여럿 있지만, 제대로 작동하는 것은 없습니다.
이 글은 두 가지를 목표로 합니다. 첫째, 문제와 기존 해법들이 왜 충분하지 않은지 설명합니다. 둘째, Rust 언어에 비교적 구현이 쉬우면서(호환성도 유지되는) 변경을 가해, 제가 임시로 “스코프 제네릭(scoped generics)”이라 부르는 새 기능을 추가하면 이 문제를 거의 완전히 없앨 수 있음을 보이려 합니다. 독자가 그 구현 가치가 충분함을 납득하도록요. 그 과정에서 “생성성(generativity)”이라는 주제(문제의 부분적 해결책이나 불완전함)를 다루며, 스코프 제네릭이 그것을 얼마나 더 인체공학적으로 만들어 주는지 설명합니다. 또 Rust의 기존 동작을 새로운 관점에서 재해석합니다(라이프타임은 사실상 크기 0의 스코프 제네릭임이 드러납니다!). 마지막으로, 이 기능이 표준 라이브러리의 특정 API를 전부 안전한 코드만으로(겉보기엔 분명 불가능해야 할) 구현하게 해 줄 수도 있음을 보이게 됩니다.
(독자 대상에 대한 안내: 이 글은 독자가 이미 Rust를 이해하고 있음을 전제로 합니다. 글이 이미 충분히 길고, 언어 개선에 관심 있는 분들이라면 제가 개선하려는 언어를 이미 알고 있을 것이라 기대합니다.)
해결책을 설명하려면 먼저 해결하려는 문제를 설명해야 합니다. 이 문제는 매우 흔하게 나타나므로, 실제 겪었던 예시(제가 이미 작성했거나 작성하려 했던 코드)로 설명하는 것이 쉽습니다. 예시는 많았지만, 여기서는 세 가지로 문제의 본질을 전달해 보려 합니다. 이해하기 쉬우면서(아마도), 서로 다른 측면을 보여 주는 예시들입니다.
제 취미 중 하나는 “튜링 타핏(Turing tarpits)”을 연구하는 것입니다. 이들은 이론상 어떤 언어로 작성된 프로그램과 동일한 계산을 할 수 있지만, 실용성은 형편없는 프로그래밍 언어들입니다. 어떤 것은 극도로 단순하기 때문에(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이 길게 이어지는 위치는 건너뛰어 비영(非零) 자릿수 수에 비례하여 선형 시간에 동작하도록). 불행히도 두 수의 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 이상 지점 수 미만의 정수로 나타내는 것입니다. 그러면 맵은 배열류 객체로 효율적으로 구현할 수 있고, 맵 값에서 지점 참조는 해당 정수를 저장하면 됩니다(순환 참조 문제를 피할 수 있는데, 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에는 이 문제를 완화하는 두 가지 해법이 있습니다. 가장 흔한 것은 래퍼 타입을 쓰는 것입니다. 보통 표현은 같지만 usize와 호환되지 않도록 만드는 식이죠:
pub struct PointIndex(usize);
이 방법은 지점 인덱스를 다른 usize 사용과 혼동하지 않게 해 줍니다. 불행히도 저는 여러 도달 가능성 문제에 대한 데이터를 동시에 저장해야 했고, 이 PointIndex 정의는 전역적입니다. 즉 “어떤” 도달 가능성 문제의 지점 인덱스라는 것만 보장하지, “특정한 바로 그” 문제의 지점 인덱스인지를 보장하지 않습니다. 따라서 서로 다른 문제에서 나온 지점 인덱스를 엉뚱한 자료구조에 넘기는 실수를 여전히 잡지 못합니다. 더 나쁜 점은 패닉조차 일으키지 않고 조용히 엉뚱한 결과를 돌려줄 수 있다는 것입니다. 그 결과, 비교적 단순한 실수나 오타로 테스트에서도 잡히기 어려운 버그를 만들 수 있는 API가 됩니다(테스트에서 우연히 같은 값을 돌려주면 더더욱).
두 번째, 아마 더 큰 문제는 인덱스를 인덱스로 사용한다는 점입니다. 즉 사용이 결국 배열(예: 위의 point_info)을 인덱싱하게 되므로, 항상 경계 내여야 합니다. 올바르게 사용하면(함수 인자로 받은 지점 인덱스와 PointInformation 내부에 저장된 인덱스가 모두 해당 문제의 지점 인덱스라면) 물론 항상 경계 내입니다. 하지만 컴파일러는 이를 알지 못해, 모든 인덱싱에 경계 검사를 삽입합니다. 현대 CPU에서 실패 분기 예측은 빠르지만, 그래도 시간과 I-캐시 압박이 생기니 없애는 편이 낫습니다. 또한 경계 검사를 생략하려고 unsafe 코드를 쓰면 라이브러리 전체가 사운드하지 않게 됩니다. 앞의 문제 때문에 항상 경계 내라고 보장할 수 없으니까요. 엉뚱한 문제의 지점 인덱스를 인자로 넘길 수 있고, 그러면 실제로 경계를 벗어날 수 있습니다.
이 두 문제 때문에 ReachabilityData의 메서드 문서가 지저분해집니다. 거의 모든 메서드에 이런 주석이 붙습니다.
Panics
주어진 지점 인덱스가 이 도달 가능성 문제의 것이 아니라면, 이 메서드는 잘못된 결과를 반환하거나 패닉할 수 있습니다.
이는 “이 메서드는 정의되지 않은 동작을 합니다”에 아주 가깝습니다. 메모리 안전을 깨지 않기에 완전히 정의되지 않은 것은 아니지만, 결과가 더 작아질 뿐 버그를 막아주지는 못합니다.
같은 프로그램에서 표시 코드를 작성할 때 다른 문제가 생겼습니다. 효율성과 구현 편의를 위해 ReachabilityData와 PointInformation은 지점 인덱스를 사용합니다. 하지만 최종 사용자는 인덱스가 아니라 지정했던 실제 지점으로 생각합니다. 예를 들어 A→B→A 경로를 찾았고, 이를 사용자에게 출력하고 싶다고 합시다. 자명한 해법은 경로를 나타내는 객체를 만들고, 그 Display 구현으로 경로를 출력하는 것입니다. 그 과정에서 각 지점도 출력하겠죠. 그런데 지점이 인덱스로 저장되어 있다면, Display는 무엇을 출력해야 할지 문맥이 부족합니다. Display::fmt는 self와 포매터 외에 인자가 없으니(fmt의 포매터는 도움이 안 됨), 예컨대 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를 비교해 같은 슬라이스(시작 주소와 길이 동일)인지 런타임에 검사할 수 있습니다. 생성자에서 인덱스 범위 검사도 해서, 범위를 벗어나면 생성 자체를 거부할 수 있겠죠. 그러면 별도의 경계 검사 없이도 point_info에 대한 참조가 항상 경계 내임을 보장합니다.
이 기법이 모든 문제를 풀까요? 아쉽게도 아닙니다. 경계 검사를 줄인다고 해도 point_names 비교로 대체할 뿐이니 실제 절약은 없습니다. 또한 PointInformation 내에 PointIndexDisplay 값을 저장하는 것도 별로입니다. 기술적으로는 가능하지만 큰 메모리 팽창을 일으킵니다. 모든 인덱스마다 슬라이스 참조(현대 컴퓨터에서 보통 16바이트)가 붙는데, 이 참조는 전부 동일하니 반복 데이터를 대량 저장하는 메모리 낭비가 됩니다. 따라서 이 기법은 실수 검출(인덱스를 틀리면 조용한 오답 대신 결정적으로 패닉)과 Display 구현에는 도움이 되지만, 비교 비용 때문에 광범위하게 쓰기는 비효율적이고, 버그 검출도 런타임에 일어납니다(가능하면 컴파일 타임에 잡는 것이 바람직합니다).
그래도 여기에는 좋은 아이디어의 씨앗이 있는 듯합니다. 아직 만족스럽지는 않지만요.
이번에는 제가 직접 작성하진 않았지만, 많은 사람들이 작성하고 싶어하는 코드의 예시입니다.
어떤 프로그램은 힙에 짧은 수명의 데이터를 대량으로 할당할 필요가 있습니다. 일반적으로 힙 할당은 오버헤드가 큽니다. 자주 할당/해제하면 성능 병목이 되곤 합니다.
한 가지 해법은 가비지 컬렉터를 사용하는 것입니다(이 시나리오에서 성능을 꽤 잘 최적화할 수 있음). 하지만 Rust 같은 시스템 프로그래밍 언어에 GC를 통합하기는 어렵습니다. 게임 개발에서 흔한 또 다른 해법은 로컬 할당자(local allocator)입니다. 특정 스코프(예: 어느 함수/메서드 본문)에서만 존재하는 객체들의 할당을 더 최적화하는 방식입니다. 할당 지점 수가 제한되므로, 필요하지 않은 기능을 과감히 포기하면서 실제 사용되는 패턴에 최적화할 수 있습니다. 간단한 예로 “이 할당자가 할당하는 모든 객체는 동일한 타입이며, 모두 동시에 해제된다”는 조건에서, 어디에 할당할지 계산이 매우 단순해져 범용 할당자보다 훨씬 단순/빠르게 만들 수 있습니다.
일반적인 Rust 코드와 상호 운용되는 로컬 할당자가 하려는 일은 무엇일까요? 할당의 주된 작업은 메모리 할당/해제입니다. 문제는 가시성입니다. 할당자와 상호작용하는 코드는 할당자에 접근할 수 있어야 합니다. 메모리를 요청하고, 할당된 객체의 메모리를 언제 회수할 수 있는지 알려주기 위해서요.
할당은 매우 단순합니다. 할당자가 필요한 모든 함수/메서드에 그에 대한 참조를 인자로 넘기면 됩니다(개념적으로는 가변 참조가 필요하지만, Rust는 호출마다 참조를 재빌림(reborrow)해 함수로 소유권이 옮겨가지 않게 해 줍니다). 모든 함수의 인자와 호출에 같은 파라미터가 계속 언급되어 다소 번거롭긴 하지만(이 문제는 이 글의 맥락과 무관한 상황에서도 생깁니다. 언젠가 따로 글을 쓸지도), 특별히 문제가 되지는 않습니다. 다음과 같은 모양입니다.
rust// 할당자는 이 함수의 스코프에 존재 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); /* ... */ }
불행히도 해제에도 같은 해법을 쓰면 문제가 생깁니다. 개념적으로는 할당과 해제가 서로 거울상이라 매력적이죠:
rustlet my_object = Box::new_in(/* value to box */, allocator); /* ... */ // 이 메서드는 제가 예시를 위해 만든 것입니다. 아래 이유로 Rust에는 없습니다 Box::drop_in(my_object, allocator);
이런 방식은 C 같은 언어에서도 간혹 보입니다. 하지만 현실에서는 보안 구멍으로 자주 이어집니다. 올바르게 작성하면 안전하지만, 여러 할당자를 쓰는 프로그램은 의외로 자주 잘못된 할당자로 해제해 버립니다. 이는 보통 정의되지 않은 동작이고 흔히 보안 버그입니다. Rust의 할당자가 이렇게 동작했다면 사운드성에도 구멍이 생겼을 겁니다:
rustfn 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); /* oops */ }
타입 시스템은 잘못된 할당자로 해제하려는 시도를 막지 못했습니다. 두 할당자는 타입이 완전히 동일합니다(allocator1과 allocator2는 같은 라이프타임에 살아 있고, 그 참조도 동일한 라이프타임). 타입 시스템이 확인할 수 있는 것은 많지만(예: my_object가 드롭되기 전에 할당되었고, 그 이후 사용되지 않음), “이 할당자가 맞다”는 것은 아닙니다.
이 문제를 무시한다 치더라도(예: Box::drop_in을 unsafe로 만들어 프로그래머가 직접 올바른 할당자인지 검증하게 함), 또 다른 문제가 있습니다. Rust에서 수동 드롭은 흔치 않습니다(가능하긴 합니다). 보통 객체는 소멸자에서만 드롭됩니다. 그럼 소멸자를 쓰면 어떻게 될까요? 아주 단순한 경우부터 삐걱댑니다. 단지 하나의 Box만을 가진 struct가 소멸자에서 이것을 해제하려고 할 때 말이죠:
ruststruct 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 같은 표준 트레이트와 잘 맞지 않습니다. 매개변수 목록이 고정되어 있으니까요.
결국 실전에서는 다른 구현이 필요합니다. 해제하는 모든 함수 에서 할당자를 알고 있게 하는 대신, 그 할당자가 할당한 객체마다 할당자를 추적하는 것입니다. 이렇게 하면 소멸자든 어디서든 해제가 아주 쉬워집니다. 객체에게 “누가 너를 할당했니?”라고 물어보고, 그 할당자에게 돌려주면 됩니다. 예를 들어 현재의 불안정(unstable) 비전역 할당자 구현은 이런 접근을 씁니다. Box::new_in으로 특정 할당자를 지정해 Box를 만들면, 그 Box는 할당에 대한 참조와 할당자 자체를 저장합니다. <Box as Drop>::drop은 저장된 할당자로 해제합니다.
불행히도 이 접근은 기술적으로는 동작하지만, 로컬 할당자에는 실제로 쓰기 어렵게 만드는 문제가 최소 두 가지 있습니다. 더 분명한 것은 성능 문제입니다. 로컬 할당자를 쓰는 주된 이유가 성능 향상인데, 할당자를 매 객체에 저장하면 메모리를 상당히 더 사용합니다(“자연스러운” 할당자 표현은 이를 설명하는 메모리에 대한 참조일 텐데, 이러면 Box의 크기가 두 배가 됩니다. 이제 참조가 둘이니까요. 어떤 프로그램에서는 자료구조 대부분이 Box로 이루어지기도 하니, 프로그램 메모리 사용량이 두 배가 될 수도 있습니다). 이 추가 메모리(및 읽기/쓰기 시간)는 개념적으로 필요하지 않습니다. 어떤 할당자로 해제해야 할지 추적하는 것은 어렵지 않고, 저장된 할당자는 a) 올바른 할당자인지 확인하는 검사, b) 소멸자 같은 특수한 문맥에서 할당자에 접근하는 수단으로만 의미가 있습니다.
덜 분명한 문제는 할당자 자체의 사운드성 검증과 관련 있습니다. 개념적으로 할당/해제는 할당자를 변이시킵니다(어떤 메모리가 할당되었는지 내부 상태가 바뀜). Rust에서 이를 표현하는 표준 방법은 할당/해제가 할당자에 대한 가변/유일 참조를 받도록 하는 것입니다. 그런데 “할당한 모든 Box에 할당자를 저장한다”는 생각과 충돌합니다. 상태가 있는 할당자 자체를 저장할 수 없으니 할당자에 대한 참조를 저장해야 합니다. 하지만 Rust에서는 가변 참조 둘을 동시에 가질 수 없으니(둘 중 하나라도 가변이면), 무엇을 저장해야 할지 애매해집니다. Rust의 실제 Box::new_in 구현은 이 문제를 회피해 다음처럼 다소 이상한 호출 형태를 낳습니다.
rust// 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로 이동되므로, 첫 할당 후에는 할당자를 더 쓸 수 없어 실전에서는 공유 가능해야 합니다. 이렇게 하면 Allocator 트레이트를 올바르게 구현하기 매우 난해해집니다. “재귀적으로 사용되지 않는다” 같은 것을 컴파일 타임에 검사할 수 없고, 그 결과 할당자는 자신의 내부 상태나 할당하려는 메모리에 대한 유일 접근권을 받지 못합니다. 이 문제는 안전한 코드로도 Cell을 써서 풀 수 있지만, 그러면 재귀 사용을 막기 위해 런타임 검사(이중 가변 빌림을 막기 위해 패닉하거나 더미 값을 반환하는 등)가 들어갑니다. 컴파일러가 불필요함을 증명하지 못한 잠재적 패닉 지점들로 가득한 할당자가 되어 더 느려지죠.
정리하면, 현재 Rust에서 로컬 할당자는 별로 잘 작동하지 않습니다. 효율적인 해법(할당자 참조를 필요한 모든 함수 인자로 넘기고, 할당/해제에 모두 사용)은 컴파일러가 사운드성을 분명히 확인하기 어렵고 소멸자와도 잘 맞지 않습니다. 정말 “거의” 되는 듯하면서 효율까지 좋은 접근인데도요. 대안(모든 객체에 할당자 저장)은 비효율적이고, 할당자에 대한 참조가 너무 많아져 컴파일러가 할당자 구현의 올바름을 증명하기 어렵게 만듭니다. 그래서 런타임 검사(또는 unsafe 코드)가 필요해져 더 느려집니다. Rust 프로그램을 더 빠르게 만들기 위해 로컬 할당자를 쓰려는 아이디어가 막다른 길이 되지 않으려면, 다른 해법이 필요합니다.
앞의 예시들에서 본 바와 같이, 현재 Rust가 표현하기 어려워하는 상황은 대략 이렇습니다. 어떤 객체를 해석하거나 안전하게 사용하려면 공통 정보가 필요합니다(진법, 어느 인덱스가 어느 지점인지의 목록, 할당자 등). 그리고 그 정보는 관련 객체들 사이에서 동일해야 합니다(효율적 덧셈, 올바른 배열 참조, 올바른 할당자로 해제 등). 종종 트레이트 구현도 어려워집니다(첫 예시는 Default, 둘째는 Display, 셋째는 Drop).
이 절에서는 가능한 해법들을 살펴보겠습니다. 어느 것도 이상적이지 않지만, 왜 실패하는지 이해하면 문제를 더 명확히 하고 해법 요건을 정확히 기술하는 데 도움이 됩니다(의도적으로 생략한 해법 하나는 뒤에서 다룹니다. 다른 걸 놓쳤다면 알려 주세요). 먼저 가장 자명한 두 가지 접근(위에서 최소 두 번씩 시도했던 것들)부터, 덜 자명한 가능성으로 넘어가겠습니다.
저처럼 C 같은 저수준 시스템 언어로 프로그래밍을 배운 이들에게 가장 자명한 접근은 “불완전 객체(incomplete object)”입니다. 공통 정보는 객체 안에 저장하지 않고, 그 객체들을 사용하는 코드가 별도로 저장합니다. 둘째 예시의 PointIndex(지점을 나타내지만 어떤 인덱스가 어떤 지점인지 정보는 없음), 셋째 예시의 할당자가 반환한 메모리(같은 할당자로 해제되어야 하지만 할당자 정보는 메모리에 없음)가 예입니다.
이 접근은 대개 매우 효율적입니다. 정보는 객체 전체 집합에 대해 한 번만 저장하면 되니까요. 이로 인해 그 정보를 쓰는 메서드 시그니처가 복잡해집니다. 단일 객체에서는 단점이지만, 두 객체가 동일한 정보를 필요로 할 때는 장점이 되기도 합니다. 예컨대 첫 예시에서 SparseBigInt를 base 없이 표현했다면, 결과 불완전 객체는 단지 자릿수 목록이고 덧셈 함수는 이렇게 생겼을 겁니다:
fn add_bigint(x: DigitList, y: DigitList, base: i32) -> DigitList { ... }
이 시그니처는 두 정수를 같은 진법에서만 더할 수 있음을 아주 명확히 보여 줍니다. 자릿수 목록은 두 개지만 진법은 하나만 받으니 말이죠.
하지만 단점이 큽니다. 두 가지입니다. 첫째, 공통 정보가 잘못 지정되는 것을 막을 수 없습니다. 맥락에 따라 그 결과는 잘못된 결과(예: 진법을 잘못 해석)에서 사운드성 구멍(예: 배열 경계 밖 인덱싱, 다른 할당자로 해제)까지 다양합니다.
둘째, 트레이트 구현이 매우 어렵습니다. 첫 예시에서 이 방식을 쓰려 했다면 Add를 구현할 수 없다는 사실을 곧 깨달았을 겁니다. DigitList 둘을 +로 더하려는데 진법을 지정할 곳이 없으니까요. 둘째 예시에서도 PointIndex 같은 불완전 객체에는 Display를 구현할 수 없어 별도의 PointIndexDisplay를 만들어야 했습니다. 불완전 객체는 Drop을 유용하게 구현할 수도 없어서 셋째 예시의 주요 문제가 됩니다.
반대 접근은 공통 정보를 그것이 필요한 모든 객체마다 저장하는 것입니다. 즉 DigitList 대신 완전한 SparseBigInt, PointIndex 대신 PointIndexDisplay, 그리고 현재의 불안정 Box처럼 할당자도 함께 저장하는 방식입니다. 이는 가시성 문제를 “무식하게” 푸는 방법이라고 볼 수 있습니다. 무엇이든 항상 보이게 하려면, 그것(혹은 그에 대한 참조)을 그것을 쓸 가능성이 있는 모든 곳에 저장하는 겁니다!
SparseBigInt에서는 이 접근이 크게 문제를 일으키지 않습니다. 공통 정보인 base(i32)는 저장하기 쉽습니다. 메모리를 거의 쓰지 않고, Copy라 라이프타임 문제가 없고, 변하지 않으니 버전 문제도 없습니다. 그래도 공통 정보의 데이터 소스가 여러 개면 항상 같은 값임을 증명해야만 정확성을 보장할 수 있습니다. SparseBigInt 둘을 더할 때 base가 같은지 검사해야 하고, 이 접근으로는 컴파일 타임에 이를 확인할 수 없습니다(불완전 객체 접근과 대칭입니다. 거기서는 base가 한 번만 지정되어 잘못 지정되면 문제가 되지만 하나만 있다는 보장은 있고, 여기서는 잘못 지정될 일은 없지만 두 base가 같은지 증명할 수 없어 문제가 됩니다).
불행히도 공통 정보가 항상 작거나, Copy거나, 복제 가능한 것도 아닙니다. 둘째 예시에서 point_names는 문자열 리스트에 대한 공유 참조입니다. 예시에서는 단순화했지만 실제로는 훨씬 복잡하곤 합니다. 셋째 예시에서 공통 정보는 개념적으로 할당자에 대한 가변 참조이며, 이를 둘 저장하는 것이 문제를 일으킨다는 건 잘 알려져 있습니다. 그래서 현재 불안정 할당자 구현은 의미가 상당히 다른 타입으로 이를 표현해 컴파일러가 해당 할당자에 대해 많은 안전 속성을 증명하기 어렵게 만듭니다. 불완전 객체에서는 이런 문제가 없습니다. 공통 정보 하나만 사용하고 필요할 때 빌리면 되니까요. 하지만 이 접근에서는 넘기기 어려운 문제들입니다.
또한 앞의 첫 예시에서 보았듯, 이 접근은 Default나 From처럼 새 객체를 만드는 메서드를 가진 트레이트와 잘 맞지 않습니다. 대부분의 트레이트에서는 self 파라미터로 공통 정보를 트레이트 구현에 전달할 수 있지만, self가 없는 메서드에서는 여전히 가시성 문제가 남습니다.
여기서부터는 “컴파일을 자주 해도 괜찮다”는 사람들에게 적합한, C(특히 FORTRAN 같은 오래된 언어)에서 흔히 보이는 접근입니다. 일반적이지는 않지만, 적용 가능한 경우에는 모든 문제를 혼자서 해결합니다.
아이디어는 이렇습니다. 프로그램 전체에서 단 하나의 공통 정보만 필요할 때가 있습니다. 제가 타핏을 분석하던 때가 그랬습니다. 모든 SparseBigInt에 하나의 진법(예: 260)만 필요했죠. 프로그램을 단 하나의 입력에 대해서만 돌리면 된다면, 공통 정보를 상수로 만들어 버릴 수 있습니다(Rust라면 const나 변경 불가능한 static). 이 방식은 많은 장점이 있습니다.
자명한 단점은 a) 프로그램 전체에 공통 정보가 하나뿐이어야 하고, b) 바뀔 때마다 재컴파일해야 한다는 것입니다. 그래도 자신만 사용할 프로그램이거나, 다양한 맥락에서 쓰일 라이브러리가 아니라면 그렇게 큰 문제는 아닐 수 있죠.
흥미롭게도 이 접근은 Rust 자체에서도 가끔 쓰입니다. 앞서 할당자 예시에서는 주로 불안정 할당자 API를 이야기했지만, 안정(stable) 할당자 API도 있습니다. 다음과 같습니다.
rust#[global_allocator] static GLOBAL_ALLOCATOR: MyAllocator = /* ... */;
할당자 불일치를 막기 위해 바이트를 들여 할당자 참조를 저장하지 않으면서도 사운드를 지키려면, Rust의 안정 버전은 프로그램 전체에 상수 할당자 하나만 두는 방식을 씁니다. Box, Vec 등 친구들을 쓰면 항상 이 할당자가 메모리를 할당하고, 드롭될 때도 이 할당자로 메모리가 반환됩니다. 다소 단순하지만, 현재 대부분의 프로덕션 Rust 코드는 이 방식으로 움직입니다.
이전 해법은 컴파일 타임 상수를 썼지만, 변경 가능한 전역 변수에 비슷한 접근을 적용할 수도 있습니다. 기본 아이디어는 관련 객체가 생성되기 전에 전역 변수에 공통 정보를 초기화하고, 그 객체들이 살아 있는 동안 그 값이 유지되도록 하는 것입니다.
프로그램 수명 동안 공통 정보가 하나만 필요한 경우(예: 제 SparseBigInt 예시)에는 cell::OnceCell로 간단히 가능합니다. 두 가지 사운드 요구 사항이 있습니다(전역 변수가 제때 초기화되고, 충분히 오래 유지되는 것). OnceCell은 두 번째 요구 사항을 자명하게 만족합니다(영원히 값을 유지). 불행히도 첫 번째 요구 사항 때문에 비효율이 생깁니다. 컴파일러가 전역 변수가 첫 객체 생성 전에 초기화되었음을 정적으로 증명할 수 없으니, 매번 요청 시 초기화 여부를 검사하는 구현이 되기 쉽습니다(완전한 제거는 아마 unsafe가 필요해 보입니다).
공통 정보가 변하는 경우에도 전역 변수를 사용할 수 있을지 모릅니다. 하지만 동시에 존재할 각 공통 정보마다 하나의 전역 변수가 필요합니다. 특히 재귀 코드에는 적용하기 어렵습니다. 중첩된 “공통 정보 라이프타임”이 임의로 많아질 수 있으니까요. 컴파일러가 전역이 올바르게 초기화/지속되는지를 검사하는 데 큰 도움을 주지 않는 것 같습니다(Rust는 전역이나 내부 가변성의 상태를 컴파일 타임에 거의 검사하지 않습니다). 그럼에도 불가능하다고 단정할 수는 없습니다. 대략 다음 같은 구상은 어떨까요?
Deref)를 구현해 전역에 접근합니다(실제 참조를 저장할 필요 없이, 메서드 코드에 위치가 박혀 있음).이 방법은 잠금 구현에 unsafe가 필요할지라도, 이를 사용하는 코드에는 사운드하고 안전한 인터페이스를 제공할 수 있을 듯합니다. 새로운 라이브러리 크레이트의 기반이 될 수도 있겠죠! 다만 코드가 일반적이지 않습니다. 전역에 박혀 있는 데이터를 “참조”로 제공하는, 다소 특이하게 동작하는 크기 0 타입들이기 때문입니다. Rust의 가장 근접한 예시는 &sync::RwLockReadGuard지만, 이는 크기 0이 아니고, 크기 0 버전을 만들려면 바이트 저장 위치를 하드코딩해야 하므로 사실상 처음부터 새로 작성해야 합니다.
또한 “완전 객체” 해법의 문제를 모두 풀지는 못하고, 오히려 하나는 더 악화시킵니다. 완전 객체 해법은 공통 정보로 가변 참조를 쓰기 어려웠는데, 이 방법은 심지어 공유 참조도 어렵습니다(전역에 참조를 저장하려면 보통 'static이어야 함). 실전에서 공통 정보가 참조인 경우가 흔하다는 점에서 이는 심각한 단점입니다.
이 절의 모든 것은 스레드 로컬 변수로도 가능하지만, 목적에 비해 전역 대비 장점이 뚜렷하지 않습니다.
앞 절의 크기 0 타입은 다른 가능성을 시사합니다. 공통 정보가 컴파일 타임에 알려져 있다면, 전역 변수에 대한 “참조”를 들고 다니는 대신 값 자체를 들고 다니면 어떨까요? 전역을 역참조하는 트레이트 구현 대신, 역참조된 값을 하드코딩할 수 있습니다. SparseBigInt 예시로 보겠습니다.
rust#[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(); }
하지만 이 구현은 진법이 항상 같은 값을 역참조한다는 보장을 주지 않습니다(deref가 결정적일 필요가 없으니까요). 다행히 Rust에는 이를 해결하는 기능이 이미 있습니다. 상수 제네릭(const generic) 입니다.
rust#[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 컴파일러는 같은 진법의 SparseBigInt끼리만 덧셈을 허용합니다.
impl<const BASE> Default
for SparseBigInt<BASE>
같은 코드는 완전히 합법이며, default 구현 내부에서 BASE 값을 사용할 수 있습니다.
즉 공통 정보 값이 객체의 필드나 메서드 파라미터가 아니라 제네릭에 저장되는 것입니다.
안타깝게도 이 해법은 대부분의 경우에 사용할 수 없습니다. 두 가지 큰 문제가 있습니다. 하나는 상수 제네릭은 컴파일 타임 상수여야 하는데, 공통 정보는 종종 런타임에 결정됩니다. 다른 하나는 현재 상수 파라미터의 타입에 극심한 제한이 있다는 것입니다(원시 정수류 타입이어야 함). 미래에 일부 완화될 계획이 있긴 합니다. 그래도 여전히 공통 정보가 상수 제네릭으로 쓰기 부적절한 타입인 경우가 많을 것입니다(예: 앞의 할당자 예시는 개념적으로 런타임 생성 값에 대한 가변 참조를 원합니다).
기존 언어 기능 중 완벽한 해법이 없다면, 새 언어 기능을 고민해 보는 것이 자연스럽습니다(이 글의 목적이기도 합니다). 앞의 부분적 해법들을 보고, 이상적인 해법은 어떤 모습이어야 할까요?
새 언어 기능을 구현할 때 가장 중요한 두 가지는 a) 의미론(이 기능을 사용한 프로그램이 어떻게 동작하는가), b) 문법(이 기능을 사용한 프로그램이 어떻게 보이는가)입니다. 앞선 시도 중 문법적으로 두드러지게 깔끔했던 것은 상수 제네릭입니다. 쓰기 쉽고 이해도 쉽죠. 이로부터 우리가 찾는 언어 기능은 공통 정보를 나타내는 새로운 종류의 제네릭이어야 함을 알 수 있습니다. 즉 특정 공통 정보로 해석되어야 하는 구조체/열거형 등은 그것을 제네릭 파라미터로 선언하고, 그 위에서 동작하는 함수/메서드도 마찬가지로 그 공통 정보에 대해 제네릭이 되어야 합니다.
그렇다면 의미론은 어떨까요? 공통 정보의 중요한 속성은 여러 객체에 대해 공통이라는 점이고, 종종 그 동일함을 컴파일 타임에 증명할 필요가 있습니다. 따라서 공통 정보가 제네릭 파라미터로 제공될 때, 해당 제네릭이 붙은 특정 객체의 모든 사용이나 그 제네릭을 가진 함수 호출 전체 동안 그 값이 변하지 않아야 합니다. 즉 그 구간에서는 상수처럼 다루어져야 합니다(상수 제네릭은 프로그램 전체에서 상수였죠). 반면 일반 상수와 달리, 적극적으로 사용하지 않는 지점에서는 값이 정의되어 있지 않을 수도 있고, 루프의 각 반복마다 다른 값을 가질 수도 있습니다.
즉 올바른 해법은 “스코프 상수” 같은 것입니다. 프로그램 전체가 아니라 특정 코드 영역 안에서만 상수인 값입니다. 기본 아이디어는 “이 블록과 그 안에서 호출되는 모든 함수 등에서, 이 값을 이 이름으로 부를 것이고, 블록 전체에서 그 이름은 그 값을 가리킨다”를 선언하고, 그 이름을 상수 제네릭처럼 객체 생성, 함수 호출 등에 사용할 수 있게 하는 것입니다.
문법적으로는 대략 이렇게 보일 수 있습니다.
rust#[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는 다시 정의되어 있지 않음. BASE를 사용하는 할당된 객체나 // 실행 중인 함수가 남아 있지 않음을 알 수 있음 }
핵심은 let const BASE: i32 = base;가 현재 스코프에 유효한 새 스코프 상수를 정의한다는 것입니다. 이는
let
base_copy: i32 = base;
처럼 현재 스코프에 불변 변수를 정의하는 것과 유사합니다. 이후 스코프 상수는 일반 상수 제네릭처럼 사용할 수 있습니다. 단 차이는, 스코프 상수는 “라이프타임을 가진다”는 점입니다. 스코프가 끝나면 더 이상 유효하지 않으므로, 그 상수를 상수 제네릭으로 사용하는 객체는 상수의 스코프가 끝나기 전에 파괴되어야 하며, 그 상수를 사용하는 함수/메서드도 스코프가 끝나기 전에 실행을 마쳐야 합니다. (상수가 라이프타임을 가진다는 것을 나타내기 위해, 제네릭을 const 'a로 표기했습니다. 이는 해당 객체의 라이프타임 상한을 'a로 제한합니다. &'a 참조를 포함하는 것과 같은 의미입니다.)
또 일반 상수 제네릭과 다른 점은, 스코프 상수의 “타입 동등성”은 값 비교가 아니라 동일한 let const 문에서 만들어졌을 때만 성립한다는 것입니다. 우연히 같은 값을 가진 서로 다른 스코프 상수를 만들고, 각 상수를 사용하는 두 객체를 같은 배열에 저장하려 들면 불가합니다(런타임 값이니 컴파일 타임에 항상 같음을 증명할 수 없음은 놀랍지 않습니다). “같은 선언에서 만들어졌다”는 기준을 쓰므로, 스코프 상수는 Eq를 구현할 필요가 없습니다(일반 상수와 달리).
언제나 그렇듯, “스코프 제네릭”에 쓸 수 있는 타입에는 몇 가지 제약이 있습니다. 가장 기본은 스코프 상수의 타입입니다. 단순한 경우는 Copy인 타입(예: 공유 참조)입니다. 가변 참조도 사용할 수 있지만 추가 제약이 있습니다(다음 소절 참조).
또 스코프 제네릭을 가진 객체/트레이트를 트레이트 객체로 쓰거나, 스코프 제네릭을 가진 함수/메서드를 함수 포인터로 쓰는 경우에는 제약이 필요할 가능성이 큽니다. 이들 사용은 안전함을 증명하지 못했으며 일부 경우에는 불안전할 수도 있습니다(최소한 트레이트 객체의 라이프타임을 스코프 상수의 라이프타임으로 제한해야 할 텐데, 그것만으로 충분한지 확신이 없습니다). 비교적 주변적인 사용이므로, 초기 구현에서는 전면 금지하는 것이 합리적입니다. 나중에 안전한 사용을 엄밀히 규명해 허용해도 되겠죠.
함수나 메서드가 스코프 제네릭 파라미터를 가지면, 이는 일반 메서드 파라미터처럼 동작할 수 있습니다.
rust// “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여야 하는 주된 이유입니다. 사실상 메서드 파라미터처럼 전달되는데, 대부분의 타입 값은 메서드 인자로 사용하면 이동되어(소비) 더는 쓸 수 없기 때문입니다. 스코프 제네릭은 여러 번 사용 가능해야 하니까요.
하지만 Rust에는 인자로 넘겨도 소비되지 않는 또 다른 타입이 있습니다. 바로 가변 참조입니다. 이것도 스코프 제네릭으로 사용 가능하다면 매우 유용합니다. 예컨대, 안전한 Rust로도 구현 가능하고 기존 unsafe 할당자와도 호환되는 할당자 API는 대략 이렇게 생겼을 수 있습니다.
rust/// 라이프타임 `'a`의 객체를 할당하는 할당자 struct MyAllocator<'a> { /* ... */ } /// `'a` 동안 존재하며 `ALLOC`이 생성한 `T` 타입의 할당 struct MyAllocation<const 'a ALLOC: &mut MyAllocator<'a>, T> { // 드롭하는 동안만 Some이 아님; Drop 트레이트의 알려진 제약 회피용 래퍼 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>(); // 스코프 제네릭에서 “재빌림” 받은, 이 함수 수명까지 유효한 가변 참조 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으로 반환, ALLOC을 변이 ... */ } }
MyAllocation은 참조 하나만 저장하므로 효율적입니다(할당자는 스코프 제네릭이므로 메모리에 필드로 저장할 필요가 없습니다). MyAllocation의 스코프 제네릭은 해당 할당이 드롭될 때 반드시 그것을 생성한 할당자에게 돌아가도록 보장합니다(같은 할당 수명을 가진 다른 할당자가 아님). 이로써 unsafe 코드가 그 가정을 사운드하게 둘 수 있습니다. 또한 할당의 라이프타임을 스코프 상수의 라이프타임에 묶음으로써, 할당자보다 먼저 모든 할당이 반드시 드롭되도록 보장합니다.
이를 사운드하게 만들려면 추가 제약이 필요합니다. 첫째, 가변 참조 타입의 스코프 상수(혹은 스코프 제네릭 값)를 사용할 때의 제약입니다. 전체 상수 라이프타임 동안 유효한 참조를 얻는 대신, “현재 함수/메서드까지”의 수명으로 제한된 참조를 얻으며, 그 참조가 드롭될 때까지 스코프 상수는 “가변 빌림” 상태로 간주됩니다. 일반(비 스코프 상수) 가변 참조보다 제약이 있지만, 여전히 유용한 프로그램을 만들기에는 충분합니다.
이 때문에 스코프 제네릭 타입을
&mut
MyAllocator<'a>
로(참조에 라이프타임 표기가 없이) 썼습니다.
즉 스코프 상수 값 자체에 별도의 고정 라이프타임을 추적하는 게 의미가 없습니다(반면 공유 참조 스코프 상수였다면, 그 값의 라이프타임을 스코프 상수 자체보다 길게 추적할 수 있습니다).
둘째 제약은 서로 다른 제네릭 파라미터를 통해 동일 가변 참조의 두 복사본을 얻을 수 없게 해야 합니다(타입 제네릭이든 스코프 제네릭이든). 충분하고, 초기 구현에 추천하는 제약은 다음입니다. “스코프 제네릭이 가변 빌림 중인 동안, 그 스코프 제네릭의 타입 및 그 제네릭 파라미터를 제외한 임의의 타입 제네릭이나 스코프 제네릭을 사용해 어떤 추가 동작도 할 수 없다(라이프타임/상수 제네릭은 사용 가능)”. 즉 제네릭으로 쓰거나, 예컨대 트레이트 메서드 호출의 수신자 타입으로 쓰는 것도 금지됩니다. 다른 관점으로 보면 “빌림이 존재하는 동안 사용되는 모든 타입은 스코프 제네릭 자체의 타입, 그 타입에 포함된 타입, 혹은 함수/메서드에 하드코딩된 타입뿐”입니다(물론 코드 실행을 수반하지 않는 제네릭 작업, 예: 이동/복사, 참조를 raw 포인터로 변환 등은 가능). 이 제약은 필요한 것보다 살짝 엄격하지만, 이해/구현이 쉽고 미래에 완화될 수 있습니다. 또한 실제로는 빌림의 라이프타임이 매우 짧은 경우가 많아 제약이 크게 불편하지 않습니다.
위 문법 세부는 취향 논쟁의 여지가 있습니다(예: 굳이
let
const
가 아니라 일반 let이면 안 될까? 제네릭의 const 키워드가 프로그램 전체에서 상수가 아니라는 점에서 부적절하지 않을까?). 그러나 적어도 의미론적으로는 이 해법이 옳다고 자신합니다. 이유는 몇 가지가 있는데, 그 중 하나를 여기서 다루고 나머지는 뒤에서 이야기하겠습니다.
널리 발생하는 문제를 푸는 언어 기능을 평가할 때 “왜 이 문제가 그동안 해결되지 않았을까?”를 자문할 가치가 있습니다. 많은 경우, 실제로는 과거에 특수한 형태로 해결되었지만, 일반 문제 전체를 다루지는 못했음을 발견하게 됩니다. 특수 해법을 살펴보면 일반 해법과 비교하는 데 도움이 됩니다.
이 글만큼 일반적인 문제라면, Rust의 핵심 기능 중 하나 정도는 “스코프 제네릭 문제”의 특수해법으로 볼 수 있어도 놀랍지 않습니다. 레퍼런스와 포인터의 차이를 생각해 봅시다.
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의 주소를 저장합니다. 차이는 raw 포인터는 적절한 주의 없이 사용하면 사운드하지 않다는 것입니다(역참조하려면 대상이 할당/초기화/생존 중인지, 현재 가변 빌림 중이 아닌지 등을 보장해야 함). 반면 레퍼런스는 추가 정보(라이프타임)를 담아, a) 언제 사용하는 것이 사운드한지 조건을 명시하고, b) 레퍼런스를 쓸 수 있는 기간을 제한합니다. 라이프타임의 본질은 타입 별칭으로 표기하면 더 분명해집니다.
rusttype ThingRef<'a> = &'a Thing; fn pick_a_thing<'a>(x: ThingRef<'a>, y: ThingRef<'a>) -> ThingRef<'a> { /* ... */ }
라이프타임은 a) 제네릭이며, b) 특정 스코프 동안 존재하고, c) 서로 다른 객체 간 비교 가능합니다. 이는 불완전/완전 객체를 가르는 공통 정보와 매우 흡사합니다. 실제로 레퍼런스와 포인터의 차이는 바로 이 라이프타임입니다.
사실 Rust의 기존 라이프타임과 제가 제안한 스코프 상수의 거의 유일한 차이는, 스코프 상수는 런타임에 존재하는 비자명한 값을 가진다는 점이고, 라이프타임은 컴파일 타임에만 존재하는 크기 0 구성물이라는 점입니다. 어떤 면에서 Rust의 라이프타임은 “크기 0 타입으로 제한된 스코프 제네릭”이라 볼 수 있습니다(마찬가지로 Rust의 상수는 “‘static 라이프타임을 가진 스코프 상수”로 볼 수 있죠).
따라서 스코프 제네릭이 올바른 해법이라고 확신하는 이유 중 하나는, 기존 Rust의 단순한 일반화이기 때문입니다. “크기 0 타입의 스코프 제네릭은 곧 라이프타임이며, 라이프타임을 구현하는 데 사용할 수 있다”고 생각해 보면 됩니다. 그렇게 하면 결과는 Rust의 기존 라이프타임 구현과 같습니다. 이는 고무적인 신호입니다.
앞서 “스코프 제네릭 문제”의 해법을 가능한 한 나열하되 하나를 제외한다고 했습니다. 이번 절은 그 남은 해법에 관한 것입니다. 다양한 일을 사운드하게 구현하는 방법을 다루는 Rust 관련 글들을 읽다 보면, 가끔 이런 말을 보게 됩니다. “이 코드는 런타임 검사로 사운드를 보장하며 인자가 잘못되면 패닉합니다. 컴파일 타임 보장을 원한다면 생성성(generativity)을 사용하세요.” 그리고 컴파일 타임 해법은 너무 어렵거나 복잡하거나 혼란스럽다는 뉘앙스를 풍깁니다(실제로 실전에서 거의 쓰이지 않는 듯합니다!). 원천은 Gankra의 학위 논문(PDF) 6.3절로 보이며, 거기에도 “이 트릭은 매우 섬세하며 널리 쓰이리라 기대하지 않는다”라는 단서가 달려 있습니다.
이번 절에서는 생성성이 무엇인지, 왜 널리 쓰이지 않았는지, 그리고 현재의 Rust 컴파일러에서 스코프 제네릭을 생성성과 매우 유사한 방식으로 구현할 수 있는지를 다루겠습니다.
그렇다면 생성성이란 무엇일까요? 글들에서는 프로그래밍 기법처럼 보이지만 사실은 추상화 메커니즘이 가질 수 있는 성질입니다. 즉 생성적(generative)일 수도, 비생성적(non-generative)일 수도 있죠(어느 쪽이 더 낫다고 할 수 없고, 상황에 따라 유용함이 다릅니다). 기본 아이디어는 동일한 것을 여러 번 추상화(예: 루프에서 호출)했을 때, 컴파일러가 결과를 서로 다른 것으로 취급할 수 있느냐입니다(“이것들은 같다”라는 관계까지 추상화되는가).
예를 들어 Rust의 impl Trait은 비생성적입니다.
rusttrait 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를 구현하는 어떤 타입을 반환하며, 같은 타입의 객체에 대해서는 항상 같은 타입을 반환한다”는 보장입니다).
반면 라이프타임은 생성성 정의에 매우 가까운 동작을 보입니다.
rust#[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" }
문법상 accept 호출은 하나뿐입니다. 전달되는 데이터는 a) y의 메모리 위치, b) y에 대한 레퍼런스의 라이프타임입니다. 그러나 루프가 돌 때마다 라이프타임이 달라집니다. 이는 값이 아니라 타입처럼 보이는 구성물임에도(컴파일 타임에만 존재하고 크기 0), 호출마다 다릅니다. 그래서 컴파일이 실패합니다. x의 타입은 Foo<'a>인데 유효한 'a가 없습니다. 루프 10회 각각에 대해 'a가 루프 반복 내에 완전히 포함되어야 하는데, 이 요구를 동시에 만족시키는 'a가 없기 때문입니다.
원래 “생성성”의 정의가 이런 동작까지 포괄하려 했는지는 모르겠습니다(라이프타임은 엄밀히 말해 추상화 메커니즘은 아닙니다). 하지만 Rust 커뮤니티에서는 일반적으로 이런 의미로 쓰니 여기서도 그렇게 하겠습니다.
Rust 글들에서 “생성성”은 보통 이러한 라이프타임의 동작(특히 불변 라이프타임 제네릭)을 사용해 사실상 고유 타입 값을 만드는 맥락에서 언급됩니다. 이를 공통 정보에 적용하고, 서로 같은 공통 정보를 요구하는 함수에 다른 공통 정보가 들어오면 컴파일러가 잡는 예시를 보겠습니다.
rust#[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를 비가변으로 만들기 위해, 구조체를 라이프타임 'a에 대해 불변(invariant)으로 만듭니다. 이를 위해 PhantomData를 사용해 가변성을 바꿉니다. (PhantomData는 주어진 타입의 가변성으로 바꿔 줍니다. *mut &'a ()는 'a에 대해 불변이므로, PhantomData는 CommonInfo의 가변성을 그렇게 바꿉니다.) 보통 Rust는 동일해야 하는 서로 다른 라이프타임을 가변성에 따라 합치려 하지만, CommonInfo의 불변성은 이를 막습니다.
이 기법은 “불완전”과 “완전” 객체 해법 모두를 개선할 수 있습니다.
rust// 완전 객체 pub struct SparseBigInt<'a> { base: CommonInfo<'a, i32>, digits: BTreeMap<i64, i32> } // 이제 같은 진법의 두 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 { /* ... */ } // `self`와 `inc` 모두에 대해 올바른 base를 넘기지 않으면 컴파일 실패 fn add_bigint(self, inc: Self, base: CommonInfo<'a, i32>) -> Self { /* ... */ } }
얻는 이점은 “공통 정보가 다름” 문제를 해결한다는 것입니다. 완전 객체의 경우 다른 진법의 두 객체를 섞는 것이 불가능해집니다. 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) 스코프 제네릭의 값을 함수/메서드에 전달하기 위한 추가 파라미터. 다음은 스코프 제네릭을 사용한 코드 예시입니다.
rust#[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) }
이것이 다음처럼 디슈거링됩니다.
rust#[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) }
이 디슈거링은 스코프 제네릭 파라미터가 직접 쓰이지 않고 타입 제네릭을 통해 암시되는 경우에도 적용됩니다(그 타입이 스코프 제네릭을 가질 때). 그리고 트레이트 메서드는 사실상 “수신자 타입에 대해 제네릭”이라 동일하게 취급됩니다. 예를 들어:
rust// 표준 라이브러리 트레이트 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); } }
이는 다음처럼 디슈거링됩니다.
rust// 디슈거링은 모노모픽 후에 적용, 트레이트 메서드는 구현 타입 전용 함수로 대체됨 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 중 하나를 선택해야 하고, 동일 데이터에 대해 두 참조가 동시에 존재할 수 없습니다.
여러 프로그램에서 이 제약은 걸림돌이 되곤 합니다. 흔히 시도되는 우회로는 참조를 벡터의 인덱스로 표현하는 것입니다. 벡터는 함수에서 함수로 가변 참조로 전달됩니다. 함수가 “역참조”에 해당하는 일을 해야 하면 해당 인덱스의 요소에 접근합니다. 이는 요소를 변경할 수 있기에 가변 참조처럼 동작합니다. 동시에 인덱스를 여러 곳에 보관할 수 있다는 점에서 공유 참조처럼도 동작합니다(그냥 정수니까요).
이런 것을 보면 스코프 제네릭이 너무 강력해진 것은 아닌가 의심이 들 수 있습니다. 여기서의 벡터는 공통 정보와 매우 유사하게 동작하고, 벡터에 대한 가변 참조는 스코프 제네릭으로 허용되는 타입이기 때문입니다. 그럼 이 기법으로 Rust의 규칙을 깨는 시도를 해 봅시다.
첫 시도는 벡터에 대한 인덱스이면서, 벡터는 스코프 제네릭인 “레퍼런스” 타입을 만드는 것입니다.
ruststruct 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으로 우회할 수도 있습니다.
rustimpl<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 구현이 같은 셀에 대한 공유 빌림을 가지고, 클론 중에 쓰려고 할 수 있기 때문입니다. 하지만 여기서는 그런 실패 케이스가 발생할 수 없습니다. 만약 T가 어떤 스코프 제네릭을 가지고 있다면, 그것들의 라이프타임은 반드시 'a보다 먼저 시작했어야 합니다(스코프 상수 VEC가 만들어질 때 이미 존재해야 T에 언급될 수 있으므로). 따라서 그 어느 것도 VEC에 대한 참조를 포함할 수 없습니다. 그리고 VEC의 값에 접근하는 수단은 스코프 제네릭뿐입니다(스코프 제네릭이 가변 빌림 중이므로). 그러니 get_clone은 사운드하며, Cell에서는 사운드하지 않은 대응 연산이지만 여기서는 사운드를 증명할 수 있습니다.
결론적으로 OmniReference는 a) 사운드하고, b) 기본적으로 Cell과 유사하지만 어떤 연산이 사운드한지 컴파일러가 더 잘 이해할 수 있습니다. 스코프 제네릭이 “이중 가변 참조”를 일으킬 가능성을 추론하는 지침이 되어 주기 때문입니다. Cell보다 (약간) 더 강력한 것을 안전하게 구현할 수 있다는 점은 재미있고, Rust의 무기고에 유용하게 추가될 수도 있습니다(Rust 초보들은 종종 Rc와 RefCell을 많이 사용하는 가변 구조를 시도하는데, RefCell의 런타임 검사를 줄이면 패닉 가능성이 줄어 신뢰성이 높아질 수 있습니다). 물론 추가 간접층 때문에 덜 효율적이긴 합니다.
더 놀라운 점이 있습니다. 이는 완전히 안전한 코드만으로 구현한 Cell입니다. 이것이 본질적으로 “불가능해야 하는 것”은 아닙니다(사운드성 구멍을 연다는 의미가 아님). 하지만 대부분의 Rust 프로그래머는 언어상 불가능하다고 여길 것이고(스코프 제네릭이나 유사 기능 없이), 실제로도 스코프 제네릭이 없다면 불가능합니다.
이걸로 Cell을 직접 대체할 수 있다면 좋겠습니다. 언어의 복잡성을 줄일 수 있겠죠(컴파일러가 Cell을 올바르게 추론하기 위해서는 UnsafeCell 관련한 많은 특수 처리가 필요합니다). 어쩌면 스코프 제네릭 기능이 언어 복잡성을 줄이는 효과가 있을지도요. 유감스럽게도 실제로는 어렵습니다. 덜 효율적이고, 프로그램 전체에서 Cell을 사용하는 모든 함수/메서드/타입에 스코프 제네릭을 추가해야 하므로 코드가 매우 번잡해집니다. 그래도 언어 단순화를 고민하는 발판이 될 수는 있습니다. 저는 프로그래밍 언어가 많은 예외적 특수 처리(UnsafeCell처럼 언어의 나머지와 다르게 동작하는 것들)를 두기보다, 다양한 문제를 푸는 몇 가지 깊은 아이디어에 기반하길 선호합니다.
스코프 제네릭 메커니즘을 앞으로 어떻게 개선할 수 있을지 아이디어를 몇 가지 제시합니다.
Rust의 제네릭은 때로 문법적 소음을 많이 유발합니다. 스코프 제네릭은 기존에 제네릭을 쓰지 않던 곳에도 제네릭을 도입합니다. 어떤 이들은 안전성 향상보다 문법 복잡도의 증가가 더 커 보일 수 있습니다. 따라서 제네릭 문법을 더 깔끔하게 만드는 방법을 생각해 볼 가치가 있습니다(스코프 제네릭을 쓰지 않는 프로그램에도 도움이 됩니다).
위에서 적은 가변 참조 스코프 제네릭 제약은 필요한 것보다 엄격합니다. 정말 무관함을 증명 할 수 있다면 무관한 제네릭을 사용해도 안전합니다(겉보기에는 무관하지만 같은 스코프 상수에 접근 가능한 경우를 배제). 이를 위해서는 “두 제네릭이 공통 스코프 상수를 공유하지 않음”을 나타내는 새로운 형태의 where 절이 필요할 것입니다. 그러면 앞의 map 같은 것도 작동할 수 있겠죠.
또 하나 매우 흥미로운 방향이 있습니다. 이는 스코프 제네릭에 대해 엄청난 토끼굴을 여는 것으로, 초기 구현에는 절대 포함시키지 말아야 합니다(파급효과가 너무 크고 구현 난이도도 높습니다). 하지만 스코프 제네릭을 이해한 사람에게는 배우고 이해하기도 매우 쉬우며, 안전한 Rust로 작성 가능한 프로그램의 폭을 크게 넓혀 줄 수 있습니다. 다음 가상의 코드를 보세요.
rust// 상수 제네릭 정의: 이 스코프에서 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();
위에서 정의한 스코프 제네릭으로는 작동하지 않습니다. 배열 크기는 상수 제네릭이지 스코프 제네릭이 아니므로(실제 상수여야 하죠). 그래도 사운드성 관점에서는 문제 없어 보이고, 코드 효율도 좋아질 수 있습니다(슬라이스 참조는 배열 참조보다 두 배 크기이므로, 같은 스코프 상수를 크기로 쓰는 가변 크기 배열이 둘 이상이면 슬라이스 두 개보다 메모리를 절약합니다). 또한 “Rust에서 alloca를 어떻게 하나” 같은 문제의 간단한 해법이 될 수 있고(별도의 “unsized locals” 기능 없이도 됨), C 코드를 Rust로 옮기기도 쉬워집니다(C에서는 위와 같은 가변 길이 배열 선언이 합법입니다. 다만 컴파일 타임 검사가 적어 구현은 더 쉽죠). 구현은 매우 어렵겠지만요(지금까지는 컴파일 타임 상수였던 것들이 런타임에 바뀔 수 있게 되므로).
Rust에는 스코프 제네릭이 필요합니다. 이는 매우 자주 마주치는 문제를 해결합니다. 제가 작성하는 거의 모든 Rust 프로그램에서 문제로 등장합니다. 현재에는 만족스러운 해법이 없습니다. 스코프 제네릭은 언어의 나머지와 일관된 방식으로 이 문제를 해결합니다(라이프타임과 상수 제네릭이 스코프 제네릭의 특수한 경우). 또한 “생성성 사용”이 풀려는 문제를 같은 수준으로 풀어 주지만, 훨씬 단순하고 인체공학적인 방법으로 풀어 줍니다. 이 모든 장점이 비교적 낮은 비용으로 가능합니다. 이 정도 범위와 파워를 가진 언어 기능 치고는 구현이 꽤 쉬울 것입니다(Rust가 이미 구현한 라이프타임과 매우 비슷하니까요).