Rust에서 크기 미지정 값을 dyn 객체로 강제 변환하고 Any를 통해 다시 다운캐스트할 때의 제약과 가능성을 살펴봅니다.
interface{}를 보여 주는 Go 둘러보기의 코드를 살펴보겠습니다: [1]
package main
import "fmt"
func doit(i interface{}) {
switch v := i.(type) {
case int:
fmt.Printf("Twice %v is %v\n", v, v*2)
case string:
fmt.Printf("%q is %v bytes long\n", v, len(v))
default:
fmt.Printf("I don't know about type %T!\n", v)
}
}
func main() {
doit(21)
doit("hello")
doit(true)
}
interface {} 값에 그 타입을 물어본 다음, 구체 타입으로 다운캐스트하여 값으로 어떤 작업을 할 수 있습니다. 순진하게 생각하면 Rust의 Any 트레이트로도 똑같이 할 수 있을 듯하지만, 미묘한 함정이 있습니다.
doit가 어떤 모습이어야 할지 생각해 봅시다. 값을 소비하고 싶지만, 단일 함수를 만들려면 제네릭 인수가 아니라 어떤 종류의 dyn 객체를 사용해야 합니다. 이는 제네릭 인수가 단순히 허용되지 않는 일부 영역에서 중요합니다. 예를 들어 트레이트가 dyn 호환이 되도록 하거나, doit가 extern 함수여야 하거나, 함수를 fn/Fn 객체로 전달해야 하는 경우입니다.
use std::any::Any;
fn doit(i: &dyn Any) {
if let Some(v) = i.downcast_ref::<i32>() {
println!("Twice {v} is {}", v*2);
} else if let Some(s) = i.downcast_ref::<String>() {
println!("String: {s} is {} bytes long", s.len());
} else if let Some(s) = i.downcast_ref::<&'static str>() {
println!("&'static str: {s} is {} bytes long", s.len());
} else {
println!("I don't know about type {:?}", i.type_id());
}
}
특히 위의 두 번째 호출인 doit("hello")는 문제가 됩니다.
pub fn main() {
doit(&21);
doit(&"hello".to_string());
doit(&true);
}
문자열을 할당하고 복사해야 하는데, 이는 정말 성가십니다! 대안으로 &'static str로 다운캐스트하고 경우를 추가할 수 있습니다.
if let Some(s) = i.downcast_ref::<&'static str>() {
println!("{s} is {} bytes long", s.len());
}
// ...
doit(&"hello");
하지만 'static 수명에 주목하세요. 이를 교묘하게 생략하고 단순히 &str로 쓸 수도 있습니다. 이 때문에 문자열을 누수시키거나 프로그램 실행 기간 내내 상수로 유지해야 합니다. 하지만 잠시 동안만 유효한 대여를 가진 &str로 문자열을 받는 것은 가능하지 않습니다.
fn doit_for_str(s: &str) {
// error[E0277]: the size for values of type `str` cannot be known at compilation time
doit(s);
// ^ doesn't have a size known at compile-time
// help: consider borrowing the value, since `&&str` can be coerced into `&(dyn Any + 'static)`
//
// but this suggestion does not work!
//
// error: lifetime may not live long enough
doit(&s);
// ^^ coercion requires that `'1` must outlive `'static`
}
그렇다면 무슨 일이 일어날까요? 강제 변환을 구현할 때 컴파일러는 dyn Any의 포인터 메타데이터를 합성해야 합니다. 이는 곧 살펴볼 만한 VTable의 &'static VTable입니다. 이 가상 함수 표는 .rodata에 존재하므로 컴파일러는 런타임의 값을 사용할 수 없고, 컴파일 시점에 강제 변환 원본 타입, 이 경우에는 str로부터 이를 도출해야 합니다. 다시 말해, 현재 Rust에서는 Sized 값만 dyn 객체로 강제 변환할 수 있습니다.
잠시 뒤에 의존하게 될 컴파일러 내부를 살펴보면, 가상 함수 표에는 다음 순서로 #[repr(C)] 구조체에 배치된 것처럼 네 가지 항목이 들어 있습니다.
fn(*const ()) 시그니처를 가집니다. str의 드롭은 자명하므로, 여기서는 아무 작업도 하지 않을 수 있습니다.1입니다.type_id 함수로, 다시 대략 fn(*const ()) -> TypeId 시그니처를 가집니다. 독자 여러분, 드롭 함수에서 슬쩍 넘겼을 수도 있지만 이 함수 포인터들은 원래 포인터 메타데이터를 받지 않습니다! 컴파일러가 <dyn Any as Any>::type_id(i) 호출을 합성할 때, 포인터 메타데이터를 사용해 이 함수 포인터를 얻은 뒤 메타데이터를 제거하고 얇은 포인터만 전달합니다. TypeId를 도출하기 위해 str을 검사할 필요는 없으므로, 여기서도 복잡한 문제를 피할 수 있습니다. 하지만 일반적으로 원래 포인터 메타데이터, 즉 문자열 길이는 사라집니다. [2]가상 함수 표 구성을 직접 해야 합니다. 실제 문자열 크기를 저장해야 하므로 런타임에 이를 구성해야 하고, 이 가상 함수 표를 스택에 저장할 장소가 필요합니다.
use std::any::TypeId;
use std::mem::MaybeUninit;
#[repr(C)]
struct AnyVTable {
drop: fn(*const()),
size: usize,
align: usize,
type_id: fn(*const()) -> TypeId,
}
pub struct Host {
vtable: MaybeUninit<AnyVTable>,
}
impl Host {
pub fn new() -> Self {
Host { vtable: MaybeUninit::uninit() }
}
pub fn borrow<'a>(&'a mut self, s: &'a str) -> &'a dyn Any {
todo!()
}
}
목표는 borrow 함수를 구현하는 것입니다. 이 함수에는 이미 작은 거짓말이 들어 있습니다. 반환된 참조는 'a 동안에만 사용할 수 있으므로 Host에 저장한 가상 함수 표 역시 여전히 대여되어 있음을 보장합니다. 하지만 약간의 나이틀리 마법을 쓰면, 수명을 잊어버리고 대여 이후에도 사용할 수 있는 포인터 메타데이터를 std::ptr::metadata()로 추출할 수 있습니다.
dyn 키워드 뒤에 가상 함수 표를 위한 수명을 추가하면 이 문제를 해결할 수 있다고 제안하고 싶습니다.
impl Host {
pub fn borrow<'a, 's>(&'s mut self, s: &'a str) -> &'a dyn<'s> Any { }
// ^^^^
// `dyn` could default like other lifetime to dyn<'static>, which would
// have the same meaning as now, in some contexts and introduce a fresh
// lifetime in other contexts (function signatures). I'm not certain this
// default is necessarily what we want or need.
// In this case, the above would omit the 's lifetime by the way lifetimes
// in return types are inferred to default to the lifetime of `&mut self`.
// Perhaps for compat, the default should always be 'static and the explicit
// form above is better.
}
하지만 곁길로 샜습니다. 잠시만 믿기 어려움을 접어 두세요. 이를 시도해 봅시다.
impl Host {
pub fn borrow<'a>(&'a mut self, s: &'a str) -> &'a dyn Any {
let vtable = self.vtable.write(AnyVTable {
drop: |_| (), // str has trivial drop
size: s.len(),
align: 1,
type_id: |_| TypeId::of::<str>(),
});
#[repr(C)]
struct FatPtr<'a> {
thin: *const (),
vtable: &'a AnyVTable,
}
let rep = FatPtr {
thin: s.as_ptr() as *const (),
vtable,
};
unsafe { std::mem::transmute(rep) }
}
}
fn doit_str(s: &str) {
let mut host = Host::new();
doit(host.borrow(s));
}
실행하면 다음 출력을 생성합니다.
I don't know about type TypeId(0xb7381ee5f3fdfc9d7fa709f37e151622)
그렇습니다. 이제 타입 식별자가 str의 것이므로 위의 어떤 경우도 작동하지 않습니다. 다시 다운캐스트할 방법이 필요합니다. downcast_ref 메서드를 시도하면 또 다른 장애물에 부딪힙니다. 핵심적으로 downcast_ref<T>는 T: Sized를 요구합니다. 이는 앞서 발견한 문제 때문입니다. 가상 함수 표를 통한 호출이 포인터 메타데이터를 버리므로, 일반적으로 이를 복구할 방법이 없고 다운캐스트는 포인터 메타데이터가 없는 타입, 즉 Sized 타입에서만 작동할 수 있습니다.
하지만 특히 str의 경우에는 실제로 필요한 모든 것을 복구할 수 있습니다. 구체적으로 제공된 메타데이터에서 문자열 길이를 복구할 수 있습니다.
fn downcast_dyn_str(s: &dyn Any) -> Option<&str> {
if s.type_id() != TypeId::of::<str>() {
return None;
}
// On nightly we can use std::str::from_raw_parts
Some(unsafe {
std::str::from_utf8_unchecked(
std::slice::from_raw_parts(
std::ptr::from_ref(s).cast(),
size_of_val(s),
)
)
})
}
// Add the case to doit:
if let Some(s) = downcast_dyn_str(i) {
println!("str: {s} is {} bytes long", s.len());
}
이제 마침내 다음 결과를 볼 수 있습니다.
doit_str("hello");
// str: hello is 5 bytes long
검증을 위해 코드를 Miri에서 실행하면 다음과 같이 나옵니다.
error: Undefined Behavior: constructing invalid value of type &dyn std::any::Any:
encountered 0x39858[alloc4896]<19017>, but expected a vtable pointer
축복받지 않은 불경한 가상 함수 표 포인터를 분명히 위조했으므로 이는 말이 됩니다. 예상된 결과입니다. 하지만 한 가지 작은 주의점과 함께, 컴파일러가 이 변환을 이론적으로는 우리 대신 수행할 수 있다고 주장하고 싶습니다. 포인터 메타데이터를 다시 살펴봐야 합니다.
가상 함수 표의 함수들이 완전한 넓은 포인터를 받는다면, 예를 들어 그 안에 이전 메타데이터를 넣을 수 있습니다. 우리의 사용 사례에서 Host가 단순히 AnyVTable을 포함하는 대신 다음과 같은 구조체를 포함한다고 가정해 봅시다.
struct MetaChained {
prev: usize, // str length in our case
vtable: AnyVTable,
}
이제 넓은 포인터는 여전히 구조체의 두 번째 필드인 AnyVTable에 대한 포인터를 포함하지만, 약간의 빠른 포인터 산술을 이용하면 이전 포인터 메타데이터를 복구할 수 있습니다. 컴파일 시점에 인스턴스화되는 가상 함수 표는 지금처럼 메타데이터를 제거할 수 있지만, 가상 함수 표 함수로 호출하기 전에 제거하는 대신 내부에서 제거하게 됩니다. 그러면 downcast_ref::<T: ?Sized>()도 가능해집니다.
이 모든 것의 의미를 판단하는 일은 여러분에게 맡기겠습니다. 위 내용을 일반화하여, 일반화된 T: CoerceUnsized<U>와 동등한 가정 아래 모든 T를 U로 강제 변환하는 Host를 위한 추가 실험 저장소가 있습니다.
특히 참조를 강제 변환하는 것처럼 Box<str>를 Box<dyn Any>로 강제 변환할 수 있습니다. 하지만 dyn<'a> Any와 같이 추가 수명을 가진 어노테이션을 허용하는 언어 변경이 없으므로, 실수로 오용하는 일을 막기 위해 이것은 unsafe로 선언됩니다.
넓은 포인터가 가상 함수 표 함수로 전달되지 않으면 이 실험은 [T]에서 dyn Any로의 변환 같은 다른 강제 변환을 지원할 수 없습니다. 현재는 드롭이 자명하고 슬라이스 메타데이터를 복구할 필요가 없는 T: Copy를 허용합니다.
메타데이터를 잃는 동일한 제약은 다른, 덜 자명한 트레이트로 확장하는 것도 막지만, 올바른 수신자 값을 복구하지 못하게 하는 유일한 요소입니다.
이것을 도입할 적임자가 누구인지 모르겠습니다. 하지만 미래의 어느 시점에는 적어도 &[T]를 &dyn Any로 강제 변환하고 다시 네이티브하게 downcast_ref할 수 있기를 바랍니다.
do는 예약어이므로 doit로 이름이 변경되었습니다.↩︎
관심 있는 분들을 위해 말하자면, 메타데이터 제거는 컴파일러가 합성하는 DispatchFromDyn 트레이트가 존재하는 이유입니다.↩︎