面试题答案
一键面试Rust动态派发机制基本原理
- trait对象:在Rust中,trait对象是一种指针类型,它允许通过统一的接口来操作不同类型的值。其形式为
&dyn Trait
(引用形式)或Box<dyn Trait>
(装箱形式)。trait对象在运行时才知道具体的类型。例如:
trait Animal {
fn speak(&self);
}
struct Dog;
impl Animal for Dog {
fn speak(&self) {
println!("Woof!");
}
}
struct Cat;
impl Animal for Cat {
fn speak(&self) {
println!("Meow!");
}
}
let animals: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in animals {
animal.speak();
}
- 动态派发原理:动态派发是基于trait对象实现的。当通过trait对象调用方法时,Rust在运行时根据对象的实际类型来决定调用哪个具体的方法实现。这一过程依赖于vtable(虚函数表)。每个实现了trait的类型都有一个对应的vtable,它是一个函数指针的列表,每个指针指向该类型对于trait中各个方法的具体实现。当创建一个trait对象时,它内部包含一个指向实际对象数据的指针和一个指向vtable的指针。在调用方法时,通过vtable找到对应方法的实际实现并调用。
与静态派发的主要区别
- 编译期 vs 运行期:
- 静态派发:在编译期就确定了调用的具体函数。Rust的泛型(
impl<T: Trait> for SomeType<T>
)就是静态派发的典型例子。编译器在编译时会针对每个具体的类型生成一份代码,调用的函数是明确的。例如:
- 静态派发:在编译期就确定了调用的具体函数。Rust的泛型(
trait Animal {
fn speak(&self);
}
struct Dog;
impl Animal for Dog {
fn speak(&self) {
println!("Woof!");
}
}
struct Cat;
impl Animal for Cat {
fn speak(&self) {
println!("Meow!");
}
}
fn make_sound<T: Animal>(animal: &T) {
animal.speak();
}
let dog = Dog;
let cat = Cat;
make_sound(&dog);
make_sound(&cat);
这里make_sound
函数的调用在编译期就知道调用Dog
或Cat
的speak
方法。
- 动态派发:在运行期才确定调用的具体函数。如上述trait对象的例子,只有在运行时,根据trait对象实际指向的类型(Dog
或Cat
),通过vtable来决定调用哪个speak
方法。
2. 代码大小:
- 静态派发:会导致代码膨胀,因为对于每个使用的具体类型,编译器都会生成一份重复的代码。例如上述make_sound
函数,对于Dog
和Cat
类型会分别生成对应的代码。
- 动态派发:代码大小相对紧凑,因为不管有多少类型实现了trait,vtable和实际的方法实现代码只有一份。不同的trait对象都共享这些代码,只是在运行时通过vtable来索引具体的实现。
3. 灵活性:
- 静态派发:在编译期确定函数调用,灵活性较差。如果要新增一种实现Animal
trait的类型,需要重新编译使用了泛型的代码。
- 动态派发:更灵活,运行时可以根据实际情况决定使用哪种类型的对象,新增实现trait的类型时,不需要重新编译使用trait对象的代码,只要在运行时能正确创建和使用新类型的trait对象即可。