Rust의 타입 시스템에 ‘뷰 타입’과 ‘추상 필드’를 도입해 필드 접근 범위를 타입 서명에 명시하고, 거짓 프로시저 간 대여 충돌을 방지하는 방법을 탐구한다. 또한 서브타이핑, 추론, 리팩터링 친화성, 트레이트와의 상호작용, 유령 필드 등 확장 가능성까지 논의한다.
몇 해 전 나는 (거짓) 프로시저 간 대여 충돌 문제를 다루기 위해 Rust 타입 시스템의 확장으로서 ‘뷰 타입’(view types)을 제안했다.[1] 기본 아이디어는 “{f1, f2} Type”과 같은 ‘뷰 타입’을 도입하는 것으로, 이는 “Type의 인스턴스인데 f1 또는 f2 필드만 접근할 수 있다”는 뜻이다. 주요 목적은 함수 시그니처에 접근 가능한 필드 집합을 명시하는 것이다. 예컨대 “& {f1, f2} self” 또는 “&mut {f1, f2} self”처럼 쓸 수 있다. 최근 이 아이디어를 다시 생각해 보면서 실제로 어떻게 작동할 수 있을지 조금 더 깊이 파고들고, 특히 비공개 필드의 이름을 노출하지 않고도 타입 내부의 ‘플레이스’를 다루는 방법 같은 흔한 질문에 답해보고 싶었다.
Data 타입을 계속 활용할 예시로 삼자. Data는 각 실험의 이름과 f32 값 집합을 모아 둔다. 실험 데이터 외에 몇 개의 측정이 성공했는지를 나타내는 카운터 successful도 가진다.
struct Data {
experiments: HashMap<String, Vec<f32>>,
successful: u32,
}
실험 목록을 순회하고 각 실험의 데이터를 읽기 위한 보조 함수들이 있다. 이들은 모두 self로부터 빌린 데이터를 반환한다. 현재 Rust에서는 보통 라이프타임 생략 규칙을 활용하는데, 반환 타입의 &는 자동으로 &self 인자와 연결된다:
impl Data {
pub fn experiment_names(
&self,
) -> impl Iterator<Item = &String> {
self.experiments.keys()
}
pub fn for_experiment(
&self,
experiment: &str,
) -> &[f32] {
experiments.get(experiment).unwrap_or(&[])
}
}
이제 Data가 성공한 실험 수 카운터를 읽고 수정하는 메서드를 가진다고 하자:
impl Data {
pub fn successful(&self) -> u32 {
self.successful
}
pub fn add_successful(&mut self) {
self.successful += 1;
}
}
지금까지의 Data 타입은 꽤 그럴듯하지만, 실제로는 쓰기 불편할 수 있다. 예컨대 실험들을 순회하며 데이터를 분석하고 그 결과에 따라 successful 카운터를 조정하고 싶다고 하자. 다음처럼 작성할 수 있을 것이다:
fn count_successful_experiments(data: &mut Data) {
for n in data.experiment_names() {
if is_successful(data.for_experiment(n)) {
data.add_successful(); // 오류: data가 여기서 빌려짐
}
}
}
숙련된 러스티시언이라면 여기서 고개를 저을 것이다. 실제로 위 코드는 컴파일되지 않는다. 뭐가 문제일까? 문제는 experiment_names가 self로부터 빌린 데이터를 반환하고, 그 참조가 루프 전체 동안 유지된다는 점이다. add_successful을 호출하려면 &mut Data 인자가 필요한데, 이것이 충돌을 일으킨다.
컴파일러가 제기하는 걱정은 타당하다. 위험은 experiment_names가 아직 그 맵을 순회하는 동안 add_successful이 experiments 맵을 변경할 수 있다는 점이다. 작성자인 우리는 그럴 가능성이 낮다는 걸 알지만 — 솔직히 지금은 낮더라도, Data가 발전하는 과정에서 add_successful 안에 experiments 맵을 변경하는 로직이 누군가에 의해 추가될 가능성이 전혀 없진 않다. 이런 미묘한 상호의존성이 “한 줄뿐인데요!” 같은 무심한 PR이 큰 보안 사고로 이어지게 만든다. 그건 그렇고, 여전히 이 코드를 쓸 수 없다는 건 매우 성가시다.
여기서 올바른 해결은 타입 시스템에서 어떤 필드들이 접근될 수 있는지 표현할 수 있게 하는 것이다. 그러면 오늘 이 코드가 컴파일되도록 할 수 있을 뿐 아니라, 미래의 PR이 버그를 도입하는 것도 막을 수 있다. 다만 현재의 Rust 시스템에서는 타입이 필드가 아니라 실행 시간의 구간(‘라이프타임’)만을 표현하므로 이를 하기 어렵다.
하지만 뷰 타입이 있으면 &self를 &{experiments} self로 바꿀 수 있다. &self가 사실상 self: &Data의 축약인 것처럼, 이는 실제로 self: & {experiments} Data의 축약이다.
impl Data {
pub fn experiment_names(
& {experiments} self,
) -> impl Iterator<Item = &String> {
self.experiments.keys()
}
pub fn for_experiment(
& {experiments} self,
experiment: &str,
) -> &[f32] {
self.experiments.get(experiment).unwrap_or(&[])
}
}
add_successful 메서드도 필요한 필드를 표시하도록 수정한다:
impl Data {
pub fn add_successful(
self: &mut {successful} Self,
) {
self.successful += 1;
}
}
이 글의 목적은 뷰 타입이 어떻게 작동할 수 있을지 좀 더 자세히 스케치하는 것이다. 기본 아이디어는 Rust의 타입 문법에 새 타입을 확장하는 것이다…
T = &’a mut? T
| [T]
| Struct<...>
| …
| {field-list} T // <— 뷰 타입
또한 플레이스에 대한 뷰를 정의하는 어떤 식의 표현도 필요하다. 이는 플레이스 표현식일 것이다. 우선은 E = {f1, f2} E라고 쓰겠는데, 물론 이는 Rust 블록과 모호하다는 문제가 있다. 예를 들어 다음처럼 쓸 수 있다…
let mut x: (String, String) = (String::new(), String::new());
let p: &{0} (String, String) = & {0} x;
let q: &mut {1} (String, String) = &mut {1} x;
…이렇게 하면 튜플 전체에 대한 참조이되 필드 0만 접근 가능한 참조 p와, 필드 1만 접근 가능한 가변 참조 q를 얻는다. &{0}x는 접근 범위가 제한된 ‘튜플 전체’에 대한 참조를 만들고, &x.0은 ‘해당 필드 자체’에 대한 참조를 만든다는 점을 주의하라. 두 가지는 각각의 쓸모가 있다.
예시의 이 함수를 보자:
impl Data {
pub fn add_successful(
self: &mut {successful} Self,
) {
self.successful += 1;
}
}
self.successful += 1 문장을 어떻게 타입 검사할까? 현재(뷰 타입 없이)는 self.successful 같은 표현을 타입 검사할 때 self의 타입, 이를테면 &mut Data를 얻는 것에서 시작한다. 그 다음 ‘자동 역참조(auto-deref)’를 수행해 구조체 타입을 찾는다. 그러면 Data에 이르게 되고, 이어서 Data가 successful 필드를 정의하는지 확인한다.
뷰 타입을 통합하려면, 접근 중인 데이터의 타입과 허용된 필드의 집합을 모두 추적해야 한다. 처음에 변수 self는 &mut {successful} Data 타입이고 허용-집합은 *이다. 역참조를 하면 {successful} Data로 이동하고(허용-집합은 * 유지), 뷰 타입을 통과하면 허용-집합이 변경된다. 즉 *에서 {successful}로 바뀐다(합법이려면 뷰에 포함된 모든 필드가 허용-집합에 속해야 한다). 이제 타입은 Data다. 그런 다음 successful 필드가 Data의 멤버이자 허용-집합의 멤버임을 확인할 수 있으므로 이 코드는 성공적으로 통과한다.
반대로 선언된 뷰에 포함되지 않은 필드를 접근하도록 함수를 수정한다면, 예컨대
impl Data {
pub fn add_successful(
self: &mut {successful} Self,
) {
assert!(!self.experiments.is_empty()); // <— 여기를 추가했다고 하자
self.successful += 1;
}
}
이제 self.experiments 타입 검사는 실패한다. experiments 필드가 허용-집합의 멤버가 아니기 때문이다.
더 흥미로운 문제는 add_successful() 호출을 타입 검사할 때 발생한다. 다음 코드가 있었다:
fn count_successful_experiments(data: &mut Data) {
for n in data.experiment_names() {
if is_successful(data.for_experiment(n)) {
data.add_successful(); // 전에는 오류였지만, 이제는 OK.
}
}
}
data.experiment_names() 호출을 생각해보자. 오늘날 컴파일러에서 메서드 탐색은 우선 &mut Data 타입의 data를 보고, 한 번 자동 역참조하여 Data를 얻은 다음, 다시 자동 참조(auto-ref)하여 &Data를 만든다. 결과적으로 이 메서드 호출은 Data::experiment_names(&*data) 같은 호출로 디슈거링된다.
뷰 타입에서는 자동 참조를 도입할 때 뷰 연산도 함께 도입한다. 그래서 Data::experiment_names(& {?X} *data) 같은 형태가 된다. 여기서 {?X}는 허용된 필드 집합을 추론해야 함을 나타낸다. 플레이스-집합 변수 ?X는 필드 집합 또는 * (모든 필드)로 추론될 수 있다.
이 플레이스-집합 변수를 추론에 통합한다. 즉, {?A} Ta <: {?B} Tb는 ?B가 ?A의 부분집합이고 Ta <: Tb일 때 성립한다(예: [x, y] Foo <: [x] Foo). 또한 서브타입에서 뷰 타입을 제거하는 것도 허용한다. 예컨대 {*} Ta <: Tb는 Ta <: Tb일 때 성립한다.
플레이스-집합 변수는 내부 추론 디테일로만 등장하므로, 사용자가 플레이스-집합에 대해 제네릭한 함수를 작성할 수는 없고, 얻을 수 있는 제약도 부분집합(P1 <= P2)과 포함(f in P1)뿐이다. 이는 HIR 타입 검사 추론에 비교적 쉽게 통합될 수 있을 것이라 생각한다. 일반화 시에는 라이프타임과 마찬가지로 각 구체적 뷰 집합을 변수로 치환한다. MIR을 구성할 때는 항상 뷰에 포함할 정확한 필드 집합을 알 수 있다. 필드 집합이 *인 경우라면 MIR에서 뷰를 생략할 수도 있다.
뷰 타입은 접근할 타입 집합을 더 명시적으로 드러내어 이러한 충돌을 해결하는 데 도움을 주지만, 새로운 문제가 생긴다 — 비공개 필드 이름이 인터페이스의 일부가 되어 버리는 걸까? 그건 분명 바람직하지 않다.
해결책은 ‘추상’[2] 필드 개념을 도입하는 것이다. 추상 필드는 실제로 존재하지 않지만, ‘있는 것처럼’ 말할 수 있는 일종의 가짜 필드다. 데이터에 상징적 이름을 붙일 수 있게 해준다.
추상 필드는 필드 집합의 별칭(alias)으로 정의한다. 예: pub abstract field_name = (list-of-fields). 별칭은 필드 집합에 대해 공개(symbolic) 이름을 정의한다.
따라서 Data에 대해 실험 집합과 성공한 실험 수를 각각 나타내는 두 개의 별칭을 정의할 수 있다. 실제 필드 이름에 대한 별칭을 허용하는 것이 유용할 것 같다. 실전에서는 컴파일러가 언제나 어떤 집합을 사용할지 판별할 수 있다고 생각하고, 별칭이 있다면 동일한 이름의 실제 필드에 추상 필드를 연결하도록 요구하겠다.
struct Data {
pub abstract experiments = experiments,
experiments: HashMap<String, Vec<f32>>,
pub abstract successful = successful,
successful: u32,
}
이제 앞서 썼던 뷰 타입들(예: & {experiments} self 등)은 합법이지만, 실제 필드가 아니라 ‘추상’ 필드를 가리킨다.
추상 필드의 장점 중 하나는 리팩터링을 허용한다는 것이다. 예컨대 Data가 더 이상 Map<String, Vec<f32>>로 실험을 저장하지 않고, 모든 실험 데이터를 하나의 큰 벡터에 넣고 맵에는 인덱스 범위(예: Map<String, (usize, usize)>)만 저장하도록 바꾸고 싶다고 하자. 문제없이 할 수 있다:
struct Data {
pub abstract experiments = (experiment_names, experiment_data),
experiment_indices: Map<String, (usize, usize)>,
experiment_data: Vec<f32>,
// ...
}
여전히 &mut {experiments} self 같은 메서드를 선언하되, 이제 컴파일러는 추상 필드 experiments가 비공개 필드 집합으로 확장될 수 있음을 이해한다.
가능하다. 빈 필드 집합을 나타내기 위해 pub abstract foo;처럼 정의할 수 있어야 한다고 생각한다.
좋은 질문이다. 필수적인 상호작용은 없다. 뷰 타입을 그냥 하나의 타입으로 남겨둘 수 있다. 예컨대 구조체에 대한 뷰에 대해 Deref를 구현하는 식으로 흥미로운 일을 할 수 있다:
struct AugmentedData {
data: Vec<u32>,
summary: u32,
}
impl Deref for {data} AugmentedData {
type Target = [u32];
fn deref(&self) -> &[u32] {
// 여기서 self의 타입은 &{data} AugmentedData
&self.data
}
}
그렇다! 그리고 흥미로울 것이다. 트레이트의 인터페이스에 등장할 수 있는 추상 필드를 트레이트 멤버로 선언하는 것을 상상해 볼 수 있다:
trait Interface {
abstract data1;
abstract data2;
fn get_data1(&{data1} self) -> u32;
fn get_data2(&{data2} self) -> u32;
}
그리고 impl에서 그 필드들을 정의한다. 일부는 실제 필드에 매핑하고, 일부는 순수하게 추상으로 남길 수도 있다:
struct OneCounter {
counter: u32,
}
impl Interface for OneCounter {
abstract data1 = counter;
abstract data2;
fn get_data1(&{counter} self) -> u32 {
self.counter
}
fn get_data2(&{data2} self) -> u32 {
0 // 필드가 필요 없음
}
}
처음부터 원하진 않지만, {foo.bar} Baz 같은 것을 허용할 수 있다고 본다. 그러면 &foo.bar 같은 것이 주어졌을 때 &{bar} Baz 타입을 얻게 된다. 다만 그 이상으로 깊이 생각하진 않았다.
그렇다! 다음과 같은 일을 할 수 있어야 한다:
struct Strings {
a: String,
b: String,
c: String,
}
fn play_games(s: Strings) {
// 구조체 s를 이동하되 a와 c 필드만 이동
let t: {a, c} Strings = {a, c} s;
println!("{s.a}"); // 오류: s.a는 이동되었음
println!("{s.b}"); // OK
println!("{s.c}"); // 오류: s.c는 이동되었음
println!("{t.a}"); // OK
println!("{t.b}"); // 오류: b 필드 접근 권한 없음
println!("{t.c}"); // OK
}
뷰 타입 서브타이핑 규칙을 다음 두 가지로 설명했다:
원칙적으로는 Ta <: {*} Tb, 단 Ta <: Tb 같은 규칙을 둘 수도 있다 — 이 규칙은 슈퍼타입에 뷰 타입을 ‘도입’할 수 있게 한다. 그런 규칙이 필요해질 수도 있지만, 다음과 같은 코드를 컴파일 가능하게 만들어 버리기 때문에 원치 않았다(Strings 예시를 재사용):
fn play_games(s: Strings) {
let t: {a, c} Strings = s; // <— 그냥 = s, 즉 = {a, c} s가 아님
}
이는 다음이 성립하므로 컴파일되길 기대할 수도 있다:
{a, c} Strings <: {*} Strings <: Strings
하지만 개인적으로는 컴파일되지 않기를 바란다.
그렇다! 추상 필드는 두 가지 다른 방식으로도 유용하다고 생각한다(정의를 약간 확장해야 하긴 하지만). Rust가 정리 증명기(theorem prover)와 더 강하게 통합되는 게 중요하다고 믿는다. 폭넓게 쓰이진 않겠지만, 특정 핵심 라이브러리(std, zerocopy, 어쩌면 tokio 등)에서는 타입 안정성을 수학적으로 증명할 수 있으면 좋다. 그런데 수학적 증명 시스템은 종종 ‘유령 필드(ghost fields)’ 개념을 필요로 한다 — 즉 런타임에는 존재하지 않지만, 증명에서는 말할 수 있는 논리적 상태다. 유령 필드는 본질적으로 빈 필드 집합에 매핑되며 타입을 가진 추상 필드다. 예를 들어 두 개의 추상 필드(a, b)와, 그 합을 저장하는 실제 필드 하나를 가진 BeanCounter 구조체를 선언할 수 있다:
struct BeanCounter {
pub abstract a: u32,
pub abstract b: u32,
sum: u32, // <— 런타임에는 합만 저장
}
그리고 BeanCounter를 만들 때 그 필드들에 대한 값을 지정한다. 그 값은 추상 블록 같은 것으로 작성할 수 있는데, 이 블록 내부 코드는 실제로 실행되지는 않지만(그렇더라도 타입 검사는 가능해야 한다):
impl BeanCounter {
pub fn new(a: u32, b: u32) -> Self {
Self { a: abstract { a }, b: abstract { b }, sum: a + b }
}
}
추상 값을 제공하는 것은, 증명기가 전후 조건과 기타 계약을 검사하는 목적에 한해 ‘마치 코드가 존재하는 것처럼’ 행동할 수 있게 해 주므로 유용하다.
그렇다! a: PhantomData<T> 대신 abstract a: T처럼 할 수 있으리라 본다. 다만 그러려면 어떤 추상 초기화자가 필요하다. 그래서 아마도 abstract _: T처럼 익명 필드를 허용할 수 있는데, 이 경우 초기값을 제공할 필요는 없지만 계약에서 이름으로 참조할 수도 없다.
먼저 가장 단순한 형태의 추상 필드, 즉 실제 필드 집합에 대한 별칭부터 시작하겠다. 하지만 유령 필드나 PhantomData를 포괄하려면, 추상 필드에 타입을 선언할 수 있어야 한다(기본은 ()라고 할 수 있다). ()가 아닌 타입을 갖는 필드의 경우, 구조체 생성자에서 추상 값을 제공해야 한다. PhantomData를 편하게 다루려면, 타입을 요구하지 않는 익명 추상 필드를 추가할 수도 있다.
여기서는 구조체와 튜플에 부착된 뷰 타입만 보여 주었다. 개념적으로는 다른 곳에도 허용할 수 있다. 예: {0} &(String, String)은 &{0} (String, String)과 동등하도록. 지금 당장은 필요 없다고 생각하고 금지하겠지만, 언젠가 지원하는 것도 합리적일 수 있다.
뷰 타입에 대한 탐색은 여기까지다. 글을 쓰면서 내용이 바뀌었다 — 처음에는 플레이스 기반 대여(place-based borrow)도 포함하려 했지만, 결국 그게 꼭 필요하지 않다는 결론에 이르렀다. 또한 처음엔 뷰 타입이 구조체 타입의 특수한 경우라고 예상했고, 실제로 그렇게 하면 단순해질 수도 있지만, 최종적으로는 이들이 그 자체로 유용한 타입 생성자(type constructor)라고 결론 내렸다. 특히 트레이트에 통합하려면 제네릭 등에도 적용될 수 있어야 한다.
다음 단계는 아직 잘 모르겠다. 이 아이디어를 계속 생각해 보려 한다. Rust의 이 격차는 분명 해결해야 하고, 현재로서는 뷰 타입이 가장 자연스러워 보인다. 진화 중인 a-mir-formality에서 프로토타이핑해 보며 다른 놀라운 점이 있는지 살펴보는 게 흥미로울 것 같다.