Lobsters

Unsizing unsized values

Unsize할 수 없는 값을 Unsize하기 — Go에는 되고 Rust에는 안 되는 것

Go의 interface{} 타입 스위치처럼 Rust에서도 dyn Any로 다운캐스트하려 하지만, &str 같은 unsized 타입은 dyn으로 강제 변환(coerce)할 수 없다는 문제를 다룹니다. 저자는 vtable의 내부 구조를 분석한 뒤, 런타임에 vtable을 수작업으로 구성해 &str을 &dyn Any로 변환하고 다시 downcast하는 실험을 보여줍니다.

본문

Go 투어의 interface{} 예제처럼 값의 타입을 런타임에 조회해 콘크리트 타입으로 다운캐스트하는 패턴을 Rust에서 재현하려는 시도에서 출발합니다. Rust에서는 dyn Any와 downcast_ref로 비슷하게 할 수 있지만, 제네릭을 쓸 수 없는 상황(dyn 호환 트레이트, extern 함수, fn 포인터로 전달 등)에서 &str을 그대로 넘기면 'the size for values of type str cannot be known at compilation time' 오류가 발생합니다. &&str로 한 번 더 감싸면 'static 라이프타임 요구 때문에 빌려온 문자열을 받을 수 없습니다.

왜 unsized 타입은 dyn으로 변환되지 않는가

저자는 컴파일러가 dyn Any용 포인터 메타데이터, 즉 &'static VTable을 합성해야 하기 때문이라고 설명합니다. vtable은 .rodata에 존재하며 #[repr(C)] 구조체처럼 네 항목을 담습니다: drop glue 함수(fn(*const ())), 값의 size, align, 그리고 type_id 함수입니다. str의 align(1)과 trivial drop, TypeId는 컴파일 타임에 결정할 수 있지만 size는 문자열 길이에 따라 달라지므로 정적으로 만들 수 없습니다. 이것이 현재 Rust에서 Sized 값만 dyn 객체로 강제 변환(coerce)될 수 있는 이유입니다. 또한 vtable 함수 포인터는 thin pointer만 받기 때문에 호출 시 원래의 메타데이터(문자열 길이)가 소실되는데, 이것이 바로 컴파일러가 합성하는 DispatchFromDyn 트레이트의 존재 이유라고 덧붙입니다.

런타임 vtable 수작업 구성

이 제약을 우회하기 위해 저자는 AnyVTable 구조체를 정의하고, 스택에 vtable을 보관할 Host 타입을 만듭니다. borrow 메서드에서 drop은 no-op, size는 s.len(), align은 1, type_id는 TypeId::of::<str>()로 채운 vtable을 MaybeUninit에 기록한 뒤, thin pointer와 vtable 포인터로 이루어진 fat pointer 표현을 transmute해 &dyn Any를 위조합니다. 반환된 참조의 라이프타임이 Host의 빌림에 묶이도록 설계해 vtable이 살아 있는 동안만 사용되게 했고, dyn<'a> Any처럼 vtable 전용 라이프타임을 표기할 수 있는 언어 확장이 있으면 더 안전하게 표현할 수 있을 것이라 제안합니다.

다운캐스트 복구와 Miri의 지적

이렇게 만든 dyn Any의 type_id는 str이므로 기존 downcast_ref::<T>로는 받을 수 없습니다. 해당 메서드는 T: Sized를 요구하는데, vtable 호출이 메타데이터를 버리기 때문에 일반적인 ?Sized 타입은 복구할 수 없기 때문입니다. 다만 str은 특별한 경우로, size_of_val(s)로 길이를 복구한 뒤 slice::from_raw_parts와 from_utf8_unchecked로 &str을 재구성할 수 있어, 전용 downcast_dyn_str 함수로 'str: hello is 5 bytes long' 출력에 성공합니다. Miri로 실행하면 'expected a vtable pointer'라는 UB 지적이 나오는데, 위조된 vtable이므로 예상된 결과라고 저자는 인정합니다.

언어 차원의 해법 제안

저자는 vtable 함수가 thin pointer 대신 완전한 fat pointer를 받도록 바꾸면, vtable 앞에 이전 메타데이터를 체이닝해 두고(str 길이 등) 포인터 연산으로 복구할 수 있다고 주장합니다. 컴파일 타임에 생성되는 vtable은 지금처럼 메타데이터를 벗기되, 호출 전이 아니라 vtable 함수 내부에서 벗기면 downcast_ref::<T: ?Sized>도 가능해진다는 것입니다. 일반화된 CoerceUnsized 가정 아래 Box<str>을 Box<dyn Any>로 변환하는 등의 실험을 담은 저장소(unsize-the-unsize)를 공개했지만, [T]에서 dyn Any로의 변환처럼 메타데이터 복구가 필요한 경우는 여전히 지원하지 못하며, 언젠가 &[T]를 &dyn Any로 coerce하고 네이티브로 downcast할 수 있기를 기대한다고 마무리합니다.

Lobsters 반응

  • 등록된 댓글이 없습니다 (점수 8, 댓글 0)

원문: HackMD / 번역·요약: Trawling