Raph Levien은 Rust 기반의 매우 독창적인 SIMD 설계를 사용하여 세계에서 가장 빠른 글자 렌더링 엔진을 초고효율로 구현한 것으로 유명합니다. 그의 작업은 제가 2023년 Bestechnics에서 SIMD GPU를 설계할 때 큰 영감을 주었습니다. 이후 Ralf Levien은 이 글자 렌더링 엔진의 GPU 버전도 개발했습니다.

그래서 우연히 그의 최신 강연인 “Rust가 승리한 방법: 고성능, 안정적인 소프트웨어를 향한 여정“을 보게 되었고, 한 시간을 투자해서 강연을 보고 배울 만한 통찰력을 얻어보기로 했습니다.

러스트 시크릿 소스: 더 자세한 종류

문제 설명 Link to heading

가장 긴장되는 부분(일명 익사 구역)은 55분경에 시작되며, Rust 비밀 소스: 더 정확한 유형이라는 제목의 슬라이드가 나옵니다.

56분 50초에 Raph는 “여기서 아핀 타입이 중요한 역할을 합니다. (복제 가능성을) ‘이 객체의 인스턴스가 하나만 존재한다’로 모델링하고 싶을 때, 이 타입을 사용하면 함수 시그니처에서 소유권을 어떻게 처리할지 세심하게 지정할 수 있습니다. 마지막 예제는 생략하겠지만, 재밌죠…“라고 말합니다.

저는 Rust 전문가라고는 절대 자처하지 않으며, Go를 약간 더 선호한다는 것도 인정합니다. 그래서 질문은, 아핀 타입이 실제로 무엇을 의미하는지 이해할 수 있을까요? 그리고 방대한 지식에 파묻히지 않고도 쉬운 말로 설명해 주실 수 있을까요?

유튜브 댓글에서 지적된 또 다른 질문은, 마지막 함수의 반환 타입이 Cow<&'a [T]>가 아니라 Cow<'a, [T]>여야 한다는 것입니다. 솔직히 말해서, 이건 양자 알고리즘보다 더 복잡해 보입니다. 하지만 어쩌면 꼭 그럴 필요는 없을지도 모르죠!

그럼 이러한 ‘더욱 정확한 유형의 비법’ 사례들을 좀 더 자세히 살펴보겠습니다.

  • fn α( l: &[T] ) → Vec<T> : 항상 할당하며 입력에는 영향을 미치지 않습니다.

  • fn β( l: Vec<T> ) → 벡터<T> : 입력을 소비하고 할당을 재사용합니다.

  • fn δ( l: &mut Vec<T> ): 메모리 할당 없이 입력 벡터만 변경합니다.

  • fn ω( l: &'a [T]) → Cow<'a,[T]>: 메모리 할당이 발생할 수 있습니다. 양쪽 끝에만 중복이 있는 경우 메모리 할당을 피하십시오.

첫 번째 과제: 제네릭 의약품 Link to heading

제네릭 템플릿 T에서 가장 먼저 확인해야 할 것은 무엇일까요? 제대로 작동하는지, 해당 함수들이 컴파일될 수 있는지 여부입니다. 가장 간단한 버전의 함수로 한번 시도해 보겠습니다.

// fails with: error cannot find type T in this scope
fn α( l: &[T] ) -> Vec<T> { vec![] }

이것은 분명히 작동하지 않으며, 이 범위에서 T 형식을 찾을 수 없습니다라는 오류가 발생합니다. C++에 익숙하다면 T 형식은 함수 이름으로 선언해야 한다는 것을 알 것입니다. 따라서 α와 같이 선언해야 합니다. α 대신 ``를 사용합니다.

// all right with the T declaration
fn α<T>( l: &[T] ) -> Vec<T> { vec![] }

이렇게 하면 잘 작동합니다! 하지만 중복을 제거하지 않고 vec로만 변환하는 기본 구현을 추가하면 컴파일러가 ‘트레잇 제약 조건 T: Clone을 만족할 수 없습니다’라는 오류를 발생시킵니다.

// fails with: the trait bound `T: Clone` is not satisfied
fn α<T>( l: &[T] ) -> Vec<T> { l.to_vec() }

다행히 Rust는 제네릭과 트레이트에 대한 문서가 매우 잘 되어 있어서(https://doc.rust-lang.org/book/ch10-02-traits.html), 10분 정도 읽어보니 α를 변경해야 한다는 것이 명확해졌습니다. 에서 α로<T: Clone> `는 T형의 특성 경계를 표현하는 수단입니다.

// all right with the trait bound
fn α<T: Clone>( l: &[T] ) -> Vec<T> { l.to_vec() }

다음 단계는 중복을 제거하는 솔루션을 구현하는 것입니다. 이를 위해 HashSet을 사용하는 방법이 있습니다. 먼저 배열을 이터레이터로 변환합니다(let iterator: std::slice::Iter). = l.iter();)를 사용하여 반복자를 생성하고, 이 반복자로부터 해시셋을 생성한 다음, 마지막으로 해시셋을 벡터로 변환합니다. set.into_iter().collect().

//Fails with: the trait bound `T: Eq` is not satisfied
use std::collections::HashSet;
fn α<T: Clone>( l: &[T] )  -> Vec<T> {
    let array_iterator: std::slice::Iter<T> = l.iter();
    let hash_set: HashSet<&T> = HashSet::from_iter(array_iterator);
    hash_set.into_iter().collect()
}

당연히 실패합니다! Rust의 trait 문서를 10분만 읽어봤다면 HashSet이 타입 T의 요소를 “비교"할 수 있는 기능을 필요로 한다는 것을 알았을 테니 당연한 결과입니다. Eq 트레이트가 바로 그 역할을 하므로, α 트레이트의 경계에 Eq 트레이트를 추가해 보겠습니다.<T: Clone> 에서 α로<T: Clone + Eq> `):

//Fails with: the trait bound `T: Hash` is not satisfied
use std::collections::HashSet;
fn α<T: Clone + Eq>( l: &[T] )  -> Vec<T> {
    let array_iterator: std::slice::Iter<T> = l.iter();
    let hash_set: HashSet<&T> = HashSet::from_iter(array_iterator);
    hash_set.into_iter().collect()
}

여전히 오류가 발생하는데, 그 이유는 타입 T가 해시 값으로 변환될 수 있어야 한다는 점을 언급하지 않았기 때문입니다. Hash 트레이트가 바로 이 역할을 합니다. 따라서 α에서 변경해 보겠습니다.<T: Clone + Eq> 에서 α로<T: Clone + Eq + Hash> `

//Fails with: a value of type `Vec<T>` cannot be built from an iterator over elements of type `&T`
use std::collections::HashSet;
fn α<T: Clone + Eq + std::hash::Hash>( l: &[T] )  -> Vec<T> {
    let array_iterator: std::slice::Iter<T> = l.iter();
    let hash_set: HashSet<&T> = HashSet::from_iter(array_iterator);
    hash_set.into_iter().collect()
}

여기서 복제 가능성의 흥미로운 부분이 시작됩니다. 해시 세트는 처음에 리스트에 있던 요소들을 가리키는 포인터를 포함하는 구조체이며, 이 요소들은 값으로 전달되었습니다(정확히 말하면, 배열은 포인터로 전달되었고, 배열은 값으로 참조되는 요소들을 포함하고 있었습니다). 따라서 해시 세트에게 해당 요소들의 복사본을 만들도록 지시해야 하는데, 이는 마지막 collect 앞에 cloned()를 추가함으로써 수행할 수 있습니다(collect는 이터레이터를 벡터 컬렉션으로 변환하는 데 사용됩니다).

// all right!
use std::collections::HashSet;
fn α<T: Clone + Eq + std::hash::Hash>( l: &[T] )  -> Vec<T> {
    let array_iterator: std::slice::Iter<T> = l.iter();
    let hash_set: HashSet<&T> = HashSet::from_iter(array_iterator);
    hash_set.into_iter().cloned().collect()
}

자, 이렇게 해서 첫 번째 함수 예제를 살펴볼 준비가 되었습니다.

fn main() {
   println!("{:?}", α(&["A","A","B","D"]));
   println!("{:?}", α(&[1,1,2,4]));
}

솔직히 말해서, 이건 복잡하기도 하고 복잡하지 않기도 합니다. 복잡한 이유는 컴파일러가 엄격한 규칙을 적용하기 때문입니다. 이러한 규칙들은 암묵적으로 도출되는 것이 아니라 명시적으로 선언되어야 하므로 학습 곡선이 가파릅니다. 복잡하지 않은 이유는 문서를 읽는 데 “10분” 정도 투자하고 나면 그 이후에는 훨씬 쉬워져야 하기 때문입니다. 어쩌면 문제는 문서를 읽는 데 10분이 걸리는 것이 아니라, 그 내용을 이해하는 데 10시간이 걸리는 것일지도 모릅니다. 마치 데자뷔를 느끼는 것 같습니다…

두 번째 과제: 아핀 유형 Link to heading

복제 가능성에 대해 한 가지 질문이 더 있습니다. 전달된 배열의 요소에 대한 참조만 포함하는 벡터를 반환할 수 있을까요? 언뜻 보기에는 Vec만 변경하면 될 것 같습니다. Vec<&T>로 변환하고 아핀 타입 제약 조건을 제대로 처리하려면 어떻게 해야 할까요? 한번 시도해 보겠습니다.

// All right - works fine
use std::collections::HashSet;
fn α<T: Clone + Eq + std::hash::Hash>( l: &[T] )  -> Vec<&T> {
    let array_iterator: std::slice::Iter<T> = l.iter();
    let hash_set: HashSet<&T> = HashSet::from_iter(array_iterator);
    hash_set.into_iter().collect()
}

음, 잘 되네요! 수명에 대한 불만은 없으신가요? 우리가 요소를 빌려 쓰는 경우라면 당연히 수명을 확인해야 하는 것 아닌가요? 혹시 main 함수가 너무 단순한 건가요? 그럼 좀 더 복잡한 함수를 만들어 보겠습니다.

// fails with expected named lifetime parameter
fn limited_lifetime() -> vec<&str> {
    let l = ["A","A","B","D"];
    α(&l)
}

fn main() {
    let v = limited_lifetime();
    println!("{:?}",v);
}

이번에는 실패합니다. 컴파일러가 라이프타임 매개변수가 누락되었다고 오류를 표시하므로, Rust 문서에서 라이프타임을 사용한 참조 유효성 검사 부분을 10분 더 읽어보도록 하겠습니다. 얼핏 보면 ‘a ?를 추가하기만 하면 되는 것처럼 보입니다.

//  fais with: mismatched types; expected `&[str]`, found `&[&str; 4]`
fn limited_lifetime<'a>() -> Vec<&'a str> { ... }

하지만 그건 안 되네요. “변경 불가능한 문자열"을 동적인 “문자열"로 변환해 보겠습니다.

// fails with: cannot return value referencing local variable `l`
fn limited_lifetime<'a>() -> Vec<&'a String> {
    let l = ["A".into(),"A".into(),"B".into(),"D".into()];
    α(&l)
}

네, 이것은 우리가 예상했던 오류입니다. borrow checker가 제대로 작동하고 있네요. 그런데, 그렇다면 로컬로 할당된 변수들의 소유권을 힙으로 옮기도록 컴파일러에게 어떻게 알려줄 수 있을까요? 이를 위해서는 배열 l을 힙에 할당해야 한다는 것을 컴파일러에게 알려줘야 합니다. 그리고 이를 위해서는 배열 대신 vec을 사용해야 합니다.

// this works
fn β<T: Clone + Eq + std::hash::Hash>( l: Vec<T> )  -> Vec<T> {
    let hash_set: HashSet<_> = l.into_iter().collect(); 
    hash_set.into_iter().collect()
}

fn limited_lifetime<'a>() -> Vec<&'a str> {
    let l2 = vec!["A","B","B","D"];
    β(l2)
}

fn main() {
    println!("{:?}",limited_lifetime());
}

이 버전은 실제로 작동합니다. 왜냐하면 새로운 β 함수는 요소들로 이루어진 벡터를 입력받아 포인터가 아닌 동일한 요소를 반환하기 때문입니다. 즉, 요소들이 복제되는 것입니다. 하지만 만약 β 함수가 입력된 요소들을 가리키는 포인터를 반환하도록 하려면 어떻게 해야 할까요?

세 번째 과제: 쓰기 시 복제 Link to heading

지난 오메가 함수에 나왔던 ‘소’를 기억하시나요?

fn ω( l: &’a [T]) → Cow<&’a [T]>

Cow는 실제로 열거형(enum)입니다. 불변(빌려온) 참조 또는 동일한 객체의 가변 복제본을 저장할 수 있습니다. 예를 들면 다음과 같습니다.

use std::borrow::Cow;
fn trim_input(input: &str) -> Cow<str> {
    if input.ends_with(' ') {
        Cow::Owned(input.trim_end().to_string())
    }
    else  Cow::Borrowed(input)
}

그렇다면 Cow<'a [T]>라고 써야 할까요, 아니면 Cow<&'a [T]>라고 써야 할까요? 언뜻 보면 입력이 [T] 타입의 객체를 가리키는 포인터 &이고, 수명은 'a이므로 출력 또한 같은 타입의 객체를 가리키는 포인터입니다. 차이점은 Cow<>가 포인터를 의미한다는 것입니다. 따라서 정답은 Cow<'a, [T]>입니다.

fn ω<'a, T>( l: &'a [T]) ->  Cow<'a, [T]> where [T]: ToOwned { Cow::Borrowed(l) }

여기서 흥미로운 점은 Rust에서 배열의 길이는 컴파일 시점에 고정된다는 것입니다. 그렇다면 실행 시점에 동적으로 크기를 변경하는 배열을 어떻게 할당할 수 있을까요? 스택오버플로우 댓글에 따르면, 이는 불가능한 것으로 보입니다. 혹시 Ralf는 vec을 사용하려고 했던 것일까요?

fn ωx<'a, T: Clone + Eq + std::hash::Hash>( l: &'a Vec<T>) -> Cow<'a, Vec<T>> {
    let hash_set: HashSet<_> = l.into_iter().collect(); 
    if hash_set.len()==l.len() {
        return Cow::Borrowed(l);
    }
    let own_copy = hash_set.into_iter().cloned().collect();
    return Cow::Owned(own_copy);    
}

여담 Link to heading

솔직히 말해서, 저는 “fn ω<‘a, T>( l: &‘a [T]) -> Cow<‘a, [T]> where [T]: ToOwned { Cow::Borrowed(l) }“라는 구문에 완전히 매료되어 버렸습니다. 이 구문은 단순히 “동일한 것의 복제본"을 의미해야 합니다.

&'a T를 Ptr<'a,T>로 다시 쓸 수 있다고 생각하면, 함수를 다음과 같이 다시 쓸 수도 있습니다.

fn ω<'a, T>( l: Ptr<'a, [T]>) ->  Cow<'a, [T]> where [T]: ToOwned { Cow::Borrowed(l) }

T가 배열이라는 사실을 생략하면 위 코드는 다음과 같이 다시 작성할 수 있습니다.

fn ω<'a, T: ToOwned>( l: Ptr<'a, T>) ->  Cow<'a, T> { Cow::Borrowed(l) }

그렇다면 구문 기반 표현식 대신 매크로 기반 표현식으로 타입을 다시 작성하는 것은 어떨까요?

fn ω<'a, T>( l: Ptr<'a, T> ) ->  !Cow(l) { Cow::Borrowed(l) }

!Cow(l) 매크로는 재작성을 허용함으로써 타입 T가 ToOwned 트레이트에 바인딩되어야 함을 암시할 수 있습니다. 이러한 관점에서 a와 T는 Ptr 트레이트를 사용할 때 명시적으로 지정할 필요가 없는 제네릭 타입이라고 예상할 수 있습니다.

fn ω( l: Ptr ) ->  !Cow(l) { Cow::Borrowed(l) }

그렇다면 컴파일 타임 타입과 런타임 구현을 아무런 구분 없이 명시하는 것이 혼란스러운 이유는 무엇일까요? 하지만 어쩌면 그것이 바로 Rust의 본질일지도 모르겠습니다.

결론 Link to heading

랄프의 “비밀 레시피"는 한 시간짜리 발표에서 3분 정도로는 부족하고, 설명하려면 슬라이드 자료 하나만으로도 한 시간은 족히 걸릴 것 같습니다. 게다가 비판적인 시각으로 바라보는 것도 중요할 것 같습니다. 모든 게 다 재밌기만 한 건 아닐 수도 있잖아요? 과도한 설계 때문에 개발자들이 흥미를 잃는 부분도 있을지도 모릅니다. 어쩌면 그게 바로 “재미"라는 말의 의미일지도 모르죠. 어쨌든, 앞으로 몇 주 동안 한 시간짜리 발표 자료를 몇 장 만들어 보겠습니다.

양자 컴퓨팅 문제로 돌아가서, 양자 컴퓨팅 시스템을 작동시키기 위해 우리도 비슷한 수준의 복잡성을 가져야 하는지에 대한 질문이 제기됩니다. 어쩌면 이것이 오늘날 우리가 직면한 과제일지도 모릅니다. 겉보기에는 복잡해 보이지만, 양자 알고리즘은 이 야만적인 Rust 구문에 비하면 상당히 간단합니다. 양자 컴퓨팅의 “유용성” 문제를 해결하기 위해서는 한 발 앞서 생각해야 할지도 모릅니다. 그리고 이러한 추가적인 복잡성이 없다면, 우리는 여전히 기본적인 연산만 수행할 수 있을 뿐일까요?

이 모든 것이 우리가 조작하려는 객체의 의미론에 관한 것이라고 가정한다면, 큐비트보다 상위에 논리적 추상화 수준이 존재한다는 사실을 깨달아야 할지도 모릅니다. 업계가 여전히 큐비트의 작동 방식을 구현하는 데 어려움을 겪고 있기 때문에 이 추상화 수준은 아직 명확하게 드러나지 않고 있습니다.