Android程序员初学Rust-泛型

86 阅读8分钟

1.jpg

在上一篇文章中,我们讲到了 Rust 中的方法与特征,今天,我们进入另一个类型话题——泛型。

泛型(Generics),是编程语言中一种参数化类型机制,旨在通过抽象化实现代码复用与类型安全。

哦,对了,我们先来看上一文的答案:

trait Logger {
    fn log(&self, verbosity: u8, message: &str);
}

struct StderrLogger;

impl Logger for StderrLogger {
    fn log(&self, verbosity: u8, message: &str) {
        eprintln!("verbosity={verbosity}: {message}");
    }
}

struct VerbosityFilter {
    max_verbosity: u8,
    inner: StderrLogger,
}

impl Logger for VerbosityFilter {
    fn log(&self, verbosity: u8, message: &str) {
        if verbosity <= self.max_verbosity {
            self.inner.log(verbosity, message);
        }
    }
}

fn main() {
    let logger = VerbosityFilter { max_verbosity: 3, inner: StderrLogger };
    logger.log(5, "FYI");
    logger.log(2, "Uhoh");
}

// Output
// verbosity=2: Uhoh

泛型函数

Rust 支持泛型,这使你能够对算法或数据结构(例如排序或二叉树)中的类型进行抽象,从而大大的简化后续扩展流程。

先举一个简单的例子:

fn pick<T>(cond: bool, left: T, right: T) -> T {
    if cond {
        left
    } else {
        right
    }
}

fn main() {
    println!("picked a number: {:?}", pick(true, 222, 333));
    println!("picked a string: {:?}", pick(false, 'L', 'R'));
}

// Output
// picked a number: 222
// picked a string: 'R'

如果我们不使用泛型,那么我们应该会这样实现代码:

fn pick_i32(cond: bool, left: i32, right: i32) -> i32 {
    if cond {
        left
    } else {
        right
    }
}

fn pick_char(cond: bool, left: char, right: char) -> char {
    if cond {
        left
    } else {
        right
    }
}

我们会针对 i32char 类型实现两个函数,如果后续还需要使用 i64f32等其他类型,我们就需要为每一个类型编写一个 pick_xxx 函数。这,正是泛型的用武之地,避免了无意义的重复流程。

Rust 会根据参数和返回值的类型为 T 推断出一个类型。

上面的例子中,我们只展示了 i32char 类型,我们当然可以在此处使用任何类型,包括自定义类型:

struct Foo {
    val: u8,
}

pick(false, Foo { val: 7 }, Foo { val: 99 });

这与 C++ 模板类似,但 Rust 会立即对泛型函数进行部分编译,因此该函数对于所有符合约束的类型都必须有效。例如,尝试将 pick 修改为在 condfalse 时返回 left + right。即使仅使用针对整数的 pick 实例化,Rust 仍会认为这是无效的。而 C++ 则允许你这样做:

fn pick<T>(cond: bool, left: T, right: T) -> T {
    if cond {
        left
    } else {
        left + right
    }
}

pick(true, 222, 333) // compile error: cannot add `T` to `T`

你并没有表明 T 之间可以相加,所以在函数中进行相加是错误的。

Rust,泛型代码会根据调用点转换为非泛型代码。这是一种零开销抽象:你得到的结果与手动编写没有使用该抽象的数据结构时完全相同。也就是说,当我们编写泛型代码的时候,Rust 会为我们生成非泛型的版本,在所有调用的地方调用的是非泛型版本(这和 Java 这样的语言有很大的不同,Java 的泛型是会有运行时开销)。

Trait 边界

2.jpg

在使用泛型时,你通常希望要求类型实现某些特征,以便可以调用该特征的方法。 你可以使用 T: Trait 来做到这一点:

fn duplicate<T: Clone>(a: T) -> (T, T) {
    (a.clone(), a.clone())
}

struct NotCloneable;

fn main() {
    let foo = String::from("foo");
    let pair = duplicate(foo);
    println!("{pair:?}");
}

Rust 还支持另一种泛型边界的写法:

fn duplicate<T>(a: T) -> (T, T)
where
    T: Clone,
{
    (a.clone(), a.clone())
}

如果你有很多的泛型边界,那么这种 where 写法确实会很有用。欣赏一下 std::collections::HashMapget 的写法:

pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<&V>
    where
        K: Borrow<Q>,
        Q: Hash + Eq,
    {
        self.base.get(k)
    }

Rust 不直接支持传统意义上的函数重载,即允许同一个函数名有多个不同参数列表的版本。

fn duplicate(a: i32) -> (i32, i32) {
    (a.clone(), a.clone())
}

// 无法通过编译
fn duplicate(a: f64) -> (f64, f64) {
    (a.clone(), a.clone())
}

这样是行不通的。

所以对于原有的 duplicate,添加一个专门针对 a: u32duplicate 是无效的:

fn duplicate<T>(a: T) -> (T, T)
where
    T: Clone,
{
    (a.clone(), a.clone())
}

// 无法通过编译
fn duplicate(a: i32) -> (i32, i32) {
    (a.clone(), a.clone())
}

这样当然也不行。

泛型数据类型

3.jpg

你可以使用泛型来对具体的字段类型进行抽象。回到上一部分的练习:

pub trait Logger {   
    fn log(&self, verbosity: u8, message: &str);
}

struct StderrLogger;

impl Logger for StderrLogger {
    fn log(&self, verbosity: u8, message: &str) {
        eprintln!("verbosity={verbosity}: {message}");
    }
}

struct VerbosityFilter<L> {
    max_verbosity: u8,
    inner: L,
}

impl<L: Logger> Logger for VerbosityFilter<L> {
    fn log(&self, verbosity: u8, message: &str) {
        if verbosity <= self.max_verbosity {
            self.inner.log(verbosity, message);
        }
    }
}

fn main() {
    let logger = VerbosityFilter { max_verbosity: 3, inner: StderrLogger };
    logger.log(5, "FYI");
    logger.log(2, "Uhoh");
}

我们可以专门针对 L: Logger 类型编写实现,即,当 LLogger 的子类型的之后,才能有 Logger 的能力。

我们不会在 VerbosityFilter 类型本身设置 trait 约束。你也可以在那里设置约束,但在 Rust 中,通常我们只在 impl 块上设置 trait 约束。

泛型 Trait

4.jpg

特性(Traits)也可以像类型和函数一样是泛型的。特性的参数在使用时会被赋予具体的类型。例如,From<T> 特性用于定义类型转换:

// 该 Trait 是标准库的声明
pub trait From<T>: Sized {
    fn from(value: T) -> Self;
}
#[derive(Debug)]
struct Foo(String);

impl From<u32> for Foo {
    fn from(from: u32) -> Foo {
        Foo(format!("Converted from integer: {from}"))
    }
}

impl From<bool> for Foo {
    fn from(from: bool) -> Foo {
        Foo(format!("Converted from bool: {from}"))
    }
}

fn main() {
    let from_int = Foo::from(123);
    let from_bool = Foo::from(true);
    dbg!(from_int);
    dbg!(from_bool);
}

// Output
//
// [src/main.rs:19:5] from_int = Foo(
//     "Converted from integer: 123",
// )
// [src/main.rs:20:5] from_bool = Foo(
//     "Converted from bool: true",
// )

该特性的实现并不需要涵盖所有可能的类型参数。在这里,Foo::from("hello") 将无法编译,因为 Foo 并没有针对 From<&str> 的实现。

泛型特性将类型作为 “输入”,而关联类型是一种 “输出” 类型。一个特性可以针对不同的输入类型有多个实现。

实际上,Rust 要求对于任何类型 T,一个特性最多只有一个匹配的实现。与其他一些语言不同,Rust 没有用于选择 “最具体” 匹配的启发式方法。目前有关于添加此支持的工作,称为特化(specialization)。

impl Trait

5.jpg

与特性界限类似,impl trait 语法可用于函数参数和返回值中:


// 下面写法的语法糖:
// fn add_42_millions<T: Into<i32>>(x: T) -> i32 {
fn add_42_millions(x: impl Into<i32>) -> i32 {
    x.into() + 42_000_000
}

fn pair_of(x: u32) -> impl std::fmt::Debug {
    (x + 1, x - 1)
}

fn main() {
    let many = add_42_millions(42_i8);
    dbg!(many);
    let many_more = add_42_millions(10_000_000);
    dbg!(many_more);
    let debuggable = pair_of(27);
    dbg!(debuggable);
}

// Output
// 
// [src/main.rs:13:5] many = 42000042
// [src/main.rs:15:5] many_more = 52000000
// [src/main.rs:17:5] debuggable = (
//    28,
//    26,
// )

impl trait 允许你使用无法命名的类型。

impl trait 在不同位置的含义略有不同:

  • 对于参数,impl trait 就像是带有特性界限的匿名泛型参数。
  • 对于返回类型,它意味着返回类型是实现了该特性的某个具体类型,但无需指定该类型的名称。当你不想在公共 API 中暴露具体类型时,这会很有用。

在返回位置进行类型推导比较困难。返回 impl Foo 的函数会选择它返回的具体类型,但无需在源代码中写出。返回像 collect<B>() -> B 这样泛型类型的函数可以返回满足 B 的任何类型,调用者可能需要选择一种类型,比如通过 let x: Vec<i32> = foo.collect() 或使用 turbofish 语法 foo.collect::<Vec<i32>>()

dyn Trait

6.jpg

除了通过泛型使用特性进行静态分派之外,Rust 还支持通过特性对象将其用于类型擦除的动态分派:


struct Dog {
    name: String,
    age: i8,
}

struct Cat {
    lives: i8,
}

trait Pet {
    fn talk(&self) -> String;
}

impl Pet for Dog {
    fn talk(&self) -> String {
        format!("Woof, my name is {}!", self.name)
    }
}

impl Pet for Cat {
    fn talk(&self) -> String {
        String::from("Miau!")
    }
}

// 使用泛型——静态分派。
fn generic(pet: &impl Pet) {
    println!("Hello, who are you? {}", pet.talk());
}

// 使用类型擦除——动态分派。
fn dynamic(pet: &dyn Pet) {
    println!("Hello, who are you? {}", pet.talk());
}

fn main() {
    let cat = Cat { lives: 9 };
    let dog = Dog { name: String::from("Fido"), age: 5 };

    generic(&cat);
    generic(&dog);

    dynamic(&cat);
    dynamic(&dog);
}

// Output
// Hello, who are you? Miau!
// Hello, who are you? Woof, my name is Fido!
// Hello, who are you? Miau!
// Hello, who are you? Woof, my name is Fido!

泛型(包括 impl trait)使用单态化(monomorphization)为泛型实例化的每种不同类型创建函数的专用实例。这意味着在泛型函数中调用特性方法仍然使用静态分派,因为编译器拥有完整的类型信息,并且可以解析要使用哪种类型的特性实现。

而使用 dyn trait 时,它会通过虚方法表(vtable)使用动态分派。这意味着,无论传入何种类型的 Pet,都使用 fn dynamic 的单一版本。

在运行时,&dyn Pet 被表示为一个 “胖指针”,即一对指针:一个指针指向实现 Pet 的具体对象,另一个指针指向该类型的 trait 实现的虚函数表(vtable)。当在 &dyn Pet 上调用 talk 方法时,编译器会在虚函数表中查找 talk 的函数指针,然后调用该函数,并将指向 DogCat 的指针传递给该函数。编译器在执行此操作时无需知道 Pet 的具体类型。

dyn trait 被视为 “类型擦除”,因为我们在编译时不再知道具体类型是什么。

练习

这是一个很简短的练习,你将使用 Ordering trait 实现一个通用的 min 函数,用于确定两个值中的最小值:

// TODO: 实现 `min` 函数

#[test]
fn integers() {
    assert_eq!(min(0, 10), 0);
    assert_eq!(min(500, 123), 123);
}

#[test]
fn chars() {
    assert_eq!(min('a', 'z'), 'a');
    assert_eq!(min('7', '1'), '1');
}

#[test]
fn strings() {
    assert_eq!(min("hello", "goodbye"), "goodbye");
    assert_eq!(min("bat", "armadillo"), "armadillo");
}