Bootstrap

改进rust代码的35种具体方法-类型(十九)-避免使用反射

上一篇文章


从其他语言来到Rust的程序员通常习惯于将反思作为工具箱中的工具。他们可能会浪费很多时间试图在Rust中实现基于反射的设计,却发现他们所尝试的事情只能做得不好,如果有的话。这个项目希望通过描述Rust在反思方面做什么和不做什么,以及可以使用什么来节省探索死胡同所浪费的时间。

反思是程序在运行时检查自己的能力。给定一个运行时的项目,它涵盖了以下问题:

  • 关于物品的类型,可以确定哪些信息?
  • 这些信息能做什么?

具有全面反思支持的编程语言对这些问题有广泛的答案。基于反射信息,带有反射的语言通常在运行时支持以下部分或全部内容:

  • 确定变量的类型
  • 探索其内容
  • 修改其字段
  • 调用其方法

具有这种反射支持水平的语言也往往是动态类型语言(例如Python、Ruby),但也有一些值得注意的静态类型语言也支持反射,特别是java和Go

Rust不支持这种类型的反射,这使得避免反射的建议在这个层面上很容易遵循——这是不可能的。对于来自支持充分反思的语言的程序员来说,这种缺席起初似乎是一个重大差距,但Rust的其他功能提供了解决许多相同问题的替代方法。

C++具有更有限的反射形式,称为运行时类型识别(RTTI)。typeid运算符为每个类型返回一个唯一标识符,用于多态类型的对象(大致:具有虚拟函数的类):

  • typeid:可以通过基类引用恢复引用的对象的具体类
  • dynamic_cast<T>:允许将基类引用转换为派生类,当这样做安全且正确时

Rust也不支持这种RTTI反思风格,延续了本项目建议易于遵循的主题。

Rust确实支持一些在std::any模块中提供类似功能的功能,但它们是有限的(以我们将探索的方式),因此除非没有其他替代方案,否则最好避免。

std::any的第一个反射状功能起初看起来像魔术——一种确定物品类型名称的方法。以下示例使用用户定义的tname()函数:

let x = 42u32;
let y = vec![3, 4, 2];
println!("x: {} = {}", tname(&x), x);
println!("y: {} = {:?}", tname(&y), y);

显示类型以及值:

x: u32 = 42
y: alloc::vec::Vec<i32> = [3, 4, 2]

该实现由std::any::type_name<T>库函数提供,该函数也是通用的。此函数只能访问编译时信息;没有代码运行来确定运行时的类型。之前中使用的特征对象类型说明了这一点:

let square = Square::new(1, 2, 2);
let draw: &dyn Draw = &square;
let shape: &dyn Shape = &square;

println!("square: {}", tname(&square));
println!("shape: {}", tname(&shape));
println!("draw: {}", tname(&draw));

只有特征对象的类型可用,而不是具体基础项目的类型(Square):

square: reflection::Square
shape: &dyn reflection::Shape
draw: &dyn reflection::Draw

type_name返回的字符串仅适用于诊断——它显然是一个“尽最大努力”助手,其内容可能会发生变化,并且可能不是唯一的——因此不要试图解析type_name结果。如果您需要全局唯一类型标识符,请改用TypeId

use std::any::TypeId;

fn type_id<T: 'static + ?Sized>(_v: &T) -> TypeId {
    TypeId::of::<T>()
}
println!("x has {:?}", type_id(&x));
println!("y has {:?}", type_id(&y));
x has TypeId { t: 18349839772473174998 }
y has TypeId { t: 2366424454607613595 }

输出对人类的帮助较小,但唯一性的保证意味着结果可以用于代码。然而,通常最好不要直接使用TypeId,而是使用std::any::Any特征,因为标准库具有处理Any实例的附加功能(如下所述)。

Any trait只有一个方法type_id(),它返回实现该trait的类型的TypeId值。你不能自己实现这个特性,因为Any已经为大多数任意类型T提供了一个全面的实现:

impl<T: 'static + ?Sized> Any for T {
    fn type_id(&self) -> TypeId {
        TypeId::of::<T>()
    }
}

总体实现并不涵盖所有类型TT: 'static生命周期绑定意味着,如果T包含任何具有非'static生命周期的引用,则TypeId不会为T实现。这是一个故意施加的限制,因为生命周期不是类型的一部分:TypeId::of::<&'a T>将与TypeId::of::<&'b T>相同,尽管生命周期不同,这增加了混淆和代码不健全的可能性。

第8项中回想一下,特征对象是一个胖指针,它包含指向底层项目的指针,以及指向特征实现vtable的指针。对于Any,vtable有一个条目,用于返回项目类型的type_id()方法,如下图所示:

let x_any: Box<dyn Any> = Box::new(42u64);
let y_any: Box<dyn Any> = Box::new(Square::new(3, 4, 3));

Any特征对象,每个都有指向具体项目和vtable的指针

除了几个间接性外,dyn Any特征对象实际上是原始指针和类型标识符的组合。这意味着标准库可以提供一些额外的通用方法,这些方法是为dyn Any特征对象定义的;这些方法是一些附加类型T的通用的:

  • is::<T>():指示特征对象的类型是否等于某些特定的其他类型T
  • downcast_ref::<T>():返回对具体类型T的引用,前提是特征对象的类型匹配T
  • downcast_mut::<T>():返回对具体类型T的可变引用,前提是特征对象的类型匹配T

观察Any特征只是近似反射功能:程序员选择(在编译时)明确构建一些东西(&dyn Any),以跟踪项目的编译时类型及其位置。只有当构建Any特征对象的开销已经发生时,(例如)向下回归原始类型的能力才有可能。

在相对较少的场景中,Rust具有与项目相关的不同编译时和运行时类型。其中最主要的是特征对象:混凝土类型Square的项目可以强制为该类型实现的特征的特征对象dyn Shape。这种胁迫从一个简单的指针(对象/项目)构建一个胖指针(对象+vtable)。

也从之前文章中回忆起,Rust的特征对象并不是真正面向对象的。aSquare不是Shape;只是Square实现了Shape的接口。特征绑定也是如此:特征绑定Shape: Draw并不意味着is-a;它只是意味着也实现,因为Shape的vtable包括Draw方法的条目。

对于一些简单的特质界限:

trait Draw: Debug {
    fn bounds(&self) -> Bounds;
}

trait Shape: Draw {
    fn render_in(&self, bounds: Bounds);
    fn render(&self) {
        self.render_in(overlap(SCREEN_BOUNDS, self.bounds()));
    }
}

等效特征对象:

let square = Square::new(1, 2, 2);
let draw: &dyn Draw = &square;
let shape: &dyn Shape = &square;

有一个带有箭头的布局(如图3-5所示;从第重复),使问题变得清晰:给定一个dyn Shape对象,没有立即构建dyn Draw特征对象的方法,因为没有办法回到impl Draw for Square的vtable——即使其内容的相关部分(Square::bounds()方法的地址)理论上可恢复的。(这在后续版本的Rust中可能会发生变化;请参阅此项目的最后一节。)

特征边界的特征对象,具有不同的vtable用于DrawShape

与上一张图表相比,很明显,显式构造的&dyn Any特征对象没有帮助。Any允许恢复基础项的原始具体类型,但没有运行时方式来查看它实现的属性,或访问可能允许创建特征对象的相关vtable。

那么,有什么是可用的呢?

要获得的主要工具是特征定义,这符合对其他语言的建议——有效的Java项目65建议“更喜欢界面,而不是反射”。如果代码需要依赖某个项目的某些行为的可用性,请将该行为编码为特征。即使所需的行为不能表示为一组方法签名,也请使用标记特征来指示对所需行为的合规性——这比(例如)自省类名称以检查特定前缀更安全、更有效。

期望特征对象的代码也可以与具有程序链接时不可用的后立代码的对象一起使用,因为它在运行时(通过dlopen(3)或等效的)已动态加载——这意味着通用的单态化是不可能的。

与此相关,反射有时也用于其他语言,以允许同一依赖库的多个不兼容版本同时加载到程序中,绕过只能有一个的链接约束。Rust不需要这个,Cargo已经处理了同一库的多个版本。

最后,宏——特别是derive宏——可用于自动生成在编译时理解项目类型的辅助代码,作为更高效、更类型安全的等同于在运行时解析项目内容的代码。之后文章会讨论了Rust的宏系统。

Rust未来版本中的升级

本项目的文本首次编写于2021年,并一直保持准确,直到该书准备在2024年出版——届时,Rust将添加一项新功能,以改变一些细节。

当U是T的超性状(性状T: U{…})之一时,这个新的“性状上转换”特性可以将一个性状对象dyn T转换为一个性状对象dyn U。在正式发布之前,该功能被锁定在#![feature(trait_upcasting)]上,预计将在Rust 1.76版本发布。

对于前面的示例,这意味着&dyn Shape特征对象现在可以转换为&dyn Draw特征对象,更接近Liskov替换is-a关系。允许这种转换会对vtable实现的内部细节产生连锁效应,这些细节可能会变得比上图所示的版本更复杂。

然而,此项目的中心点不受影响——Any特征都没有超特征,因此上投的能力不会增加其功能。

06ae6c1d4239b3e971f583be7e1fed1e.png

;