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

发布时间:2026/7/24 22:44:51
【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其它存储字符串的类型比如说OsStringfn frobnicateT: AsRefstr(s: T) - T第三个版本进一步放宽了契约现在这个函数的参数和返回值都只要求实现了AsRefstrtrait也就是能产生字符串引用的类型这三个函数都是接收字符串返回字符串只是契约不同。这三者没有优劣之分只有限制严格与否的区别。在设计API时要仔细规划契约否则改变契约会引起破坏。我们来看完整的代码例use std::borrow::Cow; fn frobnicateT: AsRefstr(s: T) - T { s } fn main() { let string: String String::from(example); let borrowed: str hello; let cow: Cowstr Cow::Borrowed(world); let result1: str frobnicate::str(string.as_ref()); let result2: str frobnicate::str(borrowed); let result3: Cowstr frobnicate(cow); println!(Result1: {:?}, result1); println!(Result2: {:?}, result2); println!(Result3: {:?}, result3); }不论是String、str还是Cowstr(本质上也是str)这个函数都能接收String类型要现使用as_ref方法转成str并返回值返回值也可以是不同类型。输出:Result1: example Result2: hello Result3: world2.5.3. 使用泛型参数(generic arguments)让接口更灵活我们可以通过泛型放宽对函数的要求。大多数情况下我们是值得使用泛型来代替具体类型的。使用泛型参数的例子看个例子fn print_as_strT: AsRefstr(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接受一个实现了AsRefstrtrait 的参数这个函数是泛型的它对 T 进行了泛型化这意味着它会对你使用它的每一种实现了AsRefstr的类型进行单态化详见 【Rust自学】10.2.6. 泛型代码的性能。例如如果你用一个String和一个str来调用它你就会在你的二进制文件中有两份函数的拷贝print_as_str::String和print_as_str::str传入不同类型时就会调用相应的函数注意进行了单态化的好处是避免了运行时的性能开销缺点是编译器会针对每一种传入的类型生成一份函数增大文件空间占用。如果你不想要二进制文件中有多份函数的拷贝就可以使用动态分发(dynamic dispatch)fn print_as_str(s: dyn AsRefstr) { println!({}, s.as_ref()); } fn main() { let s: String String::from(hello); let r: str world; print_as_str(s); // 传递一个类型为 dyn AsRefstr 的 trait 对象 print_as_str(r); // 传递一个类型为 dyn AsRefstr 的 trait 对象 }这个函数不再是泛型的它接受一个 trait 对象它可以是任何实现了AsRefstr的类型。这意味着它会在运行时使用动态分发来调用as_ref方法。并且你只会在你的二进制文件中有一份函数的拷贝。动态分发的内容详见 1.15.4. 动态分发(dynamic dispatch)。注意动态分发相比于单态化会有一定运行时的性能开销但是非常小。使用泛型参数别太极端使用泛型参数也不要太极端需要结合具体的情况。判断到底是使用泛型或是trait对象还是使用具体的类型取决于用户是否会合理且频繁地使用其他类型代替你最初选定的具体类型如果是那么参数定义为泛型更合适。单态化与动态分发的取舍问题单态化的好处是避免了运行时的性能开销缺点是编译器会针对每一种传入的类型生成一份函数增大文件空间占用。如果你担心生成的二进制文件过大那么你可以使用动态分发(dynamic dispatch)。虽然动态分发相比于单态化会有一定运行时的性能开销但是非常小。-在高性能的应用中在频繁调用的热循环中使用动态分发可能会成为一个致命的问题只有在简单的trait约束一个trait约束中才能使用动态分发例如T: AsRefstr或impl AsRefstr。而由于Rust无法为复杂的trait约束比如两个及以上的trait约束创建虚方法表(vtable详见 1.15.5. vtable)所以就无法使用动态分发。对于以引用方式获取的参数(dyn Trait不是Sized的需要使用宽指针来使用它们)可以使用动态分发代替泛型参数。看一下例子//泛型函数静态分发 fn processT(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 processT(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: Vecusize) { // ... } fn main() { let iter vec![1, 2, 3].into_iter(); foo(iter.collect()); }主函数中通过into_iter方法把Vector转化成IntoIterusize类型iter.collect()将iter从IntoIterusize又转化为了Vecusize在前面加了就能完全符合foo函数传入参数的类型要求。这里collect方法知道要把iter收集为一个Vecusize类型是因为编译器知道这里foo函数接收的是Vecusize类型好的下面我们使用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]但是有很多类型都满足这一条件比如Vecusize和[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 FromIteratori32 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::Vecusize()); }