역사. 초기 Rust에서는 스택 할당과 효율적인 값 의미론을 보장하기 위해 모든 유형이 정적으로 알려진 크기를 가져야 했습니다. 슬라이스 [T] 및 트레잇 객체 dyn Trait와 같은 동적으로 크기 조정되는 유형 (DSTs)이 유연한 데이터 구조를 지원하기 위해 도입되었을 때, 기존 코드를 손상시키지 않으면서 크기 조정된 제네릭 매개변수와 잠재적으로 크기 조정되지 않은 매개변수를 구별하는 메커니즘이 필요했습니다. ?Sized 구문이 "완화된" 경계를 제공하는 방식으로 채택되어 제네릭이 기본 Sized 요구 사항에서 명시적으로 옵트아웃할 수 있도록 하여 비치기 데이터를 포함하지 않는 대부분의 사용 사례에 대해 인체 공학적으로 기본값을 유지합니다.
문제. 암묵적인 T: Sized 경계는 근본적인 긴장을 생성합니다: 이는 값 조작 및 컴파일 타임 메모리 계산을 가능하게 하지만, 함수가 dyn Trait 또는 슬라이스 유형을 간접 없이 직접 수용하는 것을 방지합니다. 이 제한은 개발자들이 소유권 의미론이 필요한 경우에도 Box 또는 참조를 사용해야 하여 정적 및 동적 다형성을 모두 지원하려는 API를 복잡하게 만듭니다. ?Sized가 없으면 제네릭 코드는 구체적인 유형과 런타임 다형적 객체를 모두 추상화할 수 없으며, 크기가 지정된 유형 및 크기가 미지정된 변형을 위해 강제로 힙 할당을 하거나 인터페이스를 중복시킵니다.
해결책. 컴파일러는 ?Sized로 제한된 유형이 데이터 포인터 및 런타임 메타데이터(슬라이스의 길이, 트레잇 객체의 vtable)를 포함하는 조합값인 팻 포인터를 통해서만 액세스될 수 있도록 하여 이를 해결합니다. 제네릭이 T: ?Sized를 지정할 때, 컴파일러는 std::mem::size_of::<T>() 또는 값으로 값을 이동하는 것과 같이 알려진 크기를 요구하는 작업을 금지하여 모든 메모리 레이아웃이 컴파일 타임에 계산 가능하도록 보장합니다. 이 설계는 크기가 지정된 유형이 얇은 포인터를 사용하고 크기가 미지정된 유형이 팻 포인터를 사용하는 제로 비용 추상화를 허용하며, 유형 시스템이 명확하게 구분을 처리합니다.
시스템 모니터링 라이브러리는 작은 스택 할당 오류 코드 또는 dyn Display를 구현하는 크고 동적으로 형식이 지정된 오류 메시지를 기록할 수 있어야 했습니다. 초기 API 설계는 fn log<T: Display>(error: T)를 사용했으며, 암묵적인 Sized 경계 때문에 dyn Display가 제약 조건을 만족할 수 없어 동적 오류 처리에 있어 상당한 인체 공학적 장애를 초래했습니다.
고려된 첫 번째 접근 방법은 모든 오류 유형에 대해 Box<dyn Display>를 강제하여 심지어 간단한 u32 오류 코드조차 힙 할당으로 변환했습니다. 장점: API 표면을 통합하고 복잡한 제네릭 없이 동적 오류 소유를 허용했습니다. 단점: 내장 타겟에 적합하지 않은 할당자 종속성을 도입하고 단순하고 정적 오류를 처리하는 핫 경로에 측정 가능한 지연을 추가했습니다.
두 번째 옵션은 두 개의 별도의 로깅 방법을 유지하는 것이었습니다: 하나는 일반적인 T: Display 크기 지정된 유형을 위한 것이고, 다른 하나는 특별히 &dyn Display를 위한 것이었습니다. 장점: 크기 지정된 유형에 대해 힙 할당을 피하고 복잡한 오류를 위한 동적 배치를 올바르게 지원했습니다. 단점: 상당한 코드 중복이 필요하고, 공개 API 문서를 복잡하게 하였으며, 호출자가 유형의 크기에 대한 사전 지식에 따라 올바른 방법을 선택하도록 강요했습니다.
팀은 fn log<T: ?Sized + Display>(error: &T)를 사용하는 세 번째 접근 방법을 선택하여 크기 지정된 유형과 크기 미지정 유형 모두에 대한 참조를 수용했습니다. 이 솔루션은 단일하고 일관된 API 진입점을 유지하고, 필수 박스를 피하여 no-std 환경을 지원하며, 이중 방법 접근 방식과 비교하여 런타임 오버헤드가 전혀 없음을 부과하기 때문에 선택되었습니다. 제네릭 구현은 크기 지정된 유형에 대해 원래의 단일 형식 버전과 동일한 기계어 코드로 컴파일되었으며, vtable 배치를 통해 트레잇 객체를 올바르게 처리했습니다.
결과적으로 이 크레이트는 마이크로컨트롤러 및 서버 전반에 성공적으로 배포되어 수백만 개의 이질적인 오류 이벤트를 처리하였습니다. 통합 인터페이스를 통해 개발자는 &ConcreteError와 &dyn Error를 원활하게 전달할 수 있어 ?Sized가 다양한 배포 타겟에서 진정한 제로 비용 다형성을 가능하게 함을 보여주었습니다.
왜 함수가 T: ?Sized인 유형의 값을 반환할 수 없는가?
값을 반환하는 함수는 해당 값을 레지스터나 스택에 배치해야 하며, 이는 올바른 호출 규칙 코드를 생성하고 적절한 스택 공간을 예약하기 위해 컴파일 타임에 크기가 알려져 있어야 합니다. [i32] 또는 dyn Debug와 같은 ?Sized 유형은 런타임에 결정된 크기를 가지므로, 컴파일러는 ABI를 위해 필요한 고정 크기 반환 명령어 시퀀스를 생성할 수 없습니다. 포인터 유형(Box<T>, &T)만 정적으로 알려진 크기를 가지므로, 비크기 데이터에 대한 유일한 합법적인 반환 유형이 되며, 근본적으로 ?Sized 제네릭을 "값" 유형이 아닌 "보기" 유형으로 제한합니다.
?Sized는 참조에 대한 트레잇 구현의 일관성 규칙과 어떻게 상호작용하는가?
&T에 대해 트레잇을 구현할 때 T: ?Sized인 경우 구현은 자동으로 팻 포인터(&[i32] 또는 &dyn Trait와 같은)에 적용됩니다. 후보자들은 종종 impl Trait for &T where T: ?Sized가 얇은 포인터와 팻 포인터 모두를 포괄하는 반면, impl Trait for T where T: Sized는 그렇지 않다는 점을 간과합니다. 이 구별은 크기 지정된 데이터와 트레잇 객체 모두에서 작동하는 포괄적인 구현을 정의하는 데 매우 중요하며, 중첩 구현이 Rust의 오르판 규칙을 위반하지 않도록 보장합니다.
소유권 의미론 외에 Box<dyn Trait>와 &dyn Trait의 메모리 표현을 구별하는 것은 무엇인가?
두 가지 모두 팻 포인터(포인터 + vtable)를 사용하지만, Box<dyn Trait>는 할당을 소유하고 해제 목적으로 vtable 포인터를 저장하는 반면, &dyn Trait는 단순히 데이터를 관찰합니다. 중요하게도, Box<T> (여기서 T: ?Sized)는 vtable에 저장된 크기를 사용하여 동적으로 크기 조정된 해제를 처리하기 위해 할당자에게 필요합니다. 반면에 참조는 그러한 책임을 지지 않습니다. 초보자들은 종종 Box가 스택에 존재할 수 없는 크기 조정된 유형의 힙 할당을 가능하게 하는 반면, 참조는 기존 메모리만 빌려온다는 점을 간과합니다. 이는 함수에서 소유된 크기 미지정 데이터를 반환하는 데 있어 Box가 필수적입니다.