역사: 객체 안전성 개념은 초기 Rust에서 특성 객체(dyn Trait)가 동적 분배를 지원하면서 메모리 안전성을 희생하지 않고 무한한 컴파일 시간 코드 생성을 요구하지 않도록 보장하기 위해 등장하였습니다. 가상 분배가 도입되었을 때, 언어 설계자들은 일반화—각 일반 유형에 대해 특정 기계 코드를 생성—와 런타임 다형성을 위한 고정 크기 vtable 요구사항 간의 기본적인 충돌에 직면하게 되었습니다. 이로 인해, 이론적으로 무한한 수의 vtable 항목을 요구하는 일반 메서드를 포함하는 특성은 특성 객체로 직접 강제 변환될 수 없다는 제약이 생겼습니다.
문제: fn process<T>(&self, input: T)와 같은 일반 메서드는 컴파일러가 호출 지점에서 호출된 각 구체적인 유형 T를 위해 독립적인 함수 본체를 생성하는 일반화에 의존합니다. 그러나 특성 객체는 구체적인 유형을 삭제하고 고정된 함수 시그니처가 포함된 vtable에 대한 포인터만 표시합니다. vtable은 컴파일 시간에 결정된 고정 크기를 가져야 하므로, 가능한 모든 유형 T에 대한 무한한 잠재적 인스턴화를 수용할 수 없습니다. 게다가, 유형 매개변수는 컴파일 시간의 구조물이지만, 특성 객체 분배는 런타임에서 발생하므로, 호출자가 vtable을 통해 메서드를 호출할 때 필요한 유형 매개변수를 제공하는 것이 불가능합니다.
해결책: TypeId 패턴은 특성 서명에서 구체적인 유형을 삭제하고 유형 식별을 런타임으로 연기하여 이 문제를 해결합니다. 일반 매개변수를 수용하는 대신, 특성 메서드는 Box<dyn Any> 또는 &dyn Any를 수용합니다. 이 구현은 각 유형에 대해 컴파일러가 생성한 고유 식별자인 TypeId를 이용해 다운캐스팅을 통해 런타임에서 구체적인 유형을 확인합니다. 이 접근 방식은 특성 메서드 자체가 고정된 시그니처를 가지므로 객체 안전성을 복원하고, 유형별 로직은 Any 특성에 기반한 확인된 변환을 활용하여 구현 내부에 캡슐화됩니다.
use std::any::{Any, TypeId}; // 이 특성은 일반 메서드로 인해 객체 안전하지 않음 trait GenericProcessor { fn process<T: Any>(&self, input: T); } // 이 특성은 유형 삭제를 통해 객체 안전함 trait ObjectSafeProcessor { fn process_any(&self, input: Box<dyn Any>); } struct Logger; impl ObjectSafeProcessor for Logger { fn process_any(&self, input: Box<dyn Any>) { if let Ok(s) = input.downcast::<String>() { println!("Logging String: {}", s); } else if let Ok(n) = input.downcast::<i32>() { println!("Logging i32: {}", n); } else { println!("Logging unknown type"); } } } fn main() { let processor: Box<dyn ObjectSafeProcessor> = Box::new(Logger); processor.process_any(Box::new("hello".to_string())); processor.process_any(Box::new(42i32)); }
맥락: 모듈형 게임 엔진은 시스템이 서로의 구체적인 유형에 대한 컴파일 시간 지식 없이 이벤트에 가입할 수 있도록 하는 EventBus 아키텍처를 필요로 했습니다. 초기 설계에서는 다양한 이벤트 유형을 위한 무비용 추상화를 활용하기 위해 System 특성이 일반적인 on_event<E: Event>(&mut self, event: E) 메서드를 정의했습니다.
문제: 이 설계는 이질적인 시스템을 **Vec<Box<dyn System>>**에 저장하는 것을 방해했습니다. System은 객체 안전하지 않았기 때문입니다. 엔진은 컴파일 시간에 이벤트 유형을 알 수 없는 DLL로부터 동적으로 로드된 플러그인을 지원할 필요가 있었으므로, 중앙 레지스트리에 대해 정적 분배가 비현실적이었습니다.
해결책 1: 폐쇄 열거형 분배. 모든 가능한 이벤트를 포함하는 포괄적인 GameEvent 열거형을 정의합니다. 장점: 런타임 오버헤드가 없고, 할당이 없으며, 컴파일 시간에 포괄적인 패턴 매칭을 수행합니다. 단점: 개방/폐쇄 원칙을 위반합니다; 플러그인에서 새로운 이벤트를 추가하려면 핵심 열거형을 수정하고 엔진을 재컴파일해야 하므로 바이너리 호환성이 깨집니다.
해결책 2: Any를 통한 유형 삭제. 특성을 on_event(&mut self, event: Box<dyn Any>)로 리팩토링하고 내부 라우팅을 위해 TypeId를 사용합니다. 장점: 알 수 없는 이벤트 유형으로 동적 플러그인을 완전히 지원하며, 객체 안전성을 유지하고, 레지스트리가 **Box<dyn System>**을 저장할 수 있습니다. 단점: 다운캐스팅으로 인한 런타임 오버헤드, 유형 불일치가 발생할 경우 패닉 가능성, 이벤트 처리를 위한 컴파일 시간 포괄성 검사 손실입니다.
해결책 3: 방문자 패턴. 이벤트가 특정 시스템 인터페이스를 방문하는 방법을 아는 이중 분배를 구현합니다. 장점: 다운캐스팅 없이 유형 안전하며, 런타임 유형 검사의 오버헤드가 없습니다. 단점: 이벤트와 시스템 간의 강한 결합, 상당한 보일러플레이트 코드, 기존 이벤트 정의를 수정하지 않고 새로운 시스템을 확장하는 어렵습니다.
선택된: **해결책 2 (유형 삭제)**가 플러그인 아키텍처가 이벤트 유형의 개방 세트를 요구하기 때문에 선택되었습니다. EventBus는 TypeId에서 핸들러 콜백으로의 매핑을 저장하며, 시스템은 **Box<dyn Any>**를 수신하고 이를 등록된 관심 유형으로 다운캐스팅합니다. 그 결과, 플러그인이 엔진 재컴파일 없이 사용자 정의 이벤트와 시스템을 정의할 수 있는 유연한 아키텍처가 구축되었으며, 이벤트 경계에서 다운캐스팅의 경미한 런타임 비용이 모듈성이라는 유익한 거래로 수용되었습니다.
왜 Box<dyn Any>가 일반 매개변수가 있음에도 불구하고 downcast_ref<T>()를 호출할 수 있게 하는가? 일반 메서드는 일반적으로 객체 안전성을 방해하는데?
downcast_ref 메서드는 Any 특성 자체 내에서 정의되지 않고, 오히려 impl dyn Any를 통해 비구조적인 유형 dyn Any에 대한 고유 메서드로 정의됩니다. Any 특성은 fn type_id(&self) -> TypeId만 요구하며, 이는 객체 안전합니다. 일반 downcast_ref는 별도로 구현되어 런타임에서 저장된 유형 식별자를 요청된 유형의 TypeId와 비교하기 위해 type_id()를 내부적으로 호출합니다. 이는 일반화 로직이 vtable 항목이 아닌 표준 라이브러리의 구현 코드에 위치하므로 vtable 제한을 우회합니다. vtable에서 안전 검사 수행을 위해 오직 구체적인 type_id 함수 포인터만 사용됩니다.
일반 메서드의 암묵적인 Sized 경계가 객체 안전성과 어떻게 상호작용하며, 왜 명시적인 where Self: Sized가 이를 복원하는가?
일반적으로 일반 메서드는 암묵적으로 Self: Sized를 요구합니다. 왜냐하면 일반화 과정이 함수 본체를 생성하기 위해 컴파일 시간에 유형의 크기를 알고 있어야 하기 때문입니다. 특성 객체(dyn Trait)는 크기가 없기 때문에 (!Sized) 이러한 메서드와 호환되지 않습니다. 일반 메서드에 where Self: Sized를 명시적으로 추가하면, 실제로 이는 vtable 요구 사항에서 제외됩니다(메서드는 특성 객체를 통해 배분 가능하지 않게 됨) 그리고 따라서 특성에 대한 객체 안전성을 복원합니다. 후보자들은 종종 이것을 메서드가 사용할 수 없는 것으로 착각하지만, 메서드는 구체적인 유형과 일반적인 컨텍스트에서는 여전히 호출 가능한 상태로 남아 있지만, 단지 특성 객체에서 동적 분배를 통해서는 호출할 수 없습니다.
특성의 연관 유형이 일반과 유사한 객체 안전성 문제를 야기할 수 있으며, 일반 메서드와 어떻게 다른가?
연관 유형은 self를 값으로 소모하거나 Self를 반환하는 메서드에 나타날 경우 객체 안전성 문제를 야기할 수 있습니다. 이는 특성 객체가 구체적인 유형을 삭제하여 호출 지점에서 연관 유형이 불확실하게 만듭니다. 그러나 일반 메서드와는 달리, 연관 유형은 특성 객체 유형 자체를 생성할 때 지정할 수 있습니다(예: Box<dyn Iterator<Item=u32>>), 효과적으로 해당 특정 연관 유형 인스턴화를 위한 vtable을 일반화합니다. 이는 기본적으로 일반 메서드와 다르며, 일반 메서드는 특성 객체 생성 시점에서 열거할 수 없는 개방된 유형 집합을 나타내는 반면, 연관 유형은 구현당 고정됩니다.