C, C++, Rust가 낮은 수준에서 메모리를 관리하는 방식을 살펴보는 연재의 3부. 이번 편에서는 추상 타입이 구체적인 비트 패턴으로 표현되는 방식과, 다형성(여러 타입에 대해 동작하는 코드)이 구현되는 방식을 다룬다.
URL: https://lukefleed.xyz/posts/who-owns-the-memory-pt3/
이는 C, C++, Rust가 낮은 수준에서 메모리를 어떻게 관리하는지 탐구하는 연재의 세 번째 글이다. 1부에서는 하드웨어 수준에서 메모리가 어떻게 조직되는지 살펴보았다. 2부에서는 소유권과 수명을 다루며, 세 언어가 “누가 언제 메모리를 해제할 책임이 있으며, 그 메모리에 대한 접근이 언제 유효한가?”라는 질문에 어떻게 답하는지 살펴봤다.
3부는 표현(representation) 으로 방향을 튼다. 추상 타입이 어떻게 구체적인 비트 패턴이 되는지, 그리고 여러 타입에 대해 동작하는 코드를 작성할 수 있게 하는 능력인 다형성(polymorphism) 이 어떻게 구현되는지를 다룬다.
1부에서 우리는 모든 타입이 크기(size) 와 정렬(alignment) 을 가지며, 컴파일러가 정렬 제약을 만족시키기 위해 패딩(padding) 을 삽입한다는 것을 확인했다. C에서 필드 순서가 좋지 않은 구조체는 패딩으로 8바이트를 낭비할 수 있는 반면, 잘 정렬된 구조체는 2바이트만 사용한다는 것도 보았다. 하지만 우리는 질문 하나를 비껴갔다. 누가 순서를 결정하는가?
C와 C++에서는 답이 단순하다. 우리가 결정한다. 컴파일러는 선언 순서대로 필드를 배치하고, 정렬 알고리즘이 지시하는 대로 패딩을 삽입한다. 이런 예측 가능성은 바이너리 호환성, 메모리 매핑 I/O, 그리고 바이트 오프셋이 외부 명세와 일치해야 하는 네트워크 프로토콜에 필수다. 동시에 이는 제약이기도 하다. 우리는 필드 순서에 대한 책임을 지며, 부주의한 선언은 자주 할당되는 구조체를 불필요하게 부풀릴 수 있다.
Rust는 다른 선택을 한다. 기본적으로 컴파일러는 필드를 재정렬할 권리 를 가진다.
C는 구조체 레이아웃을 위한 결정론적 알고리즘을 규정한다. 현재 오프셋을 0으로 시작한다. 선언 순서대로 각 필드에 대해: 오프셋이 해당 필드의 정렬 값의 배수가 될 때까지 패딩을 추가하고, 필드의 오프셋을 기록한 뒤, 필드의 크기만큼 오프셋을 전진시킨다. 마지막으로 구조체의 총 크기를 구조체 정렬 값(필드 정렬의 최대치)으로 올림한다.
cstruct Example { char a; // offset 0, size 1 // 7 bytes padding (align to 8 for double) double b; // offset 8, size 8 char c; // offset 16, size 1 // 7 bytes padding (struct size must be multiple of 8) }; // sizeof(struct Example) == 24
이 알고리즘은 기계적이다. 필드 타입과 순서가 주어지면 레이아웃은 완전히 결정된다. 이 성질이 C를 FFI의 링구아 프랑카로 만든다. 같은 알고리즘을 구현하는 어떤 언어든 C 코드와 데이터 구조를 공유할 수 있다.
C++은 표준 레이아웃 타입(standard-layout type)에 대해 이 레이아웃을 상속한다. 대부분의 비(非)윈도우 C++ 구현을 지배하는 Itanium ABI는 기본 클래스, 가상 함수, 가상 상속을 처리하기 위해 이 알고리즘을 확장한다. 여기서 다룰 목적상, 상속/가상 함수가 없고 접근 지정자 섞임으로 필드가 분할되지 않는 C++ 구조체는 C와 동일한 레이아웃을 따른다.
[[no_unique_address]] 속성(C++20)은 멤버가 자기 자신의 멤버를 전혀 가지지 않는 경우, 컴파일러가 해당 데이터 멤버를 다른 멤버와 겹쳐서(overlap) 배치할 수 있게 한다. 이는 멤버 객체에 대한 빈 베이스 최적화(empty base optimization)를 가능하게 한다.
cppstruct Empty {}; struct WithoutAttribute { Empty e; int x; }; // sizeof(WithoutAttribute) == 8 on typical platforms // (Empty requires 1 byte in C++, padded to 4 for int alignment) struct WithAttribute { [[no_unique_address]] Empty e; int x; }; // sizeof(WithAttribute) == 4
속성이 없으면 Empty는 1바이트를 차지한다(C++은 서로 다른 객체들이 서로 다른 주소를 가져야 한다고 강제하므로, 빈 클래스도 0이 아닌 크기를 가진다). 속성이 있으면 e는 패딩이나 x 자체와 저장소를 공유할 수 있어 구조체 크기가 sizeof(int)만큼으로 줄어든다.
Rust의 기본 표현인 repr(Rust)는 최소한의 보장만 제공한다. 명세는 다음만을 말한다.
필드 순서에 대한 보장은 없다. 컴파일러는 패딩을 최소화하기 위해 필드를 재정렬할 수 있으며, 동일한 소스를 서로 다른 방식으로 컴파일하면 다른 레이아웃이 나올 수 있다. 같은 제네릭 구조체라도 서로 다른 타입 인자 조합으로 인스턴스화하면 보통 서로 다른 필드 순서를 가진다.
다음을 보자.
ruststruct Foo<T, U> { count: u16, data1: T, data2: U, }
Foo<u32, u16>에서는 효율적인 레이아웃이 count와 data2(둘 다 2바이트)를 붙여 놓고 그 뒤에 data1(4바이트)을 두는 것이다.
rust// 가능한 Foo<u32, u16> 레이아웃: size 8, align 4 // count: offset 0, size 2 // data2: offset 2, size 2 // data1: offset 4, size 4
Foo<u16, u32>에서도 같은 재정렬로 같은 효율을 얻을 수 있다.
rust// 가능한 Foo<u16, u32> 레이아웃: size 8, align 4 // count: offset 0, size 2 // data1: offset 2, size 2 // data2: offset 4, size 4
만약 Rust가 선언 순서를 유지했다면 Foo<u32, u16>은 count 뒤에 패딩이 필요했을 것이다.
rust// 선언 순서 레이아웃 Foo<u32, u16>: size 12, align 4 // count: offset 0, size 2 // _pad: offset 2, size 2 // data1: offset 4, size 4 // data2: offset 8, size 2 // _pad: offset 10, size 2
이 재정렬은 어떤 어노테이션이나 프로그래머의 주의 없이도 인스턴스당 4바이트를 절약한다.
트레이드오프는 예측 불가능성 이다. 우리는 필드 오프셋을 가정할 수 없다. *const Foo<A, B>를 바이트 포인터로 캐스팅해 “알려진 오프셋”에서 읽어 필드를 꺼내는 방식은 불가능하다. repr(Rust) 구조체를 네트워크로 보내거나 파일에 그대로 쓰고, 다른 컴파일 결과(심지어 같은 컴파일이라도 플래그가 다르면)에서 동일하게 해석될 것이라고 기대할 수 없다.
C 호환 레이아웃이 필요할 때는 타입에 #[repr(C)]를 붙인다.
rust#[repr(C)] struct ThreeInts { first: i16, second: i8, third: i32, }
repr(C)에서는 Rust가 C 레이아웃 알고리즘을 적용한다. 필드는 선언 순서대로 나타난다. 패딩은 표준 규칙을 따른다. 결과 레이아웃은 동일한 필드 타입과 순서로 선언된 C 구조체와 호환된다.
cstruct ThreeInts { int16_t first; int8_t second; int32_t third; };
repr(C)는 FFI의 정확성을 위해 필요하다. 또한 필드 오프셋에 의존하는 unsafe 코드가 안정적인 레이아웃을 필요로 할 때, 혹은 알려진 바이너리 포맷으로 직렬화할 때도 유용하다. 트레이드오프는 선언 순서가 야기하는 패딩을 그대로 받아들이는 것이다.
enum의 경우 repr(C)는 C의 union + 태그(tag)와 호환되는 레이아웃을 만든다. 정확한 표현은 enum이 필드를 가지는지에 따라 달라진다.
rust#[repr(C)] enum MyEnum { A(u32), B(f32, u64), C { x: u32, y: u8 }, D, }
이는 각 변형이 플랫폼의 C int 크기(discriminant)로 시작하는 repr(C) 구조체들의 repr(C) union으로 배치된다.
rust// 동등한 repr(C) 레이아웃: #[repr(C)] union MyEnumRepr { a: MyVariantA, b: MyVariantB, c: MyVariantC, d: MyVariantD, } #[repr(C)] struct MyVariantA { tag: u64, value: u64 } #[repr(C)] struct MyVariantB { tag: u64, _pad: u64, value0: f64, _pad2: u64, value1: u64 } // ... 등등
필드가 없는 repr(C) enum의 판별자(discriminant) 크기는 C ABI의 enum 크기와 일치하며, 이는 구현 정의(implementation-defined)다. 대부분의 플랫폼에서는 int(4바이트)이지만, 어떤 ABI는 작은 enum에 대해 더 작은 타입을 쓰기도 한다.
판별자를 정밀하게 제어해야 할 때는 정수 타입을 지정할 수 있다.
rust#[repr(u8)] enum Opcode { Nop = 0, Load = 1, Store = 2, // ... 최대 255개 변형 }
이 enum은 정확히 1바이트를 차지하며, 판별자는 u8로 저장된다. 태그가 특정 필드 폭에 맞아야 하는 바이너리 프로토콜에서 필수다.
필드를 가진 enum에서도 repr(u8) 등은 판별자 크기를 설정하지만 변형 데이터는 여전히 C 스타일 레이아웃을 사용한다.
rust#[repr(u8)] enum Packet { Ping, Data([u8; 64]), Error(u32), } // size: 72 bytes (1 byte tag + 7 padding + 64 data) // 판별자는 1바이트로 보장됨
#[repr(C, u8)]처럼 repr(C)와 원시 표현을 결합하면, C 레이아웃과 특정 판별자 타입을 동시에 지정한다.
필드가 있는 enum에 명시적 repr를 붙이면 한 가지 결과가 있다. 니치 최적화를 억제(suppress) 한다. 이는 ZST를 논한 뒤에 더 명확해진다.
때로는 정렬과 무관하게 최소 크기가 필요하다. 네트워크 패킷 헤더, 바이너리 파일 포맷, 메모리 매핑된 하드웨어 레지스터는 종종 촘촘히 패킹된 데이터를 요구한다. repr(packed)는 필드 사이 패딩을 제거한다.
rust#[repr(packed)] struct PackedExample { a: u8, b: u32, c: u8, } // size: 6 bytes (1 + 4 + 1), padding 없음 // alignment: 1 byte
기본 레이아웃(1 + 3패딩 + 4 + 1 + 3패딩 = 12바이트)과 비교하면, packed 버전은 절반 크기다.
대신 필드가 비정렬(misaligned) 될 수 있다. x86-64에서는 비정렬 로드가 성능 패널티를 유발한다. 비정렬 접근을 지원하지 않는 ARM(일부 설정)이나 오래된 SPARC 같은 더 엄격한 아키텍처에서는 하드웨어 예외를 유발한다. 관대한 아키텍처에서도 비정렬 atomic은 대개 올바르지 않다.
Rust는 이 문제의 일부를 다음 규칙으로 해결한다. 비정렬 필드에 대한 참조(reference)를 만드는 것이 불법 이다.
rust#[repr(packed)] struct Packed { a: u8, b: u32, // offset 1일 수 있어 비정렬 } let p = Packed { a: 1, b: 2 }; let r: &u32 = &p.b; // ERROR: packed field에 대한 참조
컴파일러가 이를 거부하는 이유는 &u32는 4바이트 정렬 주소를 가리켜야 하는데 p.b는 그럴 수 없기 때문이다. 우회 방법은 먼저 값을 복사하는 것이다.
rustlet value = p.b; // 비정렬 바이트를 로컬로 복사 let r: &u32 = &value; // value는 스택에서 올바르게 정렬됨
또는 raw 포인터 연산과 명시적 비정렬 읽기를 쓴다.
rustlet ptr: *const u32 = std::ptr::addr_of!(p.b); let value = unsafe { ptr.read_unaligned() };
repr(packed(n))는 최대 필드 정렬을 n으로 제한하는 일반화다. 자연 정렬이 n보다 작은 필드는 정상적으로 배치되고, n보다 큰 필드는 정렬이 n인 것처럼 취급된다. 이는 일부만 패킹하여 공간과 접근 패턴을 절충하게 한다.
FFI에서는 repr(C, packed)을 결합해 C 호환 필드 순서를 유지하면서 최소 크기를 얻는다. 이는 많은 C 컴파일러의 #pragma pack(1)과 대응된다.
repr(packed)가 정렬을 줄이는 반면, repr(align(n))는 정렬을 늘린다. 이 속성은 타입의 정렬을 최소 n 바이트로 강제하며, n은 2의 거듭제곱이어야 한다.
왜 필요 이상으로 더 정렬하고 싶을까? 답은 캐시 아키텍처에 있다. x86-64에서 L1 캐시는 64바이트 캐시 라인으로 동작한다. 한 코어가 어떤 주소에 쓰기를 하면, 그 주소가 속한 캐시 라인 전체가 다른 모든 코어의 캐시에서 무효화된다. 이는 일관성을 유지하는 MESI(또는 MESIF, MOESI 등 변형) 프로토콜 때문이다.
서로 다른 스레드가 동시에 증가시키는 두 atomic 카운터를 생각해 보자.
rustuse std::sync::atomic::{AtomicU64, Ordering}; struct Counters { a: AtomicU64, // offset 0, 8 bytes b: AtomicU64, // offset 8, 8 bytes }
둘 다 하나의 64바이트 캐시 라인에 들어간다. 스레드 1이 a를 증가시키면 스레드 2의 캐시 라인이 무효화되고, 스레드 2가 b를 증가시키면 스레드 1의 캐시 라인이 무효화된다. 서로의 데이터를 읽지도 않는데 캐시를 끊임없이 날려버리는 현상이 거짓 공유(false sharing) 이며, 경합(workload contention)이 있는 경우 성능을 한 자릿수 배까지 떨어뜨릴 수 있다.
해결책은 각 카운터가 별도 캐시 라인을 쓰도록 보장하는 것이다.
rustuse std::sync::atomic::{AtomicU64, Ordering}; #[repr(align(64))] struct CacheAligned(AtomicU64); struct Counters { a: CacheAligned, // offset 0, 64바이트로 패딩 b: CacheAligned, // offset 64, 64바이트로 패딩 }
이제 sizeof::<Counters>()는 16이 아니라 128바이트가 되지만, a와 b는 캐시 라인을 공유할 수 없다. 각 스레드의 쓰기는 자기 캐시 라인에만 영향을 주고, 일관성 프로토콜이 라인을 코어 사이에서 튕기게(bounce) 만들지 않는다.
트레이드오프는 메모리 사용량이다. 8바이트 카운터를 64바이트로 패딩하면 8배 증가다. 동시 자료구조의 뜨거운(hot) 카운터 몇 개라면 무시할 만하지만, 수천 개 카운터 배열이라면 감당하기 어렵다. 선택은 접근 패턴에 달려 있다. 스레드가 서로 다른 카운터를 주로 접근하면 정렬이 도움이 되지만, 스레드가 같은 카운터를 자주 접근한다면 그것은 거짓 공유가 아니라 실제 경합이며, 정렬로 해결되지 않는다.
repr(align)은 다른 표현과 결합할 수 있다. #[repr(C, align(64))]는 C 호환 필드 순서와 캐시 라인 정렬을 제공한다. align 수정자는 구조체 전체에 적용되며, 개별 필드에 적용되지 않으므로, 필드들은 확대된 정렬 구조체 내부에서 자연 오프셋을 유지한다.
때로는 기존 타입과 완전히 동일한 레이아웃을 가지는 새 타입이 필요하다. newtype 패턴은 단일 필드를 감싼다.
ruststruct Meters(f64); struct Seconds(f64);
repr(Rust)에서는 이 타입들이 f64와 동일한 레이아웃이나 ABI를 가진다고 보장되지 않는다. Meters를 반환하는 함수가 f64를 반환하는 함수와 다른 레지스터를 사용할 수도 있다. 내부 데이터가 동일하더라도 말이다.
repr(transparent)는 레이아웃과 ABI의 동일성을 보장한다.
rust#[repr(transparent)] struct Meters(f64); #[repr(transparent)] struct Seconds(f64);
이제 Meters는 f64와 완전히 같은 크기/정렬/호출 ABI를 가진다. C가 double을 기대하는 자리에 Meters를 전달할 수 있다. Meters와 f64 사이를 (양방향으로) 트랜스뮤트해도 미정의 동작이 아니다.
repr(transparent)는 0이 아닌 크기를 가진 필드가 정확히 하나여야 하며, 정렬 1을 가진 0-크기 필드(예: PhantomData<T>)는 여러 개 있어도 된다.
예를 들어 다음이 가능해진다.
rustuse std::marker::PhantomData; #[repr(transparent)] struct Id<T>(u64, PhantomData<T>);
Id<User>와 Id<Post>는 컴파일 타임에는 다른 타입이지만 레이아웃은 u64와 동일하다. PhantomData는 타입 시스템(변성, drop 검사)에는 참여하지만 레이아웃에는 참여하지 않는다.
Rust는 크기가 0인 타입을 허용한다.
ruststruct Nothing; // 필드 없음 struct AlsoNothing { } // 빈 구조체 struct MoreNothing(()); // unit 타입 포함 struct AndNothing([u8; 0]); // 길이 0 배열
이들은 모두 크기 0, 정렬 1(최소)이다. 메모리를 차지하지 않는다. [Nothing; 1000000] 배열도 크기가 0이다.
ZST는 제네릭 문맥에서 유용해진다. HashMap<K, V>는 키와 값을 저장한다. 값이 필요 없고 키만 필요하다면? HashSet<K>로 코드를 복제할 수도 있지만, 다음처럼 정의할 수도 있다.
type HashSet<K> = HashMap<K, ()>;
()는 ZST이므로 HashMap<K, ()>는 값 데이터를 저장하지 않는다. 컴파일러는 값에 대한 로드/스토어/할당을 제거한다. 런타임 오버헤드 없이 맵 구현으로부터 셋 구현을 얻는다.
표준 라이브러리의 HashSet은 실제로 이렇게 구현된다.
rustpub struct HashSet<T, S> { map: HashMap<T, (), S>, }
unsafe 코드는 ZST를 다룰 때 주의해야 한다. T가 0-크기일 때 *const T의 포인터 산술은 no-op이다. ptr.add(1)이 ptr 자체를 그대로 반환한다. 이는 “포인터를 전진시키면 주소가 달라진다”는 가정을 깨뜨린다. 또한 대부분의 할당자는 0바이트 요청을 받지 않으므로, Box::new(ZST)는 할당자를 호출하는 대신 특별 처리를 한다.
미묘한 성질 하나: ZST에 대한 참조는 null이 아니어야 하고 올바르게 정렬되어야 한다(정렬 1이면 어떤 주소든 정렬은 만족). 하지만 역참조는 0바이트를 읽는 것으로 정의된다. 주소 0x1의 ZST 참조는 유효하지만, null(주소 0)의 참조는 실제 메모리 접근이 없더라도 미정의 동작이다.
0-크기 타입은 인스턴스화할 수 있다. ()나 struct Nothing; 값을 만들고 전달할 수 있다. 빈 타입(empty type) 은 더 나아가 아예 인스턴스화할 수 없다.
enum Void {}
변형이 없는 enum은 유효한 값이 없다. 생성할 변형이 없으므로 Void를 구성할 수 없다. 타입 수준에서는 존재하지만 값 수준에서는 결코 존재할 수 없다.
이는 불가능성에 대한 타입 수준 추론을 가능하게 한다. 실패할 수 있는 데이터 소스 트레이트를 생각해보자.
rusttrait DataSource { type Error; fn fetch(&self) -> Result<Data, Self::Error>; }
대부분의 구현은 의미 있는 에러 타입을 가진다. 네트워크 소스는 I/O 에러, 파서는 문법 에러 등. 하지만 어떤 소스는 실패 불가능(infallible)하다. 메모리 캐시는 자신의 내용을 읽는 데 실패하지 않는다.
rustenum Infallible {} struct MemoryCache { data: Data } impl DataSource for MemoryCache { type Error = Infallible; fn fetch(&self) -> Result<Data, Infallible> { Ok(self.data.clone()) } }
Error = Infallible는 fetch가 Err을 반환할 수 없음을 타입 수준에서 전달한다. 호출자는 반박 불가능(irrefutable) 패턴을 사용할 수 있다.
rustfn use_cache(cache: &MemoryCache) { let Ok(data) = cache.fetch(); // Err 케이스가 없음 process(data); }
컴파일러는 이 지식을 바탕으로 최적화한다. Result<T, Infallible>은 Err 변형이 존재할 수 없고 판별자가 필요 없으므로 T와 같은 레이아웃을 가진다.
rustuse std::mem::size_of; use std::convert::Infallible; assert_eq!(size_of::<Result<u64, Infallible>>(), 8); // u64와 동일
표준 라이브러리는 이를 위해 std::convert::Infallible을 제공하며, 이는 빈 enum으로 정의되어 “실패할 수 없음”을 나타내는 데 널리 쓰인다.
빈 타입에 대한 raw 포인터는 만들 수 있지만 역참조는 미정의 동작이다. 읽어낼 값이 없어서 유효한 결과를 만들 수 없다. 그래서 빈 타입은 C의 void*를 표현하는 데 부적절하다. 불투명(opaque) C 포인터에는 *const () 또는 그것의 newtype 래퍼가 권장된다. 이는 0바이트를 읽도록 안전하게 역참조할 수 있다.
enum은 현재 어느 변형을 담고 있는지 기록해야 한다. 순진한 접근은 변형을 식별하는 작은 정수인 판별자(discriminant) 를 변형 데이터 옆에 저장하는 것이다. 4개 변형이면 최소 2비트가 필요하지만, 실제로는 변형 수와 정렬 제약에 따라 판별자가 1, 2, 4바이트를 차지한다.
Option<T>는 두 변형 Some(T)와 None을 가진다. 순진한 레이아웃은 판별자 + T를 저장한다.
text// 순진한 Option<u64> 레이아웃: // discriminant: 1 byte (0 = None, 1 = Some) // padding: 7 bytes (u64 정렬) // value: 8 bytes // total: 16 bytes
T가 &u64 같은 타입이면 낭비가 심하다. 참조는 8바이트인데 Option<&u64>가 16바이트가 된다. 하지만 최적화의 핵심 통찰은 이것이다. 참조는 null일 수 없다. Rust는 &T가 항상 유효한 T를 가리킨다고 보장한다. 0으로만 이루어진 비트 패턴(널 포인터)은 유효한 참조를 나타낼 수 없다.
컴파일러는 이를 이용한다. Option<&T>는 별도 판별자를 저장하지 않는다. Some은 포인터를 그대로 저장하고, None은 null 포인터로 표현된다. 패턴 매칭은 null 체크가 된다.
text// 실제 Option<&u64> 레이아웃: // pointer: 8 bytes // total: 8 bytes // None은 null (0x0000000000000000) // Some(&x)는 x의 주소
이를 니치 최적화 라 한다. 니치(niche) 는 어떤 타입이 “절대 가질 수 없다고 보장하는” 비트 패턴이다. 컴파일러는 니치를 이용해 추가 공간 없이 enum 판별자를 인코딩한다.
어떤 타입이 니치를 가지는가? 특정 비트 패턴을 금지하는 타입이면 된다.
&T, &mut T)는 null을 금지.NonNull<T>는 비-null 포인터.NonZeroU32(및 NonZeroU8, NonZeroI64 등)는 0을 금지.Box<T>는 내부적으로 비-null 포인터.bool은 0과 1만 허용하므로 나머지 254개의 바이트 값이 니치.이 최적화는 조합된다. Option<Box<T>>는 None을 null로 표현한다. 그러면 Option<Option<Box<T>>>는 어떨까? 바깥 Option도 None을 표현할 니치가 필요하고, 안쪽 Option은 이미 null을 자기 None에 사용했다. 바깥 None과 Some(None)을 구분할 수 있을까?
x86-64의 48비트 가상 주소 체계에서는 포인터에 비-null 외의 제약도 있다. 상위 16비트는 비트 47의 부호 확장이어야 한다. 대부분의 사용자 공간 포인터는 상위 비트가 0(정규화된 lower-half 주소)이다. 컴파일러는 0x0000000000000001 같은 비정규(non-canonical) 주소 또는 정렬 조건을 깨는 주소(정렬 > 1인 타입에 대해 1은 비정렬)를 추가 니치로 사용할 수 있다.
rustuse std::mem::size_of; assert_eq!(size_of::<Box<i32>>(), 8); assert_eq!(size_of::<Option<Box<i32>>>(), 8); assert_eq!(size_of::<Option<Option<Box<i32>>>>(), 8);
세 타입 모두 8바이트에 들어간다. 컴파일러가 포인터에서 두 개의 니치를 찾았기 때문이다. 안쪽 None에는 null, 바깥 None에는 또 다른 유효하지 않은 주소.
니치가 없는 타입에서는 판별자에 추가 공간이 든다.
rustuse std::mem::size_of; assert_eq!(size_of::<u64>(), 8); assert_eq!(size_of::<Option<u64>>(), 16);
모든 비트 패턴이 유효한 u64이므로 니치가 없다. 컴파일러는 별도 판별자를 저장해야 하고, 정렬 패딩 때문에 총 16바이트가 된다.
이제 앞에서 미뤘던 질문으로 돌아가자. 왜 #[repr(u8)]이 필드 있는 enum에서 니치 최적화를 억제할까? repr(u8)은 판별자가 특정 위치에 명시적인 u8로 저장된다는 레이아웃 보장이다(FFI, 바이너리 직렬화 목적). 니치 최적화를 쓰면 명시적 판별자 바이트가 없고, 변형은 payload 비트 패턴에 인코딩된다. 이 두 요구는 양립 불가능하다. 명시적 판별자 레이아웃과 니치 최적화는 상호 배타적 이다.
rustuse std::mem::size_of; enum WithNiche { Some(Box<i32>), None } #[repr(u8)] enum WithoutNiche { Some(Box<i32>), None } assert_eq!(size_of::<WithNiche>(), 8); // 니치 사용, 판별자 없음 assert_eq!(size_of::<WithoutNiche>(), 16); // 명시적 u8 판별자 + 패딩 + 포인터
repr(u8)은 1바이트 판별자의 존재를 강제하고, 7바이트 패딩 + 8바이트 포인터로 16바이트가 된다.
Rust는 런타임에서(상수 함수이므로 컴파일 타임 평가도 가능) 타입 속성을 확인하는 std::mem::size_of::<T>()와 std::mem::align_of::<T>()를 제공한다. 더 자세한 레이아웃 정보(필드 순서, 패딩, 판별자 위치)는 nightly rustc의 -Zprint-type-sizes 플래그로 볼 수 있다.
RUSTFLAGS=-Zprint-type-sizes cargo +nightly build --release
예를 들어 다음 enum에 대해:
rustenum E { A, B(i32), C(u64, u8, u64, u8), D(Vec<u32>), }
출력은 대략 다음을 보여준다.
textprint-type-size type: `E`: 32 bytes, alignment: 8 bytes print-type-size discriminant: 1 bytes print-type-size variant `D`: 31 bytes print-type-size padding: 7 bytes print-type-size field `.0`: 24 bytes, alignment: 8 bytes print-type-size variant `C`: 23 bytes print-type-size field `.1`: 1 bytes print-type-size field `.3`: 1 bytes print-type-size padding: 5 bytes print-type-size field `.0`: 8 bytes, alignment: 8 bytes print-type-size field `.2`: 8 bytes
컴파일러가 변형 C의 필드를 재정렬해 u8들을 패딩 앞에 배치함으로써 공간 낭비를 최소화했음을 볼 수 있다. 변형이 네 개여도 판별자는 1바이트인데(2비트면 충분하지만), 정렬 제약상 주소 지정 가능한 최소 단위가 1바이트이기 때문이다.
repr(Rust)가 컴파일러에 필드 재정렬 자유를 주는 이유는, 각 필드의 크기와 정렬을 컴파일 타임에 알고 있기 때문이다. 하지만 이 가정은 항상 성립하지 않는다. 어떤 타입은 런타임에서만 크기를 결정할 수 있으며, Rust가 이를 다루는 방식은 C++과 크게 다르다.
C에서는 배열을 함수에 전달하면 타입 시스템이 정보를 잃는다.
cvoid process(int arr[], size_t len); int main(void) { int data[10] = {0}; process(data, 10); // 길이를 따로 전달해야 함 }
매개변수 int arr[]는 int *arr와 동일하다. 배열은 포인터로 붕괴(decay) 하고 길이 정보는 타입에서 사라진다. 언어 차원에서 포인터와 길이를 연결해 주지 않는다. 길이를 잘못 넘기면 버퍼 오버플로가 난다. 아예 넘기지 않으면 함수는 배열 끝을 알 방법이 없다.
이 붕괴는 암묵적으로 일어난다. 호출 지점의 data는 int[10] 타입이지만, process에 도달할 즈음에는 int*일 뿐이다. 컴파일러는 정보를 지워버리고, 프로그래머가 수동으로 추적해야 한다.
Rust는 동적 크기 타입(DST, dynamically sized types) 으로 이를 해결한다. DST는 컴파일 타임에 크기를 알 수 없는 타입이다. 언어는 세 가지 내장 DST 범주를 가진다.
[T]: 길이를 알 수 없는 연속된 T 시퀀스.str: UTF-8 불변식을 가진 [u8].dyn Trait: Trait을 구현하는 구체 타입이 무엇인지 알 수 없는 값.하지만 이들 타입은 스택이나 구조체 필드에 직접 존재할 수 없다(마지막 필드로는 예외적으로 가능). 예컨대 let x: [u8];는 컴파일러가 스택에 얼마나 공간을 잡아야 하는지 알 수 없으므로 불가능하다. DST는 포인터 뒤에서만 존재한다.
크기가 정해진 타입(sized type)에 대한 포인터는 머신 워드 하나(예: x86-64에서 8바이트)다. DST에 대한 포인터는 추가 정보를 실어야 하므로 두 배 폭이 된다.
슬라이스의 경우 &[T]는 첫 요소를 가리키는 포인터와 요소 개수를 함께 저장한다.
rustuse std::mem::size_of; assert_eq!(size_of::<&u8>(), 8); // 얇은(thin) 포인터 assert_eq!(size_of::<&[u8]>(), 16); // 와이드 포인터 assert_eq!(size_of::<&str>(), 16); // &[u8]와 같은 표현
실제 표현은 다음처럼 확인할 수 있다.
rustlet arr = [1i32, 2, 3, 4, 5]; let slice: &[i32] = &arr[1..4]; // slice는 (arr[1]을 가리키는 포인터, 길이 3) let ptr = slice.as_ptr(); let len = slice.len(); assert_eq!(len, 3); assert_eq!(unsafe { *ptr }, 2); // 슬라이스의 첫 요소
이 와이드 포인터는 C의 문제를 해결한다. &[T]를 전달하면 길이가 포인터와 함께 이동한다. 분리해서 잘못된 길이를 넘기거나 아예 빠뜨릴 방법이 없다.
&[i32; 5]에서 &[i32]로의 강제 변환은 unsizing coercion 이다. 컴파일러는 배열의 얇은 포인터와 정적으로 알려진 길이를 결합해 와이드 포인터를 만든다. 이는 let 바인딩(명시 타입), 함수 인자/반환 등 강제 변환 지점에서 암묵적으로 일어난다.
트레이트 오브젝트에서 와이드 포인터의 메타데이터는 다르다. &dyn Trait은 구체 값을 가리키는 포인터와 vtable(가상 메서드 테이블) 포인터를 함께 저장한다.
rustuse std::mem::size_of; trait Drawable { fn draw(&self); fn area(&self) -> f64; } impl Drawable for i32 { fn draw(&self) { println!("{}", self); } fn area(&self) -> f64 { 0.0 } } assert_eq!(size_of::<&i32>(), 8); assert_eq!(size_of::<&dyn Drawable>(), 16);
vtable은 컴파일러가 각 (구체 타입, 트레이트) 쌍마다 생성하는 정적 데이터 구조로, 트레이트의 각 메서드에 대한 함수 포인터들과 메모리 관리에 필요한 메타데이터를 담는다.
Drawable이 draw와 area를 가진다면 vtable은 대략 이렇게 생겼다.
text+0: drop_in_place::<ConcreteType> +8: size_of::<ConcreteType> +16: align_of::<ConcreteType> +24: Drawable::draw for ConcreteType +32: Drawable::area for ConcreteType
크기/정렬 항목은 박스된 트레이트 오브젝트를 drop할 때 필수다. Box<dyn Drawable>에 drop을 호출하면 런타임은 몇 바이트를 해제해야 하는지, 할당자가 어떤 정렬을 기대하는지 알아야 한다.
트레이트 오브젝트의 메서드를 호출할 때 컴파일러는 vtable을 통한 간접 호출을 생성한다. 다음 함수를 보자.
rustfn call_draw(obj: &dyn Drawable) { obj.draw(); }
x86-64에서는 대략 이렇게 컴파일된다.
asmcall_draw: ; rdi = 데이터 포인터 (obj.data) ; rsi = vtable 포인터 (obj.vtable) mov rax, [rsi + 24] ; vtable에서 draw 함수 포인터 로드 mov rdi, rdi ; 데이터 포인터가 첫 인자(self) jmp rax ; draw 구현으로 tail call
vtable 룩업은 직접 호출에 비해 메모리 간접 참조를 하나 추가한다. 더 중요한 점은 레지스터를 통한 간접 호출이 vtable 로드가 끝나기 전까지 CPU가 분기 타겟을 예측하기 어렵게 만든다는 것이다. 현대 CPU는 간접 분기 예측기도 갖고 있지만, 직접 분기 예측만큼 효과적이지 않다.
정적 디스패치(static dispatch) 제네릭 함수와 비교해 보자.
rustfn call_draw_static<T: Drawable>(obj: &T) { obj.draw(); }
여기서는 컴파일러가 T마다 함수를 단형화(monomorphize)하여 직접 호출을 만든다.
asmcall_draw_static_for_i32: jmp <i32 as Drawable>::draw
vtable 룩업도 없고 간접 분기도 없다. 호출 대상이 컴파일 타임에 알려져 인라이닝이 가능해진다. draw가 작으면 컴파일러는 호출 오버헤드를 없애고 더 광범위한 최적화를 수행할 수 있다.
가상 디스패치의 오버헤드는 vtable 룩업만이 아니다. 간접 호출은 CPU 파이프라인에 연쇄 효과를 준다.
현대 CPU는 명령 파이프라인을 채우기 위해 분기 타겟을 예측한다. 직접 호출은 타겟이 명령 자체에 인코딩되어 있어 예측기가 즉시 타겟 명령을 가져올 수 있다. 반면 레지스터를 통한 간접 호출은 레지스터 값(vtable 로드)이 계산될 때까지 기다려야 하고, 그 후 간접 분기 예측기로 타겟을 추측해야 한다. 간접 예측기는 최근 타겟 테이블을 유지하지만, 한 호출 지점이 여러 구현으로 디스패치하면 정확도가 떨어진다.
예측이 틀리면 파이프라인을 플러시하고 올바른 타겟에서 재시작해야 하며, x86-64에서는 대략 15~20 사이클 비용이 든다. 어떤 루프가 두 구현 사이를 번갈아 호출하면 예측기가 안정화되지 못해 격 반복마다 미스프레딕션 패널티를 낼 수 있다.
또 vtable이 캐시에 있어야 룩업이 빠르다. vtable 자체는 작지만(메서드 몇 개면 보통 32~64바이트), 이질적(heterogeneous) 트레이트 오브젝트 컬렉션을 순회하면 각 객체가 다른 vtable을 가리킬 수 있다. 구현이 많으면 vtable들이 캐시 공간을 경쟁한다. 각 vtable의 첫 접근은 캐시 미스로 100+ 사이클 지연을 더할 수 있다.
가장 큰 손실은 인라이닝 불가다. 인라이닝은 호출자/피호출자 코드를 동시에 보게 해 상수 전파, 죽은 코드 제거, 루프 결합, SIMD 벡터화 등을 경계 너머로 가능하게 한다. 일반적으로 vtable을 통해서는 이런 최적화를 할 수 없고, 각 호출은 최적화 장벽이 된다.
C++ 컴파일러는 때때로 가상 호출을 디가상화(devirtualize) 할 수 있다. 호출 지점에서 구체 타입이 보이거나 클래스가 final이면 Clang/GCC가 간접 호출을 직접 호출로 바꾸기도 한다. LTO 및 -fwhole-program-vtables를 쓰면 프로그램 전체에서 구현이 하나뿐인 경우 디가상화가 가능할 수 있다.
Rust는 상황이 덜 유리하다. 2024년 기준 rustc는 LLVM이 전프로그램 디가상화를 하기 위한 메타데이터를 충분히 제공하지 않는다. 바이너리 전체에서 트레이트를 구현하는 타입이 하나뿐이어도 트레이트 오브젝트 호출은 간접 호출로 남는다. LLVM은 “같은 기본 블록에서 트레이트 오브젝트를 만들고 즉시 호출하는” 같은 사소한 경우에만 디가상화할 수 있는데, 실전에서는 드물다. 추적 이슈 rust-lang/rust#68262는 큰 진전이 없었다. 지금으로서는 Rust에서 디가상화를 원하면 제네릭이나 enum을 써야 하며, 컴파일러가 트레이트 오브젝트를 구해주지 않는다.
면적 합을 구하는 루프를 보자. 여기서는 주의해야 한다. 정적/동적 버전은 서로 다른 문제 를 해결한다.
rust// 정적 디스패치: 모든 요소가 같은 구체 타입이어야 함 fn sum_areas_static<T: Drawable>(shapes: &[T]) -> f64 { shapes.iter().map(|s| s.area()).sum() } // 동적 디스패치: 서로 다른 구체 타입을 섞을 수 있음 fn sum_areas_dynamic(shapes: &[&dyn Drawable]) -> f64 { shapes.iter().map(|s| s.area()).sum() }
정적 버전은 동질적(homogeneous) 슬라이스가 필요하다. 모두 Circle이거나 모두 Rectangle이어야 하며 섞을 수 없다. 동적 버전은 이질적 컬렉션을 받는다. 둘은 대체 관계가 아니며, “열린(open) 타입 집합이 필요한가, 닫힌(closed) 집합인가”에 따라 선택한다.
동질적 경우 컴파일러는 루프 본문 전체를 인라인하고, 루프를 언롤하고, SIMD로 벡터화할 수도 있다. 이질적 경우 각 area() 호출은 함수 포인터 로드 + 간접 호출 + 반환이며, 컴파일러는 area()가 무엇을 하는지 증명할 수 없어 루프를 벡터화할 수 없다.
타입 집합이 닫혀 있고 컴파일 타임에 알려져 있다면, Rust에는 둘의 장점을 결합하는 세 번째 접근이 있다. enum 디스패치 다.
rustenum Shape { Circle(Circle), Rectangle(Rectangle), } impl Shape { fn area(&self) -> f64 { match self { Shape::Circle(c) => c.area(), Shape::Rectangle(r) => r.area(), } } } fn sum_areas_enum(shapes: &[Shape]) -> f64 { shapes.iter().map(|s| s.area()).sum() }
enum 접근은 원소를 섞을 수 있지만 디스패치는 vtable 룩업이 아니라 컴파일 타임 match다. 컴파일러는 각 분기를 인라인할 수 있고, 현대 CPU는 간접 호출보다 match 분기를 더 잘 예측하는 경향이 있다. 또한 슬라이스는 타입 수준에서 동질적(&[Shape])이어서 캐시 친화적이며, 포인터 추적(pointer chasing)과 산개된 vtable이 없다.
트레이드오프는 확장성이다. enum에 새 변형을 추가하면 enum 정의와 모든 match를 수정해야 한다. 트레이트 오브젝트는 기존 코드를 건드리지 않고 새 타입이 트레이트를 구현할 수 있다. enum은 닫혀 있고(traits는 열려 있다).
경험적 규칙: 성능이 중요한 경로에서 모든 원소가 같은 구체 타입이면 제네릭 + 트레이트 바운드를 쓰자. 타입 집합이 닫혀 있고 이질적 컬렉션을 원하면서 예측 가능한 디스패치가 필요하면 enum을 쓰자. 플러그인 같은 열린 확장성이 필요하거나, 성능보다 컴파일 시간/바이너리 크기를 줄이는 것이 중요하면 트레이트 오브젝트를 쓰자.
Rust와 C++는 vtable 포인터를 어디에 저장할지에 대해 반대 결정을 내렸다.
C++에서는 다형 객체가 객체 내부에 vptr을 박는다.
cppclass Drawable { public: virtual void draw() = 0; virtual double area() = 0; virtual ~Drawable() = default; int x; }; // Drawable 서브클래스 인스턴스의 메모리 레이아웃: // +0: vptr (8 bytes, vtable을 가리킴) // +8: x (4 bytes) // +12: padding (4 bytes) // Total: 16 bytes
다형 클래스의 모든 인스턴스가 vptr을 가진다. Drawable*이나 Drawable&는 8바이트이지만, 각 객체는 가상 함수가 없었을 때보다 8바이트 더 크다.
Rust는 vtable 포인터를 객체가 아니라 참조(reference) 안에 둔다.
ruststruct Circle { radius: f64, } impl Drawable for Circle { fn draw(&self) { /* ... */ } fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } } // Circle 레이아웃: // +0: radius (8 bytes) // Total: 8 bytes (vptr 없음) // Circle을 가리키는 &dyn Drawable 레이아웃: // +0: data pointer (8 bytes) // +8: vtable pointer (8 bytes) // Total: 16 bytes
트레이드오프는 객체 크기 vs 참조 크기다. 객체 100만 개에 참조 하나씩이라면 C++은 객체에 내장된 vptr로 8MB를 쓰고, Rust는 참조의 vtable 포인터로 8MB를 쓴다. 하지만 객체 100만 개에 참조가 10개씩이라면 C++은 여전히 8MB지만 Rust는 80MB가 된다.
대신 Rust의 설계는 C++이 하기 어려운 일을 가능하게 한다. 같은 객체를 여러 트레이트 “렌즈”로 동시에 볼 수 있다.
rustuse std::fmt::Debug; let circle = Circle { radius: 1.0 }; let drawable: &dyn Drawable = &circle; let debug: &dyn Debug = &circle; let any: &dyn std::any::Any = &circle;
각 참조는 자기 vtable 포인터를 갖고 서로 다른 vtable을 가리킨다. circle 객체 자체는 변하지 않는다. C++에서는 객체 내부 vptr이 생성 시점에 동적 타입을 결정하며, 같은 객체를 가리키는 다양한 베이스 포인터가 동일한 내장 vptr을 사용한다(다중 상속에서는 조정이 있을 수 있음).
모든 트레이트가 트레이트 오브젝트로 사용될 수 있는 것은 아니다. dyn 호환성(과거 “object safety”) 규칙은 어떤 트레이트가 동적 디스패치에 쓸 수 있는지 제한한다.
dyn 호환 트레이트는 Self: Sized를 요구하면 안 된다. Sized를 요구하면 “크기를 모르는 값을 다루기 위한 트레이트 오브젝트”라는 목적과 모순된다. 또한 연관 상수(associated constants)는 구체 타입을 컴파일 타임에 알아야 하므로 허용되지 않는다. 제네릭 매개변수를 가진 연관 타입도 컴파일 타임 인스턴스화가 필요하므로 허용되지 않는다.
모든 메서드는 디스패치 가능해야 하거나, 명시적으로 비디스패치로 표시되어야 한다. 디스패치 가능한 메서드는 리시버(&self, &mut self, Box<Self> 등)가 있어야 하고, Self를 값으로 반환하면 안 되며, Self를 값 매개변수로 받으면 안 되고, 타입 매개변수를 가지면 안 된다.
rust// dyn 호환 trait Compatible { fn method(&self); fn returns_ref(&self) -> &str; } // dyn 비호환 trait Incompatible { fn returns_self(&self) -> Self; // 반환 위치의 Self fn takes_self(&self, other: Self); // 매개변수의 Self fn generic<T>(&self, x: T); // 타입 매개변수 const VALUE: i32; // 연관 상수 }
동적 디스패치는 각 메서드마다 고정된 시그니처의 함수 포인터(vtable 엔트리)가 필요하다. Self를 반환하면 반환 타입이 구체 타입에 따라 달라지는데, 트레이트 오브젝트에서는 그 타입이 지워져 있다. 제네릭 메서드는 T마다 무한히 많은 엔트리가 필요하다.
이 규칙을 위반하는 메서드도 where Self: Sized 바운드를 붙이면 dyn 호환 트레이트 안에 존재할 수 있다. 이 경우 트레이트 오브젝트에서는 호출할 수 없지만 구체 타입에서는 호출 가능하다.
rusttrait PartiallyCompatible { fn dispatchable(&self); fn not_dispatchable(&self) -> Self where Self: Sized; } let obj: &dyn PartiallyCompatible = &some_value; obj.dispatchable(); // obj.not_dispatchable(); // 컴파일되지 않음
트레이트 오브젝트는 오토 트레이트와 수명 바운드를 포함할 수 있다. 일반 트레이트는 하나만 허용되지만, 오토 트레이트는 자유롭게 덧붙일 수 있다.
rustuse std::fmt::Debug; fn takes_debug(x: &dyn Debug) {} fn takes_debug_send(x: &dyn Debug + Send) {} fn takes_debug_send_sync(x: &(dyn Debug + Send + Sync)) {} fn takes_debug_static(x: &(dyn Debug + 'static)) {}
오토 트레이트(Send, Sync, Unpin 등)는 vtable에 메서드를 추가하지 않는다. 트레이트 오브젝트가 생성될 때 컴파일 타임에 검사되는 마커 트레이트다. Send가 아닌 타입에서 &dyn Debug + Send를 만들려고 하면 컴파일러가 거부한다.
rustuse std::rc::Rc; use std::fmt::Debug; let rc: Rc<i32> = Rc::new(42); // Error: Rc<i32>는 Send가 아님 // let obj: &(dyn Debug + Send) = &*rc;
수명 바운드는 트레이트 오브젝트 내부의 참조가 얼마나 오래 살 수 있는지 제한한다. dyn Trait + 'static은 'static이 아닌 참조를 포함하지 않는다. dyn Trait + 'a는 적어도 'a 동안 사는 참조를 포함할 수 있다. 기본 수명은 문맥과 생략 규칙을 따른다.
트레이트가 슈퍼트레이트를 가지면 vtable에는 슈퍼트레이트 메서드 엔트리도 포함된다.
rusttrait Shape { fn area(&self) -> f64; } trait Circle: Shape { fn radius(&self) -> f64; }
dyn Circle의 vtable에는 area와 radius 모두의 엔트리가 있다. 트레이트 오브젝트에서 슈퍼트레이트 메서드를 호출해도 같은 vtable 룩업을 거친다.
rustfn print_area(c: &dyn Circle) { println!("Area: {}", c.area()); }
트레이트 오브젝트 업캐스팅(upcasting)은 &dyn Circle을 &dyn Shape로 변환한다. 컴파일러는 dyn Shape용 다른 vtable(오직 area만 포함)을 생성하고, 변환은 vtable 포인터를 교체한다.
rustfn use_as_shape(c: &dyn Circle) { let shape: &dyn Shape = c; // 업캐스팅 강제 변환 println!("Area: {}", shape.area()); }
기본적으로 모든 타입 매개변수에는 암묵적 Sized 바운드가 있다.
rustfn foo<T>(x: T) { } // 는 다음과 동등 fn foo<T: Sized>(x: T) { }
값으로 전달하려면 스택에 복사할 크기를 알아야 하므로 자연스럽다. 하지만 때로는 참조로 DST를 받고 싶다. ?Sized는 Sized 요구를 완화한다.
rustfn process<T: ?Sized>(x: &T) { // T는 DST일 수 있음 // T가 unsized면 x는 와이드 포인터 } process::<[u8]>(&[1, 2, 3][..]); process::<str>("hello"); process::<dyn std::fmt::Debug>(&42);
표준 라이브러리는 ?Sized를 널리 쓴다. 예를 들어 std::borrow::Borrow는 다음 시그니처를 가진다.
rustpub trait Borrow<Borrowed: ?Sized> { fn borrow(&self) -> &Borrowed; }
이는 String이 Borrow<str>를 구현해 &str(DST에 대한 참조)를 반환할 수 있게 한다.
트레이트 정의에서 Self는 타입 매개변수와 반대로 암묵적 Self: ?Sized 바운드를 가진다. 이는 str, [T], dyn OtherTrait 같은 DST에 대해서도 트레이트를 구현할 수 있게 한다.
rusttrait MyTrait { fn method(&self); } impl MyTrait for str { fn method(&self) { println!("length: {}", self.len()); } }
구조체는 마지막 필드로 DST를 포함할 수 있으며, 그러면 구조체 자체가 DST가 된다.
ruststruct MySlice { header: u32, data: [u8], // 마지막 필드가 DST }
MySlice는 이제 DST다. 스택에 직접 만들 수 없고, 지원되는 구성 방식은 sized 변형에서의 unsizing coercion뿐이다.
ruststruct MySized<const N: usize> { header: u32, data: [u8; N], } fn main() { let sized = MySized::<4> { header: 42, data: [1, 2, 3, 4] }; let dynamic: &MySlice = unsafe { // ptr::from_raw_parts가 필요하거나, 신중한 transmute std::mem::transmute::<&MySized<4>, &MySlice>(&sized) }; assert_eq!(dynamic.header, 42); assert_eq!(dynamic.data.len(), 4); }
이는 어색하고 unsafe가 필요하다. Rust 1.79에서 안정화된 ptr::from_raw_parts API가 커스텀 DST 포인터를 더 안전하게 만들 수 있지만, 여전히 사용성은 좋지 않다. 대부분의 코드는 커스텀 DST보다 내장 DST([T], str, dyn Trait)를 쓴다.
DST를 구성하려면 타입을 완성하는 메타데이터(길이 또는 vtable 포인터)를 제공해야 한다. 하지만 언어는 값 수준에서 이를 표현하는 문법을 제공하지 않는다. 표준 라이브러리는 str과 [T] 같은 타입에 대해 컴파일러 마법과 unsafe 코드를 통해 내부적으로 DST 구성을 처리한다.
와이드 포인터의 메타데이터는 단순 정보가 아니다. 컴파일러는 이를 안전성에 중요한 연산에서 신뢰한다. 잘못된 메타데이터를 제공하면 미정의 동작이다.
슬라이스에서는 길이가 할당 범위를 넘어 확장되지 않아야 한다.
rustlet arr = [1, 2, 3]; let ptr = arr.as_ptr(); // UB: 길이 1000은 할당 범위를 초과 let bad_slice: &[i32] = unsafe { std::slice::from_raw_parts(ptr, 1000) };
트레이트 오브젝트에서는 vtable이 해당 트레이트에 대한 유효한 vtable이어야 하고, 데이터가 가리키는 실제 동적 타입과 일치해야 한다.
rust// UB: null은 유효한 vtable이 아님 let data_ptr: *const () = &42i32 as *const i32 as *const (); let vtable_ptr: *const () = std::ptr::null(); let bad: &dyn std::fmt::Debug = unsafe { std::mem::transmute((data_ptr, vtable_ptr)) };
Rust 레퍼런스는 잘못된 와이드 포인터 메타데이터를 미정의 동작으로 명시한다. 손상된 vtable로 메서드를 호출하면 임의 코드로 점프할 수도 있다. 잘못된 슬라이스 길이는 범위를 벗어난 읽기/쓰기를 유발할 수 있다.
Rust가 동적 크기 타입에 대한 포인터를 어떻게 표현하는지 보았다. &dyn Draw는 데이터 포인터와 vtable 포인터를 함께 들고 다니는 16바이트이며, 런타임 메서드 디스패치를 가능하게 한다. 하지만 아직 답하지 않은 질문이 있다. 왜 Rust는 두 가지 다형성 메커니즘이 모두 필요할까? 템플릿/제네릭만으로도 여러 타입에 대해 동작하는 코드를 쓸 수 있다. 그런데 왜 vtable을 도입했을까?
“트레이트 X를 구현하는 어떤 타입이든” 동작하는 함수를 쓰면 컴파일러는 코드를 생성하는 방식을 선택해야 한다. 두 전략이 있다.
C++과 Rust는 둘 다 두 전략을 지원한다. 제네릭이 없는 C는 타입 안전성과 비용 측면에서 다양한 근사(workaround)만 제공한다.
단형화는 런타임 간접 참조를 없앤다. 모든 호출이 직접 호출이고, 모든 함수 본문이 인라인될 수 있으며, 옵티마이저가 추상화 경계 너머를 본다. 대신 바이너리에 각 타입 인스턴스화마다 제네릭 코드 사본이 들어가 컴파일 시간과 바이너리 크기가 커진다. vtable 기반 동적 디스패치는 구현 타입이 많아도 코드 사본이 하나라 바이너리 크기를 줄이지만, 각 메서드 호출마다 메모리에서 함수 포인터를 로드하고 점프해야 하며 CPU가 잘 예측하지 못하고 인라이닝도 막는다.
C에는 내장 매개변수 다형성이 없다. “어떤 비교 가능한 타입”에 대해 동작하는 타입 안전한 특수화를 컴파일러에 맡길 수 없다. 역사적으로 C 프로그래머들은 세 가지 우회책을 썼다.
전처리기 매크로는 컴파일러가 코드를 보기 전에 텍스트 치환을 수행한다.
c#define MAX(a, b) ((a) > (b) ? (a) : (b)) int x = MAX(3, 5); // ((3) > (5) ? (3) : (5)) double y = MAX(1.5, 2.5); // ((1.5) > (2.5) ? (1.5) : (2.5))
사용 지점마다 타입별 코드가 생성되어 단형화 같은 효과를 얻는다. 하지만 매크로는 타입 시스템 밖에서 동작한다. 전처리기는 a, b가 무엇인지 알지 못한다. 텍스트를 붙일 뿐이다. 이는 고전적인 “이중 평가” 함정으로 이어진다.
c#define SQUARE(x) ((x) * (x)) int a = 5; int b = SQUARE(a++); // ((a++) * (a++)), 미정의 동작
인자가 두 번 평가되어 부작용이 있으면 동작이 미정의가 된다. 매크로는 인자를 한 번만 평가해 로컬에 바인딩할 수 없다(로컬 변수가 없으므로).
C11의 _Generic은 컴파일 타임 타입 디스패치를 제공한다.
c#define abs(x) _Generic((x), \ int: abs_int, \ long: abs_long, \ double: fabs, \ default: abs_int)(x) int abs_int(int x) { return x < 0 ? -x : x; } long abs_long(long x) { return x < 0 ? -x : x; }
_Generic은 첫 인자의 타입을 보고 해당 표현식을 선택한다. 매크로보다 낫다. 선택은 타입 시스템 안에서 이뤄지고 각 분기는 인자를 한 번만 평가하는 함수다. 하지만 지원 타입을 모두 열거해야 하고 타입마다 구현을 써야 한다. 코드 중복이 사라지는 게 아니라 디스패치가 중앙화될 뿐이다.
동적 다형성에는 void*와 함수 포인터를 쓴다.
ctypedef int (*comparator)(const void*, const void*); void qsort(void* base, size_t nmemb, size_t size, comparator cmp); int compare_int(const void* a, const void* b) { return *(const int*)a - *(const int*)b; } int arr[] = {5, 2, 8, 1}; qsort(arr, 4, sizeof(int), compare_int);
표준 라이브러리 qsort는 배열을 raw 바이트로 취급하고 비교 함수 포인터를 받는다. 타입 정보는 지워진다. double 배열을 정렬하면서 compare_int를 넘기는 것을 막을 방법이 없다. 컴파일러는 정확성을 검증하지 못한다. 프로그래머가 틀리면 조용히 쓰레기 결과를 내거나 크래시한다.
C++ 템플릿은 타입에 매개변수화된 함수/클래스 패밀리를 정의한다.
cpptemplate<typename T> T max(T a, T b) { return (a > b) ? a : b; } int x = max(3, 5); // max<int> 인스턴스화 double y = max(1.5, 2.5); // max<double> 인스턴스화
컴파일러가 max(3, 5)를 만나면 T = int를 추론하고 특수화된 max<int> 함수를 생성한다. 두 번째 호출로는 max<double>가 생성된다. 인스턴스화는 독립적으로 컴파일되어 사람이 손으로 쓴 것과 동일한 코드를 만든다. 런타임 오버헤드는 없다.
템플릿은 흔히 “덕 타이핑”이라 불리는 방식을 쓴다. 템플릿 본문에서 사용한 연산이 구체 타입에 대해 유효하면 인스턴스화가 성공하고, 아니면 컴파일 오류가 난다. 문제는 오류가 템플릿 인스턴스화 깊은 곳에서 발생해 진짜 원인을 가리는 악명 높은 장황한 진단을 낳는다는 점이다. 템플릿의 요구 사항은 암묵적이며, 어떤 타입이 이를 만족하는지 인스턴스화 시점에서야 알 수 있다.
이 암묵적 체크는 SFINAE(Substitution Failure Is Not An Error)를 가능하게 한다. 유효하지 않은 치환은 하드 에러 대신 오버로드 후보에서 조용히 제거된다. C++20 이전에 템플릿을 제한(constrain)하려면 std::enable_if와 타입 트레이트를 이용한 난해한 메타프로그래밍이 필요했다.
cpp#include <type_traits> template<typename T> typename std::enable_if<std::is_integral<T>::value, T>::type absolute(T x) { return x < 0 ? -x : x; }
enable_if는 타입 트레이트에 따라 반환 타입을 유효/무효로 만든다. T가 정수형이 아니면 치환이 실패하여 후보에서 제거된다. 작동은 하지만 의도가 기계장치에 묻혀버린다.
C++20의 콘셉트(concepts)는 제약을 명시적으로 만든다.
cpp#include <concepts> template<typename T> concept Comparable = requires(T a, T b) { { a < b } -> std::convertible_to<bool>; { a > b } -> std::convertible_to<bool>; }; template<Comparable T> T max(T a, T b) { return (a > b) ? a : b; }
Comparable 콘셉트는 T가 지원해야 할 연산을 선언한다. 템플릿은 시그니처에서 제약을 명시한다. 비교 불가능 타입으로 max를 인스턴스화하면, 오류 메시지는 템플릿 본문 깊은 곳이 아니라 위반된 콘셉트를 직접 가리킨다.
제약 표현 방식과 무관하게 템플릿은 단형화한다. std::sort나 std::unordered_map 같은 큰 템플릿은 바이너리 부풀림이 상당하다. 이를 완화하는 기법이 명시적 인스턴스화(explicit instantiation)다. 하나의 번역 단위에서 어떤 인스턴스화를 생성할지 선언한다.
cpp// header template<typename T> void process(T x); // source template<typename T> void process(T x) { /* implementation */ } template void process<int>(int); template void process<double>(double);
다른 번역 단위는 process<int>를 사용해도 인스턴스화를 트리거하지 않고, 미리 생성된 코드에 링크한다. 이는 컴파일 시간과 바이너리 크기를 줄이지만 유연성을 희생한다.
C++는 가상 함수로 런타임 다형성을 제공한다. 가상 함수를 하나 이상 가진 클래스는 다형적(polymorphic)이다.
cppclass Shape { public: virtual double area() const = 0; virtual ~Shape() = default; }; class Circle : public Shape { double radius; public: Circle(double r) : radius(r) {} double area() const override { return 3.14159 * radius * radius; } }; class Rectangle : public Shape { double width, height; public: Rectangle(double w, double h) : width(w), height(h) {} double area() const override { return width * height; } };
베이스 클래스 포인터/참조로 가상 함수를 호출하면 실제 호출될 함수는 객체의 동적 타입에 따라 달라진다.
cppvoid print_area(const Shape& s) { std::cout << s.area() << "\n"; // 가상 디스패치 } Circle c(1.0); Rectangle r(2.0, 3.0); print_area(c); // Circle::area print_area(r); // Rectangle::area
컴파일러는 어떤 구현을 호출할지 컴파일 타임에 알 수 없다. 결정을 런타임으로 미룬다.
이를 위해 컴파일러는 다형 클래스의 모든 객체에 숨겨진 포인터(vptr)를 삽입한다. vptr은 같은 동적 타입의 모든 객체가 공유하는 정적 테이블(vtable)을 가리킨다. GCC/Clang 및 대부분의 비윈도우 컴파일러가 사용하는 Itanium C++ ABI는 vtable 레이아웃을 엄밀히 규정한다.
여기서는 단순 계층(다중 상속 없음)만 보자. x86-64에서 Circle 객체는 대략 다음과 같다.
textCircle object (x86-64에서 16 bytes): +0: vptr (8 bytes, Circle의 vtable을 가리킴) +8: radius (8 bytes, double) Circle의 vtable: -16: offset-to-top (0) -8: RTTI pointer 0: &Circle::~Circle() (complete destructor) +8: &Circle::~Circle() (deleting destructor) +16: &Circle::area()
Shape&에서 s.area()를 호출하면 컴파일러는 대략 다음을 만든다.
asmmov rax, [rdi] ; 객체에서 vptr 로드 mov rax, [rax + 16] ; vtable에서 area() 포인터 로드 call rax ; 간접 호출
가상 호출마다 메모리 로드가 두 번 발생한다(vptr 로드 + vtable 엔트리 로드). 또한 call rax는 간접 분기이며, 이질적 컨테이너를 순회하면 예측기가 흔들릴 수 있다.
옵티마이저는 가상 호출을 인라인할 수 없다. 어떤 함수가 호출될지 모르기 때문에 호출 지점에 함수 본문을 넣을 수 없다. 이로 인해 상수 전파, 죽은 코드 제거 같은 최적화가 막힌다.
장점은 코드 크기다. 구현 타입이 몇 개든 print_area 함수는 하나뿐이다. vtable 비용은 사용 지점이 아니라 클래스당 오버헤드다. 큰 계층에서는 템플릿 대안에 비해 바이너리 크기를 크게 줄일 수 있다.
Rust 제네릭은 C++ 템플릿처럼 단형화 전략을 따르지만 중요한 차이가 있다. 제약을 미리 선언 한다.
rustfn max<T: PartialOrd>(a: T, b: T) -> T { if a > b { a } else { b } } let x = max(3, 5); // max::<i32> let y = max(1.5, 2.5); // max::<f64>
T: PartialOrd 바운드는 T가 PartialOrd 트레이트를 구현해야 함을 말한다. 컴파일러는 호출 지점에서 이를 검사한다. 구현하지 않는 타입으로 호출하면 만족되지 않은 바운드를 직접 지적하는 오류가 난다.
함수 본문에서는 PartialOrd가 보장하는 연산만 쓸 수 있다. 바운드가 제공하지 않는 메서드를 호출하려 하면 인스턴스화 시점이 아니라 즉시 실패한다.
rustfn broken<T: PartialOrd>(a: T, b: T) -> T { println!("{}", a); // error: T는 Display를 구현하지 않음 if a > b { a } else { b } }
Rust는 제네릭 함수를 인스턴스화하기 전에 선언된 바운드에 대해 검증한다. 이는 본문이 구체 타입마다 “그때그때” 컴파일되는 C++ 템플릿과 다르다.
단형화 과정은 유사하다. Rust 컴파일러가 제네릭 함수 호출을 만나면 구체 타입들을 기록한다. 코드 생성 단계에서 단형화 수집기(monomorphization collector) 가 호출 그래프를 따라 필요한 모든 인스턴스화를 모은다. 각 제네릭 함수 + 구체 타입 인자 조합은 별도의 mono item 이 되어 머신 코드로 컴파일된다.
수집기는 mono item을 코드 생성 유닛(CGU)으로 나눈다. 점진적 컴파일을 위해 파티셔너는 안정적인 비제네릭 코드와 단형화된 인스턴스를 별도 CGU로 만든다. 제네릭 인스턴스화만 바뀌면 안정 CGU는 재사용될 수 있다.
바이너리 크기 문제도 동일하다. 제네릭 함수가 많은 타입으로 사용되면 사본이 많이 생긴다. cargo llvm-lines는 어떤 함수가 LLVM IR을 많이 생성하는지 보여준다. 큰 코드베이스에서는 Option::map이나 Result::map_err 같은 유틸리티가 수백 번 인스턴스화되어 코드 크기를 지배하기도 한다.
표준적인 완화책은 내부 함수 패턴(inner function pattern) 이다. 로직 대부분을 비제네릭 내부 함수로 옮기고, 바깥에 얇은 제네릭 래퍼만 둔다.
rustpub fn read<P: AsRef<Path>>(path: P) -> io::Result<Vec<u8>> { fn inner(path: &Path) -> io::Result<Vec<u8>> { let mut file = File::open(path)?; let size = file.metadata().map(|m| m.len()).unwrap_or(0); let mut bytes = Vec::with_capacity(size as usize); io::default_read_to_end(&mut file, &mut bytes)?; Ok(bytes) } inner(path.as_ref()) }
바깥 제네릭 함수는 as_ref()로 P를 &Path로 바꾼 뒤 inner로 위임한다. inner는 경로 타입이 몇 개든 한 번만 컴파일된다. 바깥 래퍼는 작아서 단형화 비용이 최소화된다.
단형화 비용이 과도할 때 Rust는 트레이트 오브젝트를 제공한다. 표현은 앞서 다뤘다. &dyn Trait은 데이터 포인터 + vtable 포인터를 가진 와이드 포인터이고, 메서드 호출은 vtable에서 함수 포인터를 로드해 간접 호출한다.
C++과의 핵심 차이는 vtable 포인터의 위치다. C++에서는 vptr이 객체에 내장되어 Circle 자체가 본질적으로 다형적이다. Rust에서는 vtable 포인터가 참조에 있으며, 평범한 Circle 구조체에는 vptr이 없다. Circle을 트레이트 오브젝트로 볼 때만 vtable 포인터가 추가된다.
rustlet c = Circle { radius: 1.0 }; let shape: &dyn Shape = &c; // 여기서 와이드 포인터 생성
Circle은 필드가 요구하는 바이트만 차지한다. 8바이트 vtable 포인터는 &dyn Shape를 만들 때만 더해진다. 객체가 vtable 포인터를 포함하지 않으므로, 같은 객체를 여러 트레이트 오브젝트(각각 다른 vtable)로 볼 수 있다.
이 설계는 트레이트를 구현해도 구조체 레이아웃이 바뀌지 않음을 의미한다. C++에서는 가상 함수를 하나 추가하면 vptr이 생겨 크기가 늘고, 비-trivially copyable이 되기도 한다. Rust에서는 트레이트 구현이 구조체 레이아웃에 영향을 주지 않는다.
단형화 기반 정적 디스패치는 다음 경우에 적합하다.
vtable 기반 동적 디스패치는 다음 경우에 적합하다.
두 전략을 결합하는 패턴도 흔하다. 예를 들어 공개 API는 제네릭으로 두고, 내부에서 트레이트 오브젝트로 바꿔 인스턴스 폭발을 피할 수 있다.
rustpub fn process<W: Write>(writer: W) { process_dyn(&mut writer as &mut dyn Write) } fn process_dyn(writer: &mut dyn Write) { // 큰 구현, 한 번만 컴파일 }
공개 API는 어떤 Write 구현체든 받는다. 내부에서는 즉시 트레이트 오브젝트로 바꿔 process_dyn이 한 번만 컴파일되게 한다. 비용은 process_dyn 내부에서 메서드 호출마다 가상 디스패치가 들어가는 것이지만, 바이너리는 구현 사본 하나만 가진다.
두 디스패치 전략으로 카운터를 증가시키는 예를 보자. 먼저 정적 디스패치.
rusttrait Counter { fn increment(&mut self); } struct Simple(u64); impl Counter for Simple { fn increment(&mut self) { self.0 += 1; } } fn inc_static<T: Counter>(c: &mut T) { c.increment(); }
inc_static::<Simple>에 대해 단형화는 다음을 만든다.
asminc_static_Simple: add qword ptr [rdi], 1 ret
메서드 호출 전체가 단일 add 명령으로 인라인된다. 트레이트 추상화는 런타임 비용이 0이다.
이제 동적 디스패치를 보자.
rustfn inc_dynamic(c: &mut dyn Counter) { c.increment(); }
컴파일러는 대략 다음을 생성한다.
asminc_dynamic: mov rax, [rsi + 24] ; vtable에서 increment 로드 mov rdi, rdi ; data pointer jmp rax ; vtable을 통해 tail call
함수는 vtable에서 메서드 포인터를 로드하고 그쪽으로 점프한다. 실제 증가 연산은 타겟 함수에서 수행되며 여기서는 인라인될 수 없다. 단일 증가라면 차이는 미미하지만, 수백만 번 증가시키는 타이트 루프에서는 정적 버전이 반복마다 vtable 로드와 간접 분기를 피한다.
정적 버전은 더 많은 최적화를 가능하게 한다. 컴파일러가 증가 사이에 카운터가 관찰되지 않음을 증명하면 증가를 뭉칠 수 있고, 값이 알려져 있으면 상수 접기도 할 수 있다. vtable 간접 참조를 통해서는 이런 최적화가 불가능하다.
우리는 두 가지 다형성 전략을 보았다. 단형화는 구체 타입마다 특수화된 코드를 만들고, vtable은 런타임 간접 디스패치로 “타입을 모르는 값”에 대해 단일 함수가 동작하게 한다. 둘 다 “변하는 코드”를 다룬다. 그런데 “상태를 들고 다니는 함수”가 필요하면 어떻게 될까?
정렬 함수가 비교(predicate)를 받는다고 하자. 비교는 순수한 코드일 수 있다. 하지만 어떤 기준점(reference point)에서의 거리로 정렬하고 싶다면, 비교 함수는 기준점 좌표에 접근해야 한다. 이제 비교는 순수 코드가 아니라 “코드 + 환경”이다.
이것이 클로저 문제다. 클로저는 둘러싼 스코프의 변수를 “닫아(closes over)” 나중에 쓰기 위해 캡처한다. 세 언어는 이 문제를 각기 다르게 접근한다. C에는 클로저가 없어 수동 우회책이 필요하다. C++는 람다를 도입해 익명 구조체 + 호출 연산자 오버로드로 디설거링된다. Rust 클로저도 유사하지만 소유권 시스템과 통합되어 Fn, FnMut, FnOnce 트레이트로 캡처 상태와의 상호작용을 인코딩한다.
C에는 함수 포인터가 있고 클로저는 없다. 함수 포인터는 실행 코드의 주소일 뿐이고, 주소 외의 데이터를 담지 못한다.
cint compare_ints(const void *a, const void *b) { return *(const int*)a - *(const int*)b; } qsort(array, n, sizeof(int), compare_ints);
비교가 외부 상태를 필요로 없을 때는 된다. 필요할 때 C 라이브러리는 관례를 쓴다. 함수 포인터와 함께 void* 컨텍스트를 넘기고, 콜백은 이 컨텍스트를 추가 인자로 받는다.
cstruct DistanceContext { double ref_x, ref_y; }; int compare_by_distance(const void *a, const void *b, void *ctx) { const struct Point *pa = a; const struct Point *pb = b; const struct DistanceContext *c = ctx; double da = hypot(pa->x - c->ref_x, pa->y - c->ref_y); double db = hypot(pb->x - c->ref_x, pb->y - c->ref_y); return (da > db) - (da < db); } // 컨텍스트를 받는 정렬 함수가 필요 struct DistanceContext ctx = { .ref_x = 0.0, .ref_y = 0.0 }; qsort_r(points, n, sizeof(struct Point), compare_by_distance, &ctx);
qsort_r(POSIX, 표준 C는 아님)은 컨텍스트를 비교 함수로 전달한다. C 콜백 API에서 흔히 보는 패턴이다. 함수 포인터 + 라이브러리가 그대로 되돌려주는 void*.
하지만 void*는 타입 정보를 지운다. DistanceContext*를 기대하는 콜백에 다른 컨텍스트 포인터를 넘기는 것을 막지 못한다. 또한 컨텍스트 포인터가 콜백 실행 시점까지 유효하다는 것을 컴파일러가 검증할 수 없다. 콜백이 컨텍스트의 스택 프레임보다 오래 살면 댕글링 포인터가 된다. 부담은 전적으로 프로그래머에게 있다.
C++11은 람다 표현식을 도입했다. 이는 익명 함수 객체에 대한 문법 설탕이다. 예를 들어
cppauto ref_x = 0.0, ref_y = 0.0; auto compare = [ref_x, ref_y](const Point& a, const Point& b) { double da = std::hypot(a.x - ref_x, a.y - ref_y); double db = std::hypot(b.x - ref_x, b.y - ref_y); return da < db; };
는 대략 다음으로 디설거링된다.
cppstruct __lambda_1 { double ref_x; double ref_y; bool operator()(const Point& a, const Point& b) const { double da = std::hypot(a.x - ref_x, a.y - ref_y); double db = std::hypot(b.x - ref_x, b.y - ref_y); return da < db; } }; __lambda_1 compare{ref_x, ref_y};
캡처 리스트는 무엇을, 어떻게 캡처할지 지정한다. [x]는 값으로(구조체에 복사), [&x]는 참조로(구조체에 참조 저장), [=]는 사용된 모든 것을 값으로, [&]는 사용된 모든 것을 참조로, [x, &y]는 혼합.
각 람다는 이름 붙일 수 없는 고유한 익명 타입이다. 로컬 변수에는 auto를 쓰고, 함수 매개변수에는 템플릿을 써야 한다.
cpptemplate<typename F> void use_callback(F&& f) { f(); }
또는 std::function<R(Args...)>로 타입 소거(type erasure)를 제공할 수 있는데, 이는 힙 할당과 가상 디스패치 비용이 든다.
캡처 방식은 클로저 크기를 결정한다. double 두 개를 값으로 캡처하면 16바이트(정렬 포함)이고, 참조로 캡처하면 포인터 두 개(각 8바이트)를 가진다. 아무 것도 캡처하지 않는 람다는 무상태(stateless)이며, 표준은 캡처 없는 람다가 일반 함수 포인터로 변환될 수 있음을 보장한다.
cppint (*fp)(int, int) = [](int a, int b) { return a + b; };
C++ 람다는 mutable을 붙이면 값 캡처한 것을 수정할 수 있다.
cppint counter = 0; auto increment = [counter]() mutable { return ++counter; }; // 각 호출이 람다 내부의 counter 복사본을 수정
mutable이 없으면 호출 연산자는 const이며 캡처 값을 수정할 수 없다.
Rust 클로저도 같은 구조적 원리를 따른다. 클로저는 캡처된 값들을 담은 익명 구조체이고, 호출을 구현하는 메서드를 가진다. 하지만 안전성에 중요한 디테일이 다르다.
rustlet ref_x = 0.0_f64; let ref_y = 0.0_f64; let compare = |a: &Point, b: &Point| { let da = ((a.x - ref_x).powi(2) + (a.y - ref_y).powi(2)).sqrt(); let db = ((b.x - ref_x).powi(2) + (b.y - ref_y).powi(2)).sqrt(); da.partial_cmp(&db).unwrap() };
컴파일러는 대략 이런 구조체를 만든다.
ruststruct __closure_1<'a> { ref_x: &'a f64, ref_y: &'a f64, } impl<'a> FnOnce<(&Point, &Point)> for __closure_1<'a> { type Output = std::cmp::Ordering; extern "rust-call" fn call_once(self, args: (&Point, &Point)) -> Self::Output { let (a, b) = args; let da = ((a.x - *self.ref_x).powi(2) + (a.y - *self.ref_y).powi(2)).sqrt(); let db = ((b.x - *self.ref_x).powi(2) + (b.y - *self.ref_y).powi(2)).sqrt(); da.partial_cmp(&db).unwrap() } } impl<'a> FnMut<(&Point, &Point)> for __closure_1<'a> { extern "rust-call" fn call_mut(&mut self, args: (&Point, &Point)) -> Self::Output { self.call_once(args) } } impl<'a> Fn<(&Point, &Point)> for __closure_1<'a> { extern "rust-call" fn call(&self, args: (&Point, &Point)) -> Self::Output { self.call_once(args) } }
C++와 달리 Rust는 명시적 캡처 주석을 요구하지 않는다. 컴파일러가 클로저 본문에서 변수가 어떻게 사용되는지에 따라 캡처 방식을 추론한다. 읽기만 하면 공유 참조로 캡처하고, 변경하면 가변 참조로 캡처하고, 값을 이동(move)하거나 Copy가 아니면 값으로 캡처한다.
rustlet s = String::from("hello"); // 공유 참조 캡처(읽기만) let c1 = || println!("{}", s); // 가변 참조 캡처(변경) let mut s = String::from("hello"); let c2 = || s.push_str(" world"); // 값 캡처(이동) let s = String::from("hello"); let c3 = || drop(s);
move 키워드는 추론을 덮어써 모든 캡처를 값으로 강제한다.
rustlet s = String::from("hello"); let c = move || println!("{}", s); // s는 여기서 더 이상 접근 불가; 클로저로 이동됨
이는 클로저가 생성된 스코프보다 오래 살아야 할 때 필수다. 예컨대 스레드를 생성할 때.
rustlet data = vec![1, 2, 3]; std::thread::spawn(move || { // data의 소유권이 클로저로 이동해 새 스레드로 전달됨 println!("{:?}", data); });
move가 없으면 클로저는 data를 참조로 캡처하려 할 것이다. 하지만 data는 생성한 스레드의 스택에 있고, 새 스레드가 실행되기 전에 해제될 수 있다. 컴파일러는 이를 거부한다.
rustlet data = vec![1, 2, 3]; std::thread::spawn(|| { println!("{:?}", data); // ERROR: 클로저가 빌린 값을 초과해 살아 있을 수 있음 });
move는 소유권 이전을 강제하여 클로저가 data를 소유하게 하고, 다른 스레드로 안전하게 가져갈 수 있게 한다.
Fn, FnMut, FnOnce 세 트레이트는 클로저가 어떻게 호출될 수 있는지 를 나타내는 계층이며, 어떻게 캡처했는지를 나타내는 것이 아니다.
FnOnce: 호출하려면 클로저의 소유권이 필요. fn call_once(self, args: Args) -> Output. 호출 후 클로저는 소비된다. 모든 클로저는 최소 한 번은 호출 가능하므로 FnOnce를 구현한다.FnMut: 호출하려면 &mut self가 필요. 캡처 값을 소비하지 않아야 하며, 캡처를 변경할 수는 있다.Fn: 호출하려면 &self만 필요. 캡처를 소비하지도 변경하지도 않아야 한다.계층은 Fn: FnMut: FnOnce다. Fn을 구현하는 클로저는 자동으로 FnMut, FnOnce도 구현한다. 이 트레이트들은 “호출 시 클로저가 무엇을 하는지”를 인코딩하며 “캡처 방식” 자체를 인코딩하는 것이 아니다.
C++ 람다 관점에서 보면 헷갈릴 수 있다. 예를 들어 move 클로저도 Fn을 구현할 수 있다.
rustlet x = 42; let c = move || x; // x를 값으로 캡처(i32는 Copy이므로 복사) // c는 캡처된 x를 읽기만 하므로 Fn 구현
반대로 참조로 캡처해도 참조 대상 값을 변경하는 클로저는 FnMut만 구현한다.
rustlet mut counter = 0; let mut increment = || { counter += 1; }; // increment는 FnMut 구현 (캡처된 &mut을 통해 변경) // Fn은 구현하지 않음
캡처된 값을 이동해 소비해버리면 FnOnce만 구현한다.
rustlet s = String::from("hello"); let consume = move || drop(s); // consume는 FnOnce만 구현
클로저를 받는 함수는 필요한 트레이트를 선언한다. Iterator::for_each와 Iterator::map은 여러 번 호출하므로 FnMut를 받고, thread::spawn은 정확히 한 번 실행되므로 FnOnce를 받는다.
rustfn call_twice<F: FnMut()>(mut f: F) { f(); f(); } fn call_once<F: FnOnce()>(f: F) { f(); }
클로저는 캡처로부터 Send와 Sync를 상속한다. 캡처된 값이 모두 Send이면 클로저도 Send다. 공유 참조로 캡처된 값이 Sync이고, 가변 참조/복사/이동으로 캡처된 값이 Send이면 클로저는 Send다. 이는 일반 구조체 합성과 같은 규칙이다.
rustuse std::rc::Rc; let rc = Rc::new(42); let closure = move || println!("{}", rc); // Rc는 Send가 아니므로 closure도 Send가 아님 // std::thread::spawn(closure); // ERROR
rustuse std::sync::Arc; let arc = Arc::new(42); let closure = move || println!("{}", arc); // Arc는 Send이므로 closure도 Send std::thread::spawn(closure); // OK
클로저는 가변 참조로 캡처하지 않고, 모든 캡처가 Clone 또는 Copy이면 Clone/Copy가 된다.
rustlet x = 42; let closure = move || x; // Copy 타입만 값으로 캡처하므로 closure는 Copy let y = closure; // 복사 let z = closure; // 여전히 유효
이 자동 트레이트 파생 덕분에 클로저는 Rust의 동시성 도구들과 자연스럽게 결합된다. Arc<Mutex<T>>를 캡처하는 move 클로저는 Send이고, 다른 스레드로 보내질 수 있으며, mutex를 통한 안전한 내부 가변성을 제공한다. 타입 시스템이 합성된다.