face
Generated by orly.nanmu.me

Rust 十万个为什么

这里整理了一些 Rust 独特的特性,并且尝试解释为什么需要这些特性。

这里不回答 “Why Rust?” 而是希望汇集一些对 Rust 的思考。

声明

这里的思考仅仅是个人的想法,不代表 Rust 官方的意见,更不代表 Rust 社区的意见。

权威请参见 Rust RFC Book 以及咨询 Rust 核心开发者。

贡献

Github Repo

如果想贡献到这个项目,你可以:

  • 写文章:请尽量有理有据。写好之后提 PR,通过之后就可以署名发表。
  • 美化网站:目前我还是在用简陋的 mdbook,但是如果有人觉得不好看,可以帮忙使用更先进的前端框架美化。
  • 提 Feature Request:觉得有意思的问题也可以通过提 Feature Request 的形式提问,但是也建议自己尝试搜索/探索/理解之后写文章。
  • 翻译:目前仅有中文版,如果你觉得有帮助并且愿意贡献,也可以帮忙翻译成别的语言。
  • 邀请大牛写文章
  • 分享

工程特性

Rust 有很多奇特的工程特性,帮助开发者更好地写代码,同时尽量符合高质量的工程实践要求。

为什么要有 Associated Type?

背景

什么是 Associated Type?

Associated Type 是泛型的一个子概念。在 Rust 里 Associated Type 和 Trait 绑定在一起,指定输出类型 (output type)。

Rust Book 里的一个例子是:


#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--
}

这样写,Counter里的Self::Item就指代的是u32

而更详细的介绍请看Rust Book 对应章节

答案

短答案是:

  • 带有 Associated Type 的 Trait 只能被一个类型impl 一次,所以可以避免一个类型有多个impl
  • Associated Type 可以当做 Output Type
  • Associated Type 带来工程上的便利

如果短答案总觉得是隔靴搔痒,那么我们需要问以下子问题:

  1. 既然 Associated Type 是泛型的子概念,那么 Associated Type 和 Rust 泛型有什么不同?
  2. 什么是 Input Type 和 Output Type?
  3. 为什么带有 Associated Type 的 Trait 只能被一个类型impl 一次?
  4. 普通泛型可以代替 Associated Type 吗?如果可以,那为什么还要 Associated Type?

对于第一个问题,它们的不同主要有两点:

  • 普通泛型可以用于 Trait, Struct和函数,但是 Associated Type 只能与 Trait 绑定。

  • 带有 Associated Type 的 Trait 只能被一个类型impl 一次,但是带有普通泛型的 Trait 可以有多个impl,例如

    
    #![allow(unused)]
    fn main() {
    // 普通泛型 + Trait
    pub trait GiveMeSomething<T: Clone> {
        fn get_something(&self) -> T;
    }
    
    // Associated Type + Trait
    pub trait GiveMeData {
        type Data;
        fn get_some_data(&self) -> Self::Data;
    }
    
    // 普通泛型 + Struct
    pub struct Something<T: Clone> {
        data: T,
    }
    
    // 一个 struct 可以有多个 GiveMeSomething<T> 的 impl
    impl<T: Clone> GiveMeSomething<u8> for Something<T> {
        fn get_something(&self) -> u8 {
            1
        }
    }
    
    impl<T: Clone> GiveMeSomething<i32> for Something<T> {
        fn get_something(&self) -> i32 {
            -1
        }
    }
    
    
    impl<T: Clone> Something<T> {
        // 普通泛型 + 函数
        pub fn get_data(&self) -> T {
            self.data.clone()
        }
    }
    
    // 一个 struct 只能有一个 GiveMeData 的 impl
    impl GiveMeData for Something<u8> {
        type Data = u8;
    
        fn get_some_data(&self) -> Self::Data {
            self.data
        }
    }
    }
    

如果我们把泛型的类型(也就是<T>中的T)也作为输入的话,那么我们可以把get_data()的输入看成是(T, &self),把它的输出看成是(T, T),第一个T是类型,第二个T是指属于这个类型的值。

假如说我们有一个Something<u8>的示例,它存着data = 1,那么它的get_data()的输入就是(u8, &self),输出就是(u8, 1)

那么,很简单的,输入的类型就是 Input Type,输出的类型就是 Output Type,也就是,出现在参数的类型是 Input Type,出现在返回值的类型是 Output Type。

至于第三个问题,为什么带有 Associated Type 的 Trait 只能被一个类型impl 一次,我们需要一些实际的例子。典型的例子就是上面的pub trait Iterator。对于Iterator,很自然的,我们想要遍历某个实例的所有的值,那么这些值只有一个类型,所以对于一个类型来说,这个 Trait 只能被impl一次。

第四个问题和第三个问题紧密相关。实际上,如果我们能人为地保证带有普通泛型的 Trait 只被一个类型 impl 一次,那么我们完全可以用普通泛型代替 Associated Type。那么为什么我们还要发明一个 Associated Type 呢?

第一个理由是最简单的,你不能人为地保证带有普通泛型的 Trait 只被一个类型 impl 一次。万一一个实习生不懂,为了方便就随手加多了一个impl呢?假如说这个乌龙发生在Iterator这个 Trait 上面,而恰好你也在用着它(例如for item in my_custom_list {}),那么rustc会抱怨不知道用哪个impl,然后一个编译错误就产生了。如果你想rustc帮你检查并保证这个带有普通泛型的 Trait 只被一个类型 impl 一次,那么这就实际上变成了 Associated Type 了。

更多的理由我们需要举个例子。Rust RFC Book 里的例子已经非常好了,所以我在这里简要翻译一下,并且补充一些内容。

假设我们需要建图,并且表达成一个 Trait Graph。如果仅仅使用普通泛型,我们可以写成


#![allow(unused)]
fn main() {
// N 和 E 是节点和边的类型
trait Graph<N, E> {
    fn has_edge(&self, &N, &N) -> bool;
    ...
}
}

但是如果这样写的话,一个类型可以有多个Graphimpl,并且每个implNE 都不一样。这就有点诡异了,因为对于一个实际的图,它的节点和边的类型是唯一确定的。并且很自然地,NE这两个类型就应该和Graph绑定在一起,有个从属的关系。

而且,假如说我们要写个函数,计算两节点间的距离,使用普通泛型,我们要这样写


#![allow(unused)]
fn main() {
fn distance<N, E, G: Graph<N, E>>(graph: &G, start: &N, end: &N) -> uint { ... }
}

前面泛型的部分就会有一长串,就是<N, E, G<N, E>>,如果一个 Trait 有好几个泛型,那么情况就更糟了,可能<>里的内容就要写一行。

而如果我们用 Associated Type,我们可以这样写


#![allow(unused)]
fn main() {
trait Graph {
    type N;
    type E;
    fn has_edge(&self, &N, &N) -> bool;
}
}

然后计算距离的函数就可以写成


#![allow(unused)]
fn main() {
fn distance<G: Graph>(graph: &G, start: &G::N, end: &G::N) -> uint { ... }
}

这样代码就变得更加简洁了。

另外,对比两个distance函数,第一个函数根本就没用到类型E,但是因为语法要求必须要写上,而第二个用G::N就行,可以不用写用不上的G::E

除了少写用不上的代码这个好处之外,我们写代码的时候还有更多灵活性和可扩展性。假如说我们要拓展我们的Graph,变成


#![allow(unused)]
fn main() {
trait Graph{
    type N;
    type E;
    type A;
    type B;
    type C;
    fn has_edge(&self, &N, &N) -> bool;
}
}

如果使用普通泛型的话,第一个distance函数就不可避免地要加上A, B, C这些泛型标识,否则编译就会出问题,但是使用 Associated Type 的话,第二个distance函数根本就不用动。

综上,Rust 的 Associated Type 不是普通泛型的语法糖,而是经过深思熟虑的、能够解决实际问题并且优化代码的实现。

相关链接及参考链接

  • Associated Type 的 Rust RFC,里面有更加详细的解释,并且还列举了Associated Type 另外两个优点(虽然对应用开发者影响不大)
  • Rust Forum 里的相关讨论帖子
  • Rust Book 里的相关章节

设计选择

为什么 Rust 是过程式编程语言中的清流(或者奇葩)?为什么 Rust 被称为是十多年的工程实践的结晶?

它的设计取舍会告诉我们(部分)答案。

为什么没有继承?

背景

继承常常用于面向对象的编程语言之中,通过继承,一个子类可以获得父类的部分(或全部)的属性、数据和方法。继承又可以分成单继承和多继承。例如,在 Java 中,子类只能有一个父类,所以叫单继承,而在 C++等语言中,一个子类可以有多个父类,所以叫多继承。

答案

为什么 Rust 没有继承?

太长不看版就是(从Rust Book摘录):

  • 继承有时候会共享过多东西(如用不上的属性、数据、方法等)。有时候子类并不需要父类的所有东西(人也是一样)。
  • 继承有一些问题难以解决。
  • Rust 不需要继承也可以达到类似继承的目的(代码共享、多态)。

长答案就是短答案的完全展开,但是与其问为什么不要 (“Why not?”),我们先来问一下自己,为什么要(“Why?”)。

所以,为什么要有继承?

有的人说,这是面向对象编程(OOP)的特性。但是,在 Erich Gamma, Richard Helm, Ralph Johnson 和 John Vlissides (AKA gang of four) 的书《Design Patterns: Elements of Reusable Object-Oriented Software》中,他们这样定义OOP:

Object-oriented programs are made up of objects. An object packages both data and the procedures that operate on that data. The procedures are typically called methods or operations.

他们压根没提到继承。(如果有人质疑权威性的话,可以查查 Gang of Four)

引用 Rust Book 的观点,继承的目的只有两点:

  1. 代码复用
  2. 多态

其实多态在一定程度上也可以说是为了代码复用,所以终极的原因还是代码可复用。所以这也表现在继承的具体实现上了。通过继承,程序员可以不用写父类已经有的方法,也可以不用声明父类已经有的属性,这就让代码更加能被复用了。

那么看起来继承全是好处,所以程序员和继承 forever and forever 100 years!?

这时候学过软件工程的同学应该会搬出一个词来反驳了,那就是“耦合”(Coupling)。“耦合”说的是组件之间的依赖程度,那么,如果把父类和子类看成是两个组件,那么他们之间的耦合应该是最高的,也就是依赖程度最高。而高耦合一般是认为不好的,就不展开讲了。举个不恰当的例子,小时候买东西都要爸妈出钱,而爸妈 diss 你到了最后总是会回到一句话“你吃我的穿我的还这么不听话“,而就算占理的是你,也只能认怂。这就是高耦合的坏处🐶

所以顺着高耦合的坏处,我们来到了继承机制的坏处,也就是为什么不(”Why not“)。

首先,第一个理由就是高耦合。引申出来的问题就是,有时候子类并不需要父类的所有东西,但是继承一股脑都把父类的东西塞给子类了。大家都知道,没有免费的午餐,所以继承的东西也不是越多越好。例如,继承方法,万一子类需要写一个同名方法,但是有着不同的输入输出怎么办?有人说可以用重载,但是复杂度又增加了。

第二个,继承方法还有一些难堪的情形不能解决。例如,经典的圆-椭圆问题(Circle-Ellipse Problem)。大家都知道,圆是椭圆,那么,按照继承的思路,圆就应该是椭圆的子类。同时我们知道,无论椭圆怎么在主轴上伸缩(除了零变换),它依旧是个椭圆。那么椭圆就应该有一个方法叫

Ellipse::stretch(stretch_principal_direction0:f32, stretch_principal_direction1:f32)

然而,如果圆也继承了这个方法,那么就会有圆伸缩了之后还是圆的奇怪情形。这在实践中微不足道,抛个 Exception 就完了,但是这是使用继承带来的理论上的根本问题之一。

第三个理由是,如果一个编程语言允许多继承,那么还会有菱形继承问题,这里就不展开了。

最后一个理由是,当继承的层级过多,记忆、管理继承下来的属性和方法就非常麻烦,使得代码更难读懂,更难理解。或许有人也曾遇到过我这样的情况,接手或学习别人的代码的时候,看到别人在代码里用了一个属性或者方法,但是在当前类中找不到,最后发现是定义在了父类中(甚至父类的父类,甚至曾祖父类中)。而如果想要定义一个子类,写一个方法,没准就是在重复造轮子,别人早早就在你继承的父类(或者祖父类,曾祖父类)中写好了,只是用了一个不同的名字。

另外一个,值得反思的是,为什么继承就一定会把属性和方法都继承了?属性是一个类需要的数据,方法是一个类的行为,数据和行为可能有关也可能无关,为什么要捆绑一起作为继承大礼包?

所以,基于上面的理由,Rust 选择完全抛开继承的概念,重新审视如何更好地复用代码。

首先,对于代码复用,Rust Book 中提到可以使用trait,因为trait可以有默认实现,如果不想重新写代码,那么就使用默认实现就好了。另外trait还可以有Super Trait(介绍见 Rust Book - super trait),就可以定义trait之间的层级。有人说,那 Super Trait 不就是像继承了吗?这个问题我们在附录中讨论。

对于多态,Rust Book 里提出可以使用泛型加 Trait Bound。

To many people, polymorphism is synonymous with inheritance. But it’s actually a more general concept that refers to code that can work with data of multiple types. For inheritance, those types are generally subclasses.

Rust instead uses generics to abstract over different possible types and trait bounds to impose constraints on what those types must provide. This is sometimes called bounded parametric polymorphism.

也就是多态可以由泛型加上 Trait 的限制来实现。

需要补充的是,Rust Book 说的多态更多的是静态多态,就是编译时可以实现的多态。而如果想要实现像 Java 一样的多态(例如,向一个 Vector 里添加有着共同父类的不同子类的对象),我们需要运行时多态。而在 Rust 中,”运行时多态“是由 Dynamic Dispatch 和 Trait Object 实现的。

这样做,Rust 降低了继承的高耦合。一个类型实现一个trait并不会把不需要的方法也继承下来,而因为trait不带属性,所以也不会继承属性。

而没有了继承,菱形继承的数据问题当然也不复存在了。而 Super Trait 带来的方法上的菱形继承问题我们也会在附录讨论。

对于圆-椭圆问题,在 Rust 中struct Circle可以包含一个Ellipse的属性,但是可以选择不把一些椭圆的方法暴露出来,这就解决了圆-椭圆问题。就像这样:


#![allow(unused)]
fn main() {
pub struct Ellipse{
    // zip
}
impl Ellipse{
    pub fn area(&self){
        // zip
    }
    pub fn stretch(&mut self){
        // zip
    }
}
pub struct Circle{
    e: Ellipse
}

impl Circle{
    pub fn area(&self){
        self.e.area()
    }
}
}

事实上,Rust 的编程建模模式更像是组合(Composition) 而不是继承(Inheritance)。继承使用的是隐式”复制代码“,而在这个例子中,程序员要显式地在Circle::area中调用Ellipse::area。虽然多余的代码写多了一点点,但是可以清晰地指出依赖层级。

这样,就算 Composition 的层级再多,看代码的时候总能追踪回原来的实现,就像是上面Circle::areaEllipse::area一样。虽然层级过多时,难以管理代码的问题依旧存在,但是至少可以减缓一点。

综上,Rust 抛弃了继承,选择了结合trait、泛型和Trait Bound 的组合(Composition)模式,解决了大部分继承带来的问题,同时尽量不引入太多的新问题、新限制,提高灵活度的同时也尽量保持了代码的可复用性。

有人说,有道理,但是我就想要继承,我连上面Circle::area这样的委托函数(delegate)都不想写,那可以怎么办?参见附录中的Deref Anti-pattern。

相关链接

附录

Super Trait 和继承有什么不同?

  • Super Trait 还是属于 Trait,所以它不能定义属性,所以也不会”继承“属性。

  • Super Trait 定义的是行为,也就是一个类型应该有的方法,由此形成的 Trait 的层级是行为上的层级。而继承不仅会继承方法,还会继承属性,引入了更多耦合,还会引入菱形继承中的数据问题。

  • 虽然 super trait 同样会有菱形继承问题,但是不像数据的问题那样难以解决,方法上的冲突用Fully qualified syntax就可以解决。例如:

    pub trait Say {
        fn say_something(&self) {
            println!("yummy")
        }
    }
    
    pub trait EatApple: Eat + Say {
        fn eat_something(&self) {
            println!("Eat something, maybe Apple?");
            self.say_something()
        }
        fn eat_apple(&self) {
            println!("Eat Apple")
        }
    }
    
    pub trait EatPear: Eat + Say {
        fn eat_something(&self) {
            println!("Eat something, maybe Pear?");
            self.say_something()
        }
        fn eat_pear(&self) {
            println!("Eat pear")
        }
    }
    
    struct People;
    
    impl Eat for People {}
    
    impl Say for People {}
    
    impl EatPear for People {}
    
    fn main() {
        let p = People;
        Eat::eat_something(&p); // OK
        p.eat_something() // compile error
    }
    

Deref Anti-pattern

参考自 Rust Design Patterns

太长不看版:

Rust 中Deref是这样定义的,一般用来实现智能指针,比如说Box<T>就可以实现Deref并且让Target=T


#![allow(unused)]
fn main() {
pub trait Deref {
    type Target: ?Sized;
    fn deref(&self) -> &Self::Target;
}
}

但是既然可以随便指定Target,那么我们可以滥用这个来模拟继承,像这样(例子来自于参考):

struct Foo {}

impl Foo {
    fn m(&self) {
        //..
    }
}

struct Bar {
    f: Foo,
}

impl Deref for Bar {
    type Target = Foo;
    fn deref(&self) -> &Foo {
        &self.f
    }
}

fn main() {
    let b = Bar { f: Foo {} };
    b.m();
}

那么b.m()就实际上是b.deref().m(),从而实际上实现了继承方法的作用。

但是这种用法属于滥用,强烈建议不要使用。在一个 crate 里,implstruct 定义块常常可以分开,这通常问题不大,因为 IDE 可以帮忙搜索出一个类型的方法定义在哪个 impl 块里。然而,Deref 是一个微妙的语法糖,IDE 不一定能从 b.m() 帮忙找到 Foo::m ,而你可以把 struct Foostruct Bar 的定义分开放并且把 impl Deref for Bar 代码块放在项目里十万八千里外的地方。相信没有人想遇上这样的状况,所以己所不欲勿施于人,Love and Peace❤️

为什么没有全局变量?

Coming……

为什么没有重载?

背景

我们在说重载(Overloading)的时候到底在说什么?重载指的是函数重载,就是多个函数签名(Function Signature)不一样的函数可以使用同一个函数名。这个与方法重写(Method Overriding)在概念上有本质的不同。至于函数签名是什么,各个语言里重载的要求是什么,就留给感兴趣的读者自行搜索。在这里我们讨论最简单的函数重载,也就是下面的形式:

int plus(int x, int y) {  // 1
  return x + y;
}

float plus(float x, float y) { // 2
  return x + y;
}

float plus(float x, float y, float z) { // 3
  return x + y;
}

对比 1 和 2,我们发现同名函数可以有不同的参数类型和返回值类型;对比 2 和 3,我们发现同名函数可以有不同的参数数量。

枚举的话,我们可以发现会用到重载的情形一共有 6 种。比如说 1 和 2 就属于情形 E,而 2 和 3 属于情形 B。

分类参数类型参数数量返回值类型
A相同相同不同
B相同不同相同
C相同不同不同
D不同相同相同
E不同相同不同
F不同不同相同

答案

太长不看版

  • Rust 的设计哲学之一是 Be Explicit,翻译过来就是不要藏着掖着的,越直白越好。重载违背了这一条设计哲学,所以没有引入重载。
  • 一些情况下,重载的功能可以使用泛型替代,而 Rust 的 Trait 和泛型系统让去掉泛型变得容易而且更符合直觉。
  • 重载可以变相实现函数参数默认值,Rust 没有引入参数默认值的特性,所以也不会有重载。
  • 重载不是百利无一害的,去掉重载可以让代码变得更加容易理解。
  • 重载会让 Rust 的自动类型推断出现小问题,去掉重载可以规避这些问题。

长答案

如果读者是从 Java 或者 C++之类支持函数或者方法重载的语言迁移过来的,有可能对重载已经习以为常了,甚至觉得理所当然,以致于没有思考过为什么需要重载。

所以,让我们扪心自问,为什么需要重载?

一个最简单的答案是,因为方便啊!像上面的 plus,我想写一个接受两个整数的版本,也写一个接受两个浮点数的版本,如果不能重载的话,那我就要写一个plus_intplus_float,我就要记两个函数名,即使它们在本质上做的是一样的事。但是按照奥卡姆剃刀准则,“方便”并不是必要,所以 Rust 设计者完全可以“任性”去掉这个特性。

如果剃刀这个理由还觉得差点意思,那么我们来想想 plus 到底想干嘛?plus 有两个(双参数)实现,一个接受整数,一个接受浮点数,那么浮点数和整数的共同点是什么?它们都是数(废话)!那plus 的意思非常直接,就是把两个数加在一起,所以我们在 Rust 里可以这样写:


#![allow(unused)]
fn main() {
use num::Num;

fn plus<T: Num>(a: T, b: T) -> T {
    a + b
}
}

使用泛型和 Trait Bound,我们完全可以精准表示出原来想要表达的意思,甚至还更加精确了。因为这里plus的意思是把两个相同类型的相加,并且返回一个值,这个值的类型跟参数是一样的。如果是比较熟悉 Rust 类型的老油条应该还会注意到加号。加号在 Rust 里也是一个 Trait,通过实现这个 Trait 来重载加法运算符。

那么一个附加题就是,为什么编译器可以放行呢?也就是,我这里定义了T: Num,只是说明了T 是数字类型,但是好像没有告诉编译器T是可加的(实现了Add这个 Trait),但是为什么编译器知道 T 是可加的,并且加出来的结果是同一个类型的呢?

回归正题,举这个例子的意图是用来说明一个观点:

Logically if we assume that function name is describing a behavior, there is no reason, that the same behavior may be performed on two completely independent, not similar types - which is what overloading actually is. If there is a situation, where operation may be performed on set of types, it‘s clear, that they have something in common, so this commonness should be extracted to trait, and then generalizing function over trait is straight way to go.

– Hashedone on Rust Forum

翻译过来就是:假如我们同意函数是用来描述一个行为的,那么(在重载的情况里)一个行为竟然可以作用于完全不相干的类型,这完全没有道理。那如果这个行为可以作用于一些类型上,那么就说明这些类型是有共通之处的。那在 Rust 里表达不同类型的共通之处的方法就是 Trait,那么合理的办法就是使用泛化函数(带泛型的函数),就像 Rust 的 plus 那样做。这样,我们就解决了表格中的 D 和 E 两种情况。

而至于情况 A,两个函数输入参数相同,输入参数数量相同,只有输出类型不同,那编译器很难推导返回值类型,就会出问题。而这于常理也解释不同,为什么同一个行为,接受同样的输入,会输出不同类型的结果呢?所以以下例子应该在大多数语言里都是行不通的。

function plus(x: float, y: float) -> float{...}
function plus(x: float, y: float) -> int{...}

而熟悉 Rust 的人应该会知道在 Rust 里也有情况 A 出现的,但是为什么编译器可以放行呢?这有两种情况,一种跟普通泛型相关,而另一种跟关联类型(Associated Type)有关(关联类型也是泛型的一种),例如:


#![allow(unused)]
fn main() {
let v = Vec::new(); // 泛型相关,现在调用的是 Vec<T> 的 new(),但是不知道 T 是什么类型
// let v = Vec::<i8>::new() // OK,编译通过,因为我们指定了使用 Vec<i8>的new
println!("{:?}", v);
}

#![allow(unused)]
fn main() {
let a = [1, 2];
let v = a.into_iter().collect(); // 关联类型相关
// let v = let v = a.into_iter().collect::<Vec<_>>(); // OK
println!("{:?}", v);
// 感兴趣的人可以按这个路径翻一下标注库 Iterator::collect -> FromIteration -> IntoIterator
}

这里不深入讨论,这两种情况都或多或少跟以下例子的问题同源

fn plus<T: Num>(a: T, b: T) -> T {
    a + b
}
fn main() {
    let f = plus; // f 的类型是 fn(?, ?) -> ? 编译器不知道? 到底是什么,所以 fn(?, ?) -> ? 这个类型也是不确定的,编译出错
}

简而言之就是纯函数的情况 A 是不允许的,因为于常理不通;而跟泛型相关的情况下,确实也会有情况 A 出现,但是这个时候跟重载没关系,那么这个时候我们可以通过特定语法,或者直接标注上变量类型来帮助类型推断器。那么情况 A 也解决了。没有重载的时候尚且会有这些小问题,如果加入重载的话那显然问题就更多了。

那么我们只剩下 B、C、F 这三种情况没有考虑。那么,我们什么时候会写有不同参数数量的同名函数呢?

一种可能是,我们想要实现默认值,用来省去复杂的配置。比如 Java 中的InputStreamReader的构造器,我们可以指定编码格式,或者使用默认的 UTF-8 格式。但是 Rust 不允许有默认值(为什么?请看 为什么没有缺省参数),所以基于不同参数数量的重载(B、C、F 三种情况)是被“不允许有默认值”这个设定一票否决了的。

不过还有另一种可能,我们想要可变参数数量的函数,例如 print 就是最常用的需求,有可能是情况 B 或者 F。情况 B 时,有同类型的输入但是不同数量,这在 Java中是用 Varargs 实现的。讲道理 Rust 也可以实现内置Varargs,比如说将多个参数放进一个数组或者 Vec 里,但是 It is what it is (maybe for now)。但是情况 F 是完全不可能的,因为这就代表着要将不同的类型的数据放进同一个容器中,这个是绝对不应该放进 Rust 标准库中的,但是 Rust 的 println!是怎么实现的呢?这好办,既然不允许函数重载,那不用函数就是了,println!就是一个宏,相当于是给不同的输入生成了不同的函数,这是属于元编程的暴力优雅。

到这,表格中的 6 种情况就讨论完了。其实除了情况 B 可以处理得更加方便之外,别的情况(A、D、E)可以使用泛型系统解决,不用引入重载,避免语言变得太繁杂,而其他情况,则是 Rust 的理念决定的,也就是越直白越好,从而就否决了函数重载,进而鼓励程序员写不同的函数名字来突出函数行为的区别。

引用

为什么没有缺省参数?

答案很直白

  • Rust 的设计哲学之一是 Be Explicit,翻译过来就是不要藏着掖着的,越直白越好。函数缺省参数违背了这一条设计哲学,所以没有引入。

    • 函数描述的是行为,什么样的输入会导致什么行为应该越明显越好,并且函数名与函数的输入应该直截了当地描述函数的行为。
  • 缺省参数使得数据流变得更加散乱,在工程上也有坏处。

    • 举例子,如果你尝试过看别人的 Python 代码来复现深度学习模型的话,看到无处不在的默认值肯定会非常头疼,就像下面的例子。简而言之,如果你不喜欢硬编码的绝对文件路径,那你也应该理解没有人想要看一堆全是默认值的代码。

      def layer(x, parameter=0.1, use_branch=False):
          if use_branch:
              return some_op(x, alpha=parameter)
          else:
              return x * parameter
          
      x0 = layer(y, 0.2, True)
      x1 = layer(x0)
      x2 = layer(x1, use_branch=False)
      

真的没有缺省值吗?

其实也是有的,对于一个类型来说,可以通过impl Default的方法来指定类型的实例的默认值。但是这个是非常局限的应用,看代码的人可以直接找到impl块获得所有的默认值信息,所以影响不大。