在2019年刚刚接触Rust的时候(那会每周例行去医院光公交车要坐一个小时,就拿着手机在车上看,好怀念),就会看到Rust的官方文档里用Java等语言的interface来类比自己的trait,并且还会声明“这只是一种比喻,traitinterface其实有着相当大的区别”,不仅Rust如此,类似Scala这样的语言也会用这种类比来描述自己的trait特性,自那时起,“traitinterface到底有什么区别”这个问题就困扰了我相当一段时间。恰巧前一段时间看到某群群友讨论Rust的impl...for...到底是什么东西;借着这个机会,来顺便讨论一下Rust中trait的设计哲学。

多态

  讨论一个语言中某个特性的设计哲学,必然要涉及到一些前置知识,如果仅仅通过给出例子来描述,恐怕看一万个traitinterface的例子也难解其中的区别(我当初没有学过PL相关知识的时候就是因为使用这种方法才始终弄不明白这两者的区别到底在哪),在开始讲进一步的内容之前,我想先聊一下多态(Polymorphism)
  对于大部分人而言,多态这个词都是在学习OOP语言诸如Java的时候了解到的,许多人可能都耳闻过OOP的三原则继承(Inheritance),封装(Encapsulation),多态(Polymorphism)。多态在OOP中通常被作为接口来体现,在这类语言中,同一个类型上面的同一个方法可以有着不同的表现,例如

public interface Drawable {
    void draw();
}

public class Rectangle implements Drawable {
    public void draw() { System.out.println("Drawing Rectangle"); }
}

public class Triangle implements Drawable {
    public void draw() { System.out.println("Drawing Triangle"); }
}

public class Circle implements Drawable {
    public void draw() { System.out.println("Drawing Circle"); }
}

public class Painter {
    public static void doDraw(Drawable drawable) {
        drawable.draw();
    }
}

在这样的一个例子中,doDraw(new Rectangle())doDraw(new Triangle())doDraw(new Circle())显然有着不同的行为,分别输出Drawing RectangleDrawing TriangleDrawing Circle。但是在doDraw内部来看,这些对象,无论是new Rectangle()还是new Triangle(),还是new Circle()——都是Drawable类型的,在doDraw中调用的总是同一个类型Drawable上的同一个方法draw(),但是却表现出了不同的行为。你可以用多种不同的形式来总结这一点,例如“多态是多种不同的类型可以被视为一个统一的父类/接口”,或者“同一个类型上的同一个方法可以有不同的表现”。不管是哪种描述,其核心都是“相同的一段代码可以在被提供不同类型时有着不同的表现”。
  多态是这样的,对吧?NO。多态是一个涵盖范围相当广泛的词,这个词并不仅仅意味着OOP这种形式的多态,事实上,还有另外两种多态的表现形式,如果用大部分人更为熟悉的术语来描述的话,它们分别是——泛型(Generics)重载(Overloading)

Parametric Polymorphism与泛型

  上面提到了,多态的含义是“同一段代码可以在被提供不同类型时产生不同的行为”,这是一种很模糊的描述,因此自然也有着多种不同的实现方法。一种与OOP中的多态不同的另一种实现就是Parametric Polymorphism,有时翻译做参数多态性,他的核心在于“类型也可以被当作参数”,也就是说,你可以写一段代码,但是把这段代码中的类型空出来,用一个占位符表示(就像函数的形式参数是实际参数的占位符一样),接着在使用的时候,再去用某个具体的类型去实例化它。在多数语言中,这种特性被称为泛型,在Java中,你可以写一个这样的函数

public static <T> T doubleFun(Function<T, T> f, T x) {
    return f.apply(f.apply(x))
}

这是一个能把一个函数两次应用到同一个对象上的函数(类似f(f(x))),让它与众不同的原因是它的类型参数<T>,当你想要把他应用在一个Double类型的函数上面的时候,你只需要用Double去实例化它:<Double>doubleFun(doubleFunc, 1.0d),当你想要把它应用在一个Character类型的函数上面的时候,只需要用Character去实例化即可:<Character>doubleFun(charFunc, 'a')。在Parametric Polymorphism,或者说泛型中,类型也是参数,他们会在函数被调用的时候被实例化,用以搭配不同类型的参数。
  说到这里,Parametric Polymorphism和OOP中传统意义上的基于继承的多态到底有什么区别?如果他们两个是一种东西的话,不就陷入“既生瑜何生亮”的泥沼了吗?事实上,这个问题相当微妙,Parametric Polymorphism和基于继承的多态(下文中讲称其为Subtyping)可以称得上是“对立且统一”,他们都有着多态所描述的现象,但是这两种多态在几种属性上却又完全不同。

Universality

  Parametric Polymorphism之所以能被称之为泛型,是因为在这样的函数中,类型是“泛化”的:一个泛型函数可以用任何类型来实例化,例如上面的doubleFun这样一个函数,可以用IntegerDoubleCharacter,等等任何你想的类型去实例化它,从这个角度来看,他是通用的(Universal),而接口则不享有这个属性,回过头来看上面的doDraw函数,虽然也能做到传进去若干种不同的类型,诸如RectangleTriangle等不一而足,但是他们都被限制为是Drawable接口的实现类,也就是说,他们与Drawable接口之间存在一个非常明显的关系——父子类型的关系,并且由于只有Drawable的实现类才能适用,doDraw显然没有Universal的属性:你不可能给doDraw传进去一个Integer类型的对象,能用来“实例化”doDraw的类型,被限制在了“Drawable的子类”这样一个范围内。

Parametricity

  Parametric Polymorphism,从这个名字中可以引申出它的一个重要属性——Parametricity,Parametricity是所有泛型函数享有的一个属性——不管用什么样的类型去实例化他们,这些函数的表现区别仅仅体现在类型的不同上,而行为上则完全相同,例如无论你用什么类型去实例化doubleFun,他所做的都是“把f应用在x上两次”,不可能因为你应用在了不同的类型上就变成了应用三次或者额外多打印几句话,doubleFun用不同类型实例化的唯一区别只是fx的类型随之不同。

实际上,如果我们把函数都看做纯函数(不考虑函数外部的环境,没有副作用),那么我们甚至可以根据函数的类型来写出函数的实现,例如唯一能满足类型\forall X. X \to XX可以被看做是泛型参数,而X \to X代表一个接受X作为参数,同时返回一个X类型的值的函数类型)的值就是\lambda X.\lambda x:X. x,如果用Java来举例子,那就是唯一能够满足函数签名

<X> X f(X x)

的实现就是

<X> X f(X x) { 
    return x; 
}

因为这个函数需要返回一个T类型的值,而他唯一能获取到这个值的地方就是来自参数x,因此他能做的事情也就仅仅是返回这个x——注意,虽然在类似Java的语言中,你可以给泛型参数加上约束从而保证这些泛型一定满足某些条件,比如都定义了某个方法(例如通过约束<X extends Drawable>来允许调用x.draw()),但是当我们考虑纯粹的Parametric Polymorphism,或者说纯粹的System F时,泛型参数是不带有任何信息的,也不存在这些约束:它就仅仅是一个占位符,你无法从它身上得知任何额外的信息。

而对于Subtyping来说,则没有这个限制,一个接口的不同实现完全可以不仅在类型上,而且在行为上都完全不同。个中原因十分好理解:对于接口来说,每个实现类都写了自己的实现,三个实现类的三个draw()方法就有三处不同的定义,而对泛型函数来说,无论是什么类型,都只拥有doubleFun这一个定义。
  那么,说了这么多看似无关的内容,Rust的trait到底属于什么呢?看上去是Subtyping和Parametric Polymorphism的结合体?毕竟你用impl...for...实现trait的时候也必须实现trait中定义的函数,似乎和接口的作用完全一致了起来?这个问题的答案同样是NO:Rust的trait的确涉及了Parametric Polymorphism这一种多态,因为在Rust中也有泛型的概念,但是它的另一半却是一种和函数重载同源的多态,与OOP中的接口并无半点关系——尽管他们看起来很像。

Rust的impl

  函数重载,一般指的是几个名字一样,但是参数不同的函数(他们的返回值可以相同也可以不同,但是参数类型或者参数数量一定不能相同),例如在Java中,你可以写这样的两个函数:

String toString(int i);
String toString(char c);

他们的名字完全相同,但是参数的类型不同——一个是int,一个是char,因此他们互相都是对方的函数重载,当你在调用toString(1)toString('1')的时候,虽然看上去像是在调用同一个函数,但是实际上前者调用的是第一个toString的实现,而后者调用的是第二个toString的实现——这同样是一种多态,同一段代码(对toString的调用)在不同类型上(1的类型是int'1'的类型是char)有着不同的表现(int类型的字符串化和char类型的字符串化逻辑和实现都是不相同的)。这样的多态,被称为Ad-hoc Polymorphism,其中Ad-hoc是拉丁文,翻译成英文是"to this/for this",而在中文语境下,可以背理解为“特别的,专门的”,换言之,Ad-hoc Polymorphism是一种“针对每个类型拥有自己的实现”的多态,这个描述应用在函数重载上面非常合适,毕竟,函数重载就是针对不同类型的参数的若干个定义不同但是同名的函数。
  But wait,如果我没记错的话,Rust应该是不允许多态的,也就是在Rust中,你不能在同一个作用域下写出

fn stringify(i: &u32) -> String
fn stringify(c: &char) -> String

这两个重载出来。没错,Rust不允许重载,但是,你可以把to_string写进一个trait里:

trait Stringify {
    fn stringify(&self) -> String;
}

然后为他提供两个u32char上的impl

impl Stringify for u32 {
    fn stringify(&self) -> String {
        format!("{}", &self)
    }
}

impl Stringify for char {
    fn stringify(&self) -> String {
        format!("{}", &self)
    }
}

然后我们就可以分别给u32char调用stringify函数了:

println!("{}", u32::stringify(&1));
println!("{}", char::stringify(&'1'));

这种方法,在调用方式上和重载有几分神似,在实现方式上和接口也有几分神似,那么为什么我会说他是和重载一样的Ad-hoc Polymorphism,而不是和接口一样的Subtyping呢?原因在于类型之间的依赖关系上。在接口对应的Subtyping中,implements代表了接口与其实现类之间的父子类型关系——这两个类型之间存在一个很强的依赖关系,它们互为父子类型。而在Ad-hoc Polymorphism中,则不存在这样的关系,在函数重载的例子中,一方是函数本身,而另一方是其参数的类型,这两者甚至不属于同一范畴下的概念,自然不可能有任何关系;而Rust的trait也是如此,在上面的例子中,一方是Stringify,另一方是他的“实现类”&u32,我们能给一个&u32类型的变量调用stringify方法,但是u32本身却并不是Stringify这个trait的子类型,严格来说,它们两个之间什么关系都没有,在类型系统中完完全全是两个互不相干的个体,唯一的关联是Stringify有一个针对&u32的实现,而如同函数重载中,编译器会根据参数的不同选择不同的重载一样,Rust编译器也会根据类型不同,选择trait的不同impl。除此之外,Ad-hoc与Subtyping对应多态类型的方式也不相同:前者是“应用在不同类型的参数上的同一函数(stringify)行为不同”,而后者是“应用在被看作是同一类型(父类/接口)的实则不同的类型(子类/实现类)的参数上的同一函数(doDraw)行为不同”
  正因如此,在Rust中,impl对应了与函数重载一样的Ad-hoc Polymorphism,而不是与OOP中的接口一样的Subtyping,我们可以做一个简单的表格,来划分Parametric,Ad-hoc,以及Subtyping三种不同的多态之间的区别,而这些区别体现在Universality,Parametricity,类型之间的关系,以及该类型多态的本质这四个维度上:

属性\多态种类 Parametric Ad-hoc Subtyping
Parametricity
(不同类型享有统一的行为)
Universality
(可以应用于任意类型,而不需要针对每个类型写出实现)
类型之间的关系
(承载多态的类型之间存不存在依赖)
不存在 不存在 存在
多态的本质
(该类型多态的实现方法)
同一段代码可以应用在不同的类型上 同一函数在不同类型的参数上有不同的行为 同一函数在被看做是同一类型,实则是不同类型的参数上有不同的行为

值得注意的是,Non-Parametricity(也就是Parametricity的反面:不同类型可以拥有完全不同的行为)和Universality这两个属性在现代编程语言中缺少任意一个都会带来相当大的麻烦。例如这样一个例子:

trait Eq {
    fn equiv(lhs: &Self, rhs: &Self) -> bool;
}

struct tuple2<K, V> {
    key: K,
    value: V
}

struct tuple3<K, V, C> {
    key: K,
    value: V,
    column: C
}

impl <K, V> Eq for tuple2<K, V> where K: Eq, V: Eq {
    fn equiv(lhs: &Self, rhs: &Self) -> bool {
        lhs.key.equiv(&rhs.key) && lhs.value.equiv(&rhs.value)
    }
}

impl <K, V, C> Eq for tuple3<K, V, C> where K: Eq, V: Eq, C: Eq {
    fn equiv(lhs: &Self, rhs: &Self) -> bool {
        lhs.key.equiv(&rhs.key) && lhs.value.equiv(&rhs.value) && lhs.column.equiv(&rhs.column)
    }
}

fn check_equiv<T: Eq>(lhs: &T, rhs: &T) -> bool {
    T::equiv(lhs, rhs)
}

就是Ad-hoc的Non-Parametricity,与Parametric的Universality结合才能做到的事情:如果没有Ad-hoc的trait以及impl,那么我们就无法为tuple2tuple3两个不同类型的Eq实现定义不同的行为——它们将会不得不一个实现Eq2一个实现Eq3这两个不同的类型来分别定义两个元素的元组和三个元素的元组的比较逻辑,这显然失去了多态的意义,而如果没有Parametric的泛型带来的Universality,我们就不得不写一堆

impl Eq for tuple2<u32, u32>
impl Eq for tuple2<String, String>
impl Eq for tuple3<char, char, char>
...

等等等等一系列类型上特化的Eq实现,同时, check_equiv也要变成一系列check_equiv_tuplecheck_equiv_strcheck_equiv_u32等等针对每个类型特化的比较函数。从这里可以看到,Non-Parametricity与Universality缺一不可,而这也正是大部分现代多范式语言采用的策略:在诸如Java/C#等语言中,达成这一点靠的是基于接口/父子类型的Subtyping和基于泛型的Parametric;而在Rust/Haskell这些语言中,则是基于trait/impl这样的Ad-hoc Polymorphism和同样是泛型的Parametric,只不过在Haskell中,相比起trait,它有一个更加响亮的名字——Typeclass;还有一些语言,比如Scala,同时支持三种多态:既能用traitextends实现Subtyping,也能用泛型实现Parametric,还能用泛型traitgivenusing结合在一起实现Ad-hoc/Typeclass。
  可以看出,Ad-hoc Polymorphism的表现方式相当广泛,从函数重载,到Haskell的class,到Rust、Scala中的trait,甚至还有本文中虽然没有提到,但是实际上同样属于Ad-hoc的扩展(Extensions),在Swift/Scala中,它们用extension关键字声明,而C#/Kotlin虽然没有完善的对扩展的支持,却也支持扩展函数这种略弱于扩展的同种特性,有兴趣的话,可以思索一下为什么扩展函数这样的特性同样属于Ad-hoc Polymorphism的范畴。

  Ad-hoc Polymorphism,相比起它的两姐妹:Subtyping和Parametric,显得更加鲜为人知,毕竟对于大部分人来说,函数重载似乎是一个理所当然的东西,不值得作为一个专门的特性来介绍。但是今天,在这篇文章中,我们发掘了Rust的impl的设计哲学,探索了Ad-hoc Polymorphism除开重载之外的几种实现方式,了解了它的优点——尤其是两个类型之间的无关联性,或者,用软件工程的术语来说:“解耦”。在现代编程语言中可以找到这个略显阿卡林的重要角色越来越广的应用。应该说,Ad-hoc Polymorphism是一个极为优秀的设计,并且应当更加广泛的被计算机从业者,尤其是负责写代码的程序员本身了解并学习。各种教程,书籍与课程与其抓着已经被嚼烂的OOP三板斧和各种设计模式不放,不如拓宽一下视野,尝试尝试旁征博引,授读者以更加灵活的思维方式,更加广袤,才能正确、有效的发挥现代编程语言的全部潜力。


欲少留此灵琐兮,日忽忽其将暮