【Rust中级教程】2.5. API设计原则之灵活性(flexible) Pt.1:代码的契约(Contract)、使用泛型参数(generic arguments)让接口更灵活

📅 2026/7/24 23:04:16 👁️ 阅读次数 📝 编程学习
【Rust中级教程】2.5. API设计原则之灵活性(flexible) Pt.1:代码的契约(Contract)、使用泛型参数(generic arguments)让接口更灵活

2.5. API设计原则之灵活性(flexible) Pt.1:代码的契约(Contract)、使用泛型参数(generic arguments)让接口更灵活

2.5.1. 代码的契约(Contract)

你写的代码里,不论是显式地还是隐式地,都包含了一种契约。

契约一共有两个方面:
- 契约是一种要求,它是代码使用的限制
- 契约是一种承诺,它是代码行为的保证

在设计API时,有这样一个经验:避免施加不必要的限制,只做能够兑现的承诺

为什么呢?
- 增加限制或取消承诺需要重大的语义版本更改,可能会导致其它代码出问题
- 在最开始设计API时放宽限制,之后再提供额外的承诺通常是向后兼容的

2.5.2. 限制(Restrictions)与承诺(Promises)

Rust中常见的限制的形式是:
- trait约束(trait bound)
- 参数类型(Argument Types)

承诺的常见形式是:
- trait的实现
- 返回类型


一些例子

我们看一个API历经三个版本的演化:

fn frobnicate(s: String) -> String
  • 第一个版本接收的参数是String类型,返回的也是String类型
  • 它的契约是:调用者进行内存分配(因为函数的参数和返回值都是拥有的,所以肯定会有内存分配),承诺返回是拥有的String
  • 这个函数的问题是以后无法更改为“无需内存分配的”函数(在不改签名的情况下),因为函数的参数和返回值都是拥有的
fn frobnicate(s: &str) -> Cow<'_, str>
  • 第二个版本稍微放宽了一些契约
  • 它的契约是:只接受字符串的引用,承诺返回字符串的引用一个拥有的String,也就是Cow这个类型(这个类型在 1.2.2. Rust的引用和指针 中有过介绍)
  • 这个版本还是有一点死板。比如说参数是&str,如果我传进来的是String,还必须先转化;还比如说Cow作为返回值,就不能返回除了String&str其它存储字符串的类型(比如说OsString
fn frobnicate<T: AsRef<str>>(s: T) -> T
  • 第三个版本进一步放宽了契约
  • 现在这个函数的参数和返回值都只要求实现了AsRef<str>trait,也就是能产生字符串引用的类型

这三个函数都是接收字符串,返回字符串,只是契约不同。这三者没有优劣之分,只有限制严格与否的区别。在设计API时要仔细规划契约,否则改变契约会引起破坏。

我们来看完整的代码例:

use std::borrow::Cow; fn frobnicate<T: AsRef<str>>(s: T) -> T { s } fn main() { let string: String = String::from("example"); let borrowed: &str = "hello"; let cow: Cow<str> = Cow::Borrowed("world"); let result1: &str = frobnicate::<&str>(string.as_ref()); let result2: &str = frobnicate::<&str>(borrowed); let result3: Cow<str> = frobnicate(cow); println!("Result1: {:?}", result1); println!("Result2: {:?}", result2); println!("Result3: {:?}", result3); }
  • 不论是String&str还是Cow<str>(本质上也是&str),这个函数都能接收(String类型要现使用as_ref方法转成&str)并返回值(返回值也可以是不同类型)。

输出:

Result1: "example" Result2: "hello" Result3: "world"

2.5.3. 使用泛型参数(generic arguments)让接口更灵活

我们可以通过泛型放宽对函数的要求。大多数情况下,我们是值得使用泛型来代替具体类型的。

使用泛型参数的例子

看个例子:

fn print_as_str<T: AsRef<str>>(s: T) { println!("{}", s.as_ref()); } fn main() { let s: String = String::from("hello"); let r: &str = "world"; print_as_str(s); // 调用 print_as_str::<String> print_as_str(r); // 调用 print_as_str::<&str> }
  • 函数print_as_str接受一个实现了AsRef<str>trait 的参数
  • 这个函数是泛型的,它对 T 进行了泛型化,这意味着它会对你使用它的每一种实现了AsRef<str>的类型进行单态化(详见 【Rust自学】10.2.6. 泛型代码的性能)。
    例如,如果你用一个String和一个&str来调用它,你就会在你的二进制文件中有两份函数的拷贝print_as_str::<String>print_as_str::<&str>,传入不同类型时就会调用相应的函数

注意:进行了单态化的好处是避免了运行时的性能开销,缺点是编译器会针对每一种传入的类型生成一份函数,增大文件空间占用

如果你不想要二进制文件中有多份函数的拷贝,就可以使用动态分发(dynamic dispatch)

fn print_as_str(s: &dyn AsRef<str>) { println!("{}", s.as_ref()); } fn main() { let s: String = String::from("hello"); let r: &str = "world"; print_as_str(&s); // 传递一个类型为 &dyn AsRef<str> 的 trait 对象 print_as_str(&r); // 传递一个类型为 &dyn AsRef<str> 的 trait 对象 }
  • 这个函数不再是泛型的,它接受一个 trait 对象,它可以是任何实现了AsRef<str>的类型。
  • 这意味着它会在运行时使用动态分发来调用as_ref方法。并且你只会在你的二进制文件中有一份函数的拷贝。

动态分发的内容详见 1.15.4. 动态分发(dynamic dispatch)。注意:动态分发相比于单态化会有一定运行时的性能开销,但是非常小


使用泛型参数别太极端

使用泛型参数也不要太极端,需要结合具体的情况。

判断到底是使用泛型(或是trait对象)还是使用具体的类型取决于用户是否会合理且频繁地使用其他类型代替你最初选定的具体类型,如果是,那么参数定义为泛型更合适。


单态化与动态分发的取舍问题

单态化的好处是避免了运行时的性能开销,缺点是编译器会针对每一种传入的类型生成一份函数,增大文件空间占用

如果你担心生成的二进制文件过大,那么你可以使用动态分发(dynamic dispatch)。虽然动态分发相比于单态化会有一定运行时的性能开销,但是非常小
-在高性能的应用中,在频繁调用的热循环中使用动态分发可能会成为一个致命的问题!

只有在简单的trait约束(一个trait约束)中才能使用动态分发,例如:T: AsRef<str>impl AsRef<str>。而由于Rust无法为复杂的trait约束(比如两个及以上的trait约束)创建虚方法表(vtable,详见 1.15.5. vtable),所以就无法使用动态分发。

对于以引用方式获取的参数(dyn Trait不是Sized的,需要使用宽指针来使用它们),可以使用动态分发代替泛型参数。

看一下例子:

//泛型函数,静态分发 fn process<T>(value: T) { println!("processing T"); }
  • 这是泛型函数的写法
// 动态分发 trait Processable { fn process(&self); } struct TypeA; impl Processable for TypeA { fn process(&self) { println!("processing TypeA"); } } fn process_trait_object(value: &dyn Processable) { value.process(); }
  • 这是动态分发的写法

那如果我把这两者都写在一起,该怎么判断那个是用的静态分发,哪个用的是动态呢:

//泛型函数,静态分发 fn process<T>(value: T) { println!("processing T"); } // 动态分发 trait Processable { fn process(&self); } struct TypeA; impl Processable for TypeA { fn process(&self) { println!("processing TypeA"); } } struct TypeB; impl Processable for TypeB { fn process(&self) { println!("processing TypeB"); } } fn process_trait_object(value: &dyn Processable) { value.process(); } fn main() { let a = TypeA; let b = TypeB; process_trait_object(&a); // 动态分发 process_trait_object(&b); // 动态分发 process(&a); // 静态分发 process(&b); // 静态分发 process(&a as &dyn Processable); // 静态分发 process(&b as &dyn Processable); // 静态分发 }
  • 使用了process_trait_object都是动态分发
  • 使用了process都是静态分发

最后的两个process语句有一点特殊。传进去的参数是&dyn Processable而不是具体的类型(因为使用了as &dyn Processable),编译器会把它视作为一种类型单态化,也就是编译器会把代码单态化为:

fn process(value: &dyn Processable) { println!("processing T"); }

这一部分仍然是静态分发,因为T = &dyn Processable是在编译时确定的。

在运行时,由于&dyn Processable这个胖指针背后没有具体的静态类型,若通过该 trait 对象调用方法,Rust会通过虚方法表查找运行时类型的实现。不过在这个process例子里,函数体根本没有对value调用任何方法,因此不会发生虚表分发;单态化仍然只是在编译期生成一份process::<&dyn Processable>特化。

但总的来说,我们仍然把对泛型process函数本身的调用看作静态分发。

输出:

processing TypeA processing TypeB processing T processing T processing T processing T

使用泛型参数时,调用者始终可以通过传递一个trait对象来选择动态分发(process(&a as &dyn Processable);)。

反过来不成立:如果你接受一个trait对象作为参数,那么调用者必须提供trait对象,而无法使用静态分发。


API应该如何考虑泛型参数?

我们可以从具体的类型开始编写接口,然后逐渐将它们转换为泛型。这样写是可行的,但不一定是向下兼容的。

看代码例:

fn foo(v: &Vec<usize>) { // ... } fn main() { let iter = vec![1, 2, 3].into_iter(); foo(&iter.collect()); }
  • 主函数中通过into_iter方法把Vector转化成IntoIter<usize>类型
  • iter.collect()iterIntoIter<usize>又转化为了Vec<usize>,在前面加了&就能完全符合foo函数传入参数的类型要求。
    这里collect方法知道要把iter收集为一个Vec<usize>类型是因为编译器知道这里foo函数接收的是&Vec<usize>类型

好的下面我们使用trait约束来写foo函数:

fn foo(v: impl AsRef<[usize]>) { // ... } fn main() { let iter = vec![1, 2, 3].into_iter(); foo(&iter.collect()); }
  • 这个程序不能通过编译,因为编译器不知道collect方法应该把iter收集为什么类型。编译器只知道foo的参数是AsRef<[usize]>,但是有很多类型都满足这一条件,比如Vec<usize>&[usize]

输出:

error[E0283]: type annotations needed --> src/main.rs:7:15 | 7 | foo(&iter.collect()); | ^^^^^^^ cannot infer type of the type parameter `B` declared on the method `collect` | = note: the type must implement `FromIterator<i32>` help: consider specifying the generic argument | 7 | foo(&iter.collect::<Vec<_>>()); | ++++++++++

为了解决这个问题,只能让调用者显式地写出collect方法把iter收集为什么类型:

fn foo(v: impl AsRef<[usize]>) { // ... } fn main() { let iter = vec![1, 2, 3].into_iter(); foo(&iter.collect::<Vec<usize>>()); }