本性决定行为,本性取决于行为。
众所周知,计算机以二进制的形式来存储信息。对于计算机而言,不管什么样的信息,都只是0和1的排列,所有的信息对计算机来说只不过是字节序列。作为开发人员,如果想要存储、表示和处理各种信息,直接使用0和1必然会产生巨大的心智负担,所以,类型应运而生。类型于20世纪50年代被FORTRAN语言引入,历经诸多高级语言的洗礼,其相关的理论和应用已经发展得非常成熟。直到现代,类型已经成为了各大编程语言的核心基础。
所谓类型,其实就是对表示信息的值进行的细粒度的区分。比如整数、小数、文本等,粒度再细一点,就是布尔值、符号整型值、无符号整型值、单精度浮点数、双精度浮点数、字符和字符串,甚至还有各种自定义的类型。不同的类型占用的内存不同。与直接操作比特位相比,直接操作类型可以更安全、更有效地利用内存。例如,在Rust语言中,如果你创建一个u32类型的值,Rust会自动分配4个字节来存储该值。
计算机不只是用来存储信息的,它还需要处理信息。这就必然会面临一个问题:不同的类型该如何计算?因此需要对这些基本的类型定义一系列的组合、运算、转换等方法。如果把编程语言看作虚拟世界的话,那么类型就是构建这个世界的基本粒子,这些类型粒子通过各种组合、运算、转换等“物理化学反应”,造就了此世界中的各种“事物”。类型之间的纷繁复杂的交互形成了类型系统,类型系统是编程语言的基础和核心,因为编程语言的目的就是存储和处理信息。不同编程语言之间的区别就在于如何存储和处理信息。
其实在计算机科学中,对信息的存储和处理不止类型系统这一种方式,还有其他的一些理论框架,只不过类型系统是最轻量、最完善的一种方式。在类型系统中,一切皆类型。基于类型定义的一系列组合、运算和转换等方法,可以看作类型的行为。类型的行为决定了类型该如何计算,同时也是一种约束,有了这种约束才可以保证信息被正确处理。
类型系统是一门编程语言不可或缺的部分,它的优势有以下几个方面。
· 排查错误。很多编程语言都会在编译期或运行期进行类型检查,以排查违规行为,保证程序正确执行。如果程序中有类型不一致的情况,或有未定义的行为发生,则可能导致错误的产生。尤其是对于静态语言来说,能在编译期排查出错误是一个很大的优势,这样可以及早地处理问题,而不必等到运行后系统崩溃了再解决。
· 抽象。类型允许开发者在更高层面进行思考,这种抽象能力有助于强化编程规范和工程化系统。比如,面向对象语言中的类就可以作为一种类型。
· 文档。在阅读代码的时候,明确的类型声明可以表明程序的行为。
· 优化效率。这一点是针对静态编译语言来说的,在编译期可以通过类型检查来优化一些操作,节省运行时的时间。
· 类型安全。
➢ 类型安全的语言可以避免类型间的无效计算,比如可以避免3/"hello"这样不符合算术运算规则的计算。
➢ 类型安全的语言还可以保证内存安全,避免诸如空指针、悬垂指针和缓存区溢出等导致的内存安全问题。
➢ 类型安全的语言也可以避免语义上的逻辑错误,比如以毫米为单位的数值和以厘米为单位的数值虽然都是以整数来存储的,但可以用不同的类型来区分,避免逻辑错误。
虽然类型系统有这么多优点,但并非所有的编程语言都能百分百拥有这些优点,这与它们的类型系统的具体设计和实现有关系。
在编译期进行类型检查的语言属于静态类型,在运行期进行类型检查的语言属于动态类型。如果一门语言不允许类型的自动隐式转换,在强制转换前不同类型无法进行计算,则该语言属于强类型,反之则属于弱类型[1]。
静态类型的语言能在编译期对代码进行静态分析,依靠的就是类型系统。我们以数组越界访问的问题为例来说明。有些静态语言,如C和C++,在编译期并不检查数组是否越界访问,运行时可能会得到难以意料的结果,而程序依旧正常运行,这属于类型系统中未定义的行为,所以它们不是类型安全的语言。而Rust语言在编译期就能检查出数组是否越界访问,并给出警告,让开发者及时修改,如果开发者没有修改,那么在运行时也会抛出错误并退出线程,而不会因此去访问非法的内存,从而保证了运行时的内存安全,所以 Rust是类型安全的语言。强大的类型系统也可以对类型进行自动推导,因此一些静态语言在编写代码的时候不用显式地指定具体的类型,比如Haskell就被称为隐式静态类型。Rust语言的类型系统受Haskell启发,也可以自动推导,但不如Haskell强大。在Rust中大部分地方还是需要显式地指定类型的,类型是Rust语法的一部分,因此Rust属于显式静态类型。
动态类型的语言只能在运行时进行类型检查,但是当有数组越界访问时,就会抛出异常,执行线程退出操作,而不是给出奇怪的结果。所以一些动态语言也是类型安全的,比如Ruby和Python语言。在其他语言中作为基本类型的整数、字符串、布尔值等,在Ruby和Python语言中都是对象。实际上,也可将对象看作类型,Ruby和Python语言在运行时通过一种名为Duck Typing的手段来进行运行时类型检查,以保证类型安全。在Ruby和Python语言中,对象之间通过消息进行通信,如果对象可以响应该消息,则说明该对象就是正确的类型。
对象是什么样的类型,决定了它有什么样的行为;反过来,对象在不同上下文中的行为,也决定了它的类型。这其实是一种多态性。
如果一个类型系统允许一段代码在不同的上下文中具有不同的类型,这样的类型系统就叫作多态类型系统。对于静态类型的语言来说,多态性的好处是可以在不影响类型丰富的前提下,为不同的类型编写通用的代码。
现代编程语言包含了三种多态形式:参数化多态(Parametric polymorphism)、Ad-hoc多态(Ad-hoc polymorphism)和子类型多态(Subtype polymorphism)。如果按多态发生的时间来划分,又可分为静多态(Static Polymorphism)和动多态(Dynamic Polymorphism)。静多态发生在编译期,动多态发生在运行时。参数化多态和Ad-hoc多态一般是静多态,子类型多态一般是动多态。静多态牺牲灵活性获取性能,动多态牺牲性能获取灵活性。动多态在运行时需要查表,占用较多空间,所以一般情况下都使用静多态。Rust语言同时支持静多态和动多态,静多态就是一种零成本抽象。
参数化多态实际就是指泛型。很多时候函数或数据类型都需要适用于多种类型,以避免大量的重复性工作。泛型使得语言极具表达力,同时也能保证静态类型安全。
Ad-hoc多态也叫特定多态。Ad-hoc短语源自拉丁语系,用于表示一种特定情况。Ad-hoc多态是指同一种行为定义,在不同的上下文中会响应不同的行为实现。Haskell 语言中使用Typeclass来支持Ad-hoc多态,Rust受Haskell启发,使用trait来支持Ad-hoc多态。所以,Rust的trait系统的概念类似于Haskell中的Typeclass。
子类型多态的概念一般用在面向对象语言中,尤其是Java语言。Java语言中的多态就是子类型多态,它代表一种包含关系,父类型的值包含了子类型的值,所以子类型的值有时也可以看作父类型的值,反之则不然。而 Rust语言中并没有类似Java中的继承的概念,所以也不存在子类型多态。所以,Rust中的类型系统目前只支持参数化多态和Ad-hoc多态,也就是,泛型和trait。
Rust是一门强类型且类型安全的静态语言。Rust中一切皆表达式,表达式皆有值,值皆有类型。所以可以说,Rust中一切皆类型。
除了一些基本的原生类型和复合类型,Rust把作用域也纳入了类型系统,这就是第4章将要学到的生命周期标记。还有一些表达式,有时有返回值,有时没有返回值(也就是只返回单元值),或者有时返回正确的值,有时返回错误的值,Rust 将这类情况也纳入了类型系统,也就是Option<T>和Result<T,E>这样的可选类型,从而强制开发人员必须分别处理这两种情况。一些根本无法返回值的情况,比如线程崩溃、break或continue等行为,也都被纳入了类型系统,这种类型叫作never类型。可以说,Rust的类型系统基本囊括了编程中会遇到的各种情况,一般情况下不会有未定义的行为出现,所以说,Rust是类型安全的语言。
编程语言中不同的类型本质上是内存占用空间和编码方式的不同,Rust也不例外。Rust中没有GC,内存首先由编译器来分配,Rust代码被编译为LLVM IR,其中携带了内存分配的信息。所以编译器需要事先知道类型的大小,才能分配合理的内存。
可确定大小类型和动态大小类型
Rust中绝大部分类型都是在编译期可确定大小的类型(Sized Type),比如原生整数类型u32固定是4个字节,u64固定是8个字节,等等,都是可以在编译期确定大小的类型。然而,Rust也有少量的动态大小的类型(Dynamic Sized Type,DST),比如str类型的字符串字面量,编译器不可能事先知道程序中会出现什么样的字符串,所以对于编译器来说,str类型的大小是无法确定的。对于这种情况,Rust提供了引用类型,因为引用总会有固定的且在编译期已知的大小。字符串切片&str就是一种引用类型,它由指针和长度信息组成,如图3-1所示。
图3-1:&str由指针和长度信息组成
&str存储于栈上,str字符串序列存储于堆上。这里的堆和栈是指不同的内存空间,在第4章会详细介绍。&str 由两部分组成:指针和长度信息,如代码清单3-1所示。其中指针是固定大小的,存储的是 str字符串序列的起始地址,长度信息也是固定大小的整数。这样一来,&str就变成了可确定大小的类型,编译器就可以正确地为其分配栈内存空间,str也会在运行时在堆上开辟内存空间。
代码清单3-1:&str的组成部分

代码清单3-1声明了字符串字面量str,通过as_ptr()和len()方法,可以分别获取该字符串字面量存储的地址和长度信息。这种包含了动态大小类型地址信息和携带了长度信息的指针,叫作胖指针(Fat Pointer),所以&str是一种胖指针。
与字符串切片同理,Rust中的数组[T]是动态大小类型,编译器难以确定它的大小。如代码清单3-2所示是将数组直接作为函数参数的情况。
代码清单3-2:将数组直接作为函数参数


代码清单3-2编译会报错:

意思是,编译器无法确定参数[u32]类型的大小。有两种方式可以修复此错误,第一种方式是使用[u32;5]类型,如代码清单3-3所示。
代码清单3-3:函数参数使用[u32;5]类型

代码清单3-3能够正常编译,从输出结果可以看出来,修改的数组并未影响原来的数组。这是因为u32类型是可复制的类型,实现了Copy trait,所以整个数组也是可复制的。所以当数组被传入函数中时就会被复制一份新的副本。这里值得注意的是,[u32]和[u32;5]是两种不同的类型。
另外一种解决代码清单3-2编译错误的方式是使用胖指针,类似&str,这里只需要将参数类型改为&mut [u32]即可。&mut [u32]是对[u32]数组的借用,会生成一个数组切片&[u32],它会携带长度信息,如代码清单3-4所示。
代码清单3-4:使用&mut [u32]作为参数类型

代码清单3-4中使用了&mut [u32],它是可变借用,&[u32]是不可变借用。因为这里要修改数组元素,所以使用可变借用。从输出的结果可以看出,胖指针&mut [u32]包含了长度信息。将引用当作函数参数,意味着被修改的是原数组,而不是最新的数组,所以原数组在reset之后也发生了改变。
代码清单3-5比较了&[u32;5]和&mut [u32]两种类型的空间占用情况。
代码清单3-5:比较&[u32;5]和&mut [u32]两种类型的空间占用情况

代码清单3-5中的std::mem::size_of<&[u32;5]>()函数可以返回类型的字节数。输出结果分别为8和16。&[u32;5]类型为普通指针,占8个字节;&mut [u32]类型为胖指针,占16个字节。可见,整整多出了一倍的占用空间,这也是称其为胖指针的原因。
零大小类型
除了可确定大小类型和DST类型,Rust还支持零大小类型(Zero Sized Type,ZST),比如单元类型和单元结构体,大小都是零。代码清单3-6展示了一组零大小的类型。
代码清单3-6:一组零大小的类型示例

代码清单3-6编译输出的类型大小均为零。所以,单元类型和单元结构体大小为零,由单元类型组成的数组大小也为零。ZST类型的特点是,它们的值就是其本身,运行时并不占用内存空间。ZST类型代表的意义正是“空”。
代码清单3-7展示了使用单元类型来查看数据类型的一个技巧。
代码清单3-7:使用单元类型查看数据类型

编译器会提示:期望的是单元类型,这是因为代码里直接指定了单元类型,但是却发现了std::vec::Vec类型。这样我们就知道了右值vec![();10]是向量类型。
代码清单3-8展示了一种迭代技巧,使用Vec<()>迭代类型。
代码清单3-8:使用Vec<()>迭代类型

在代码清单3-8中,使用了Vec<()>类型,使用单元类型制造了一个长度为10的向量。在一些只需要迭代次数的场合中,使用这种方式能获得较高的性能。因为Vec内部迭代器中会针对ZST类型做一些优化。
另外一个使用单元类型的示例是在第2章中介绍过的Rust官方标准库中的HashSet<T>和BTreeSet<T>。它们其实只是把HashMap<K,T>换成了HashMap<K,()>,然后就可以共用HashMap<K,T>之前的代码,而不需要再重新实现一遍HashSet<T>了。
底类型
底类型(Bottom Type)是源自类型理论的术语,它其实是第2章介绍过的never类型。它的特点是:
· 没有值。
· 是其他任意类型的子类型。
如果说ZST类型表示“空”的话,那么底类型就表示“无”。底类型无值,而且它可以等价于任意类型,有点无中生有之意。
Rust中的底类型用叹号(!)表示。此类型也被称为Bang Type。Rust中有很多种情况确实没有值,但为了类型安全,必须把这些情况纳入类型系统进行统一处理。这些情况包括:
· 发散函数(Diverging Function)
· continue和break关键字
· loop循环
· 空枚举,比如enum Void{}
先来看前三种情况。发散函数是指会导致线程崩溃的panic!("This function never returns!"),或者用于退出函数的std::process::exit,这类函数永远都不会有返回值。continue和break也是类似的,它们只是表示流程的跳转,并不会返回什么。loop循环虽然可以返回某个值,但也有需要无限循环的时候。
Rust中if语句是表达式,要求所有分支类型一致,但是有的时候,分支中可能包含了永远无法返回的情况,属于底类型的一种应用,如代码清单3-9所示。
代码清单3-9:底类型的应用

代码清单3-9的if条件表达式中,foo函数返回!,而else表达式返回整数类型,但是编译可以正常通过,假如把else表达式中的整数类型换成字符串或其他类型,编译也可以通过。
空枚举,比如enum Void{},完全没有任何成员,因而无法对其进行变量绑定,不知道如何初始化并使用它,所以它也是底类型。代码清单3-10展示了空枚举的一种用法。
代码清单3-10:空枚举的用法(编译无法通过,还在完善中)

Rust中使用Result类型来进行错误处理,强制开发者处理Ok和Err两种情况,但是有时可能永远没有Err,这时使用enum Void{}就可以避免处理Err的情况。当然这里也可以用if let语句处理,但是这里为了说明空枚举的用法故意这样使用。
但是可惜的是,当前版本的Rust还不支持上面的语法,编译会报错。不过Rust团队还在持续完善中,在不久的将来Rust就会支持此用法。
底类型将上述几种特殊情况纳入了类型系统,以便让Rust可以统一进行处理,从而保证了类型安全。
类型标注在Rust中属于语法的一部分,所以Rust属于显式类型语言。Rust支持类型推断,但其功能并不像Haskell那样强大,Rust只能在局部范围内进行类型推导。
代码清单3-11展示了Rust中的类型推导。
代码清单3-11:类型推导

在代码清单3-11中,第5行和第6行声明了两个变量a和b,并没有标注类型。但是传入sum函数中却可以正常运行,这代表Rust自动推导了a和b的类型。代码第8行声明了一个u8类型elem,第9行创建了一个空的向量,类型为Vec<_>,可以通过代码清单3-7的方法来查看此类型。第10行用push方法将elem插入vec中,此时vec的类型为Vec<u8>。
Turbofish操作符
当Rust无法从上下文中自动推导出类型的时候,编译器会通过错误信息提示你,请求你添加类型标注,代码清单3-12展示了这种情况。
代码清单3-12:Rust无法根据上下文自动推导出类型的情况

编译代码清单3-12,会给出如下错误信息:

代码清单3-12是想把字符串"1"转换为整数类型1,但是parse方法其实是一个泛型方法,当前无法自动推导类型,所以Rust编译器无法确定到底要转换成哪种类型的整数,是u32还是i32呢?毕竟Rust中整数类型很丰富。所以这里就需要直接给出明确的类型标注信息了,如代码清单3-13所示。
代码清单3-13:添加明确的类型标注信息

Rust还提供了一种标注类型的方法,用于方便地在值表达式中直接标注类型,如代码清单3-14所示。
代码清单3-14:另一种标注类型的方法

在代码清单3-14中,使用了parse::<i32>()这样的形式为泛型函数标注类型,这就避免了代码清单3-13第3行的变量声明。很多时候并不需要声明太多变量,代码看上去也能更加紧凑。这种标注类型(::<>)的形式就叫作turbofish操作符。
类型推导的不足
目前看来,Rust的类型推导还不够强大。代码清单3-15展示了另外一种类型推导的缺陷。
代码清单3-15:类型推导缺陷

代码清单3-15中的is_positive()是整数类型实现的用于判断正负的方法。但是当前Rust编译时此代码会出现下面的错误:
error[E0599]:no method named`is_positive`found for type`{integer}`in the current scope
这里出现的{integer}类型并非真实类型,它只是被用于错误信息中,表明此时编译器已经知道变量a是整数类型,但并未推导出变量a的真正类型,因为此时没有足够的上下文信息帮助编译器进行推导。所以在用Rust编程的时候,应尽量显式声明类型,这样可以避免一些麻烦。
泛型(Generic)是一种参数化多态。使用泛型可以编写更为抽象的代码,减少工作量。简单来说,泛型就是把一个泛化的类型作为参数,单个类型就可以抽象化为一簇类型。在第2章中介绍过的Box<T>、Option<T>和Result<T,E>等,都是泛型类型。
除了定义类型,泛型也可以应用于函数中,代码清单3-16就是一个泛型函数的示例。
代码清单3-16:泛型函数

也可以在结构体中使用泛型,如代码清单3-17所示。
代码清单3-17:泛型结构体

与枚举类型和函数一样,结构体名称旁边的<T>叫作泛型声明。泛型只有被声明之后才可以被使用。在为泛型结构体实现具体方法的时候,也需要声明泛型类型,如代码清单3-18所示。
代码清单3-18:为泛型结构体实现具体方法

注意看第3行代码中的impl<T>,此处必须声明泛型T。Rust标准库提供的各种容器类型大多是泛型类型。比如向量Vec<T>就是一个泛型结构体,代码清单3-19展示了其在Rust源码中的实现。
代码清单3-19:标准库中的Vec<T>源码

Rust中的泛型属于静多态,它是一种编译期多态。在编译期,不管是泛型枚举,还是泛型函数和泛型结构体,都会被单态化(Monomorphization)。单态化是编译器进行静态分发的一种策略。以代码清单3-16中的泛型函数为例,单态化意味着编译器要将一个泛型函数生成两个具体类型对应的函数,代码清单3-16等价于代码清单3-20。
代码清单3-20:编译期单态化的泛型函数

泛型及单态化是Rust的最重要的两个功能。单态化静态分发的好处是性能好,没有运行时开销;缺点是容易造成编译后生成的二进制文件膨胀。这个缺点并不影响使用Rust编程。但是需要明白单态化机制,在平时的编程中注意二进制的大小,如果变得太大,可以根据具体的情况重构代码来解决问题。
编译器还可以对泛型进行自动推导。代码清单3-21展示了对泛型返回值类型的自动推导。
代码清单3-21:泛型返回值类型的自动推导

代码清单3-21中定义了两个元组结构体Foo和Bar,分别为它们实现了Inst trait中定义的new方法。然后定义了泛型函数foobar,以及函数内调用泛型T的new方法。
代码第22行调用foobar函数,并指定其返回值的类型为Foo,那么Rust就会根据该类型自动推导出要调用Foo::new方法。同理,代码第24行指定了foobar函数的返回值应该为Bar类型,那么Rust就自动推导出应该调用Bar::new方法。这为日常的编程带来了足够的方便。
可以说trait是Rust的灵魂。Rust中所有的抽象,比如接口抽象、OOP范式抽象、函数式范式抽象等,均基于trait来完成。同时,trait也保证了这些抽象几乎都是运行时零开销的。
那么,到底什么是trait?从类型系统的角度来说,trait是Rust对Ad-hoc多态的支持。从语义上来说,trait是在行为上对类型的约束,这种约束可以让trait有如下4种用法:
· 接口抽象。接口是对类型行为的统一约束。
· 泛型约束。泛型的行为被trait限定在更有限的范围内。
· 抽象类型。在运行时作为一种间接的抽象类型去使用,动态地分发给具体的类型。
· 标签trait。对类型的约束,可以直接作为一种“标签”使用。
下面依次介绍trait的这4种用法。
trait最基础的用法就是进行接口抽象,它有如下特点:
· 接口中可以定义方法,并支持默认实现。
· 接口中不能实现另一个接口,但是接口之间可以继承。
· 同一个接口可以同时被多个类型实现,但不能被同一个类型实现多次。
· 使用impl关键字为类型实现接口方法。
· 使用trait关键字来定义接口。
图3-2形象地展示了trait接口抽象。
图3-2:trait作为接口抽象的形象表示
在第2章的代码清单2-53中定义的Fly trait就是一个典型的接口抽象。类型Duck和Pig均实现了该trait,但具体的行为各不相同。这正是一种Ad-hoc多态:同一个trait,在不同的上下文中实现的行为不同。为不同的类型实现trait,属于一种函数重载,也可以说函数重载就是一种Ad-hoc多态。
关联类型
事实上,Rust中的很多操作符都是基于trait来实现的。比如加法操作符就是一个trait,加法操作不仅可以针对整数、浮点数,也可以针对字符串。
那么如何对这个加法操作进行抽象呢?除了两个相加的值的类型,还有返回值类型,这三个类型不一定相同。我们首先能想到的一个方法就是结合泛型的trait,如代码清单3-22所示。
代码清单3-22:利用泛型trait实现加法抽象

代码清单3-22中定义了Add trait。它包含了两个类型参数:RHS和Output,分别代表加法操作符右侧的类型和返回值的类型。在该trait内定义的add方法签名中,以self为参数,代表实现该trait的类型。
接下来为i32和u32类型分别实现了Add trait。
代码第4行到第8行表示为i32类型实现Add,并且要求只能和i32类型相加,且返回值也是i32类型。
代码第9行到第13行表示为u32类型实现Add,并且要求只能和u32类型相加,但是返回值是i32类型。
然后在main函数中分别声明了i32和u32两组数字,分别让其相加,得到了预期的结果。
使用trait泛型来实现加法抽象,看上去好像没什么问题,但是仔细考虑一下,就会发现它有一个很大的问题。一般来说,对于加法操作要考虑以下两种情况:
· 基本数据类型,比如i32和i32类型相加,出于安全考虑,结果必然还是i32类型。
· 也可以对字符串进行加法操作,但是Rust中可以动态增加长度的只有String类型的字符串,所以一般是String类型的才会实现Add,其返回值也必须是String类型。但是加法操作符右侧也可以是字符串字面量。所以,面对这种情况,String的加法操作还必须实现Add<&str,String>。
不管是以上两种情况中的哪一种,Add的第二个类型参数总是显得有点多余。所以,Rust标准库中定义的Add trait使用了另外一种写法。
代码清单3-23展示了Rust标准库中Add trait的定义。
代码清单3-23:标准库Add trait的定义

代码清单3-23中同样使用了泛型trait,但是与代码清单3-22的区别在于,它将之前的第二个类型参数去掉了。取而代之的是type定义的Output,以这种方式定义的类型叫作关联类型。而Add<RHS=Self>这种形式表示为类型参数RHS指定了默认值Self。Self是每个trait都带有的隐式类型参数,代表实现当前trait的具体类型。
当代码中出现操作符“+”的时候,Rust就会自动调用操作符左侧的操作数对应的add()方法,去完成具体的加法操作,也就是说“+”操作与调用add()方法是等价的,如图3-3所示。
图3-3:“+”操作等价于调用add()方法
代码清单3-24展示了标准库中为u32类型实现Add trait来定义加法的源码,为了突出重点,这里删减了一些不必要的内容。
代码清单3-24:标准库中为u32类型实现Add trait

因为Rust源码为u32实现Add trait的操作是用宏来完成的,所以代码清单3-24中出现了$t这样的符号,在第12章会讲到关于宏的更多细节。当前这里的$t可以看作u32类型,如代码清单3-25所示。
代码清单3-25:可以将上面的$t看作u32类型

这里的关联类型是u32,因为两个u32整数相加结果必然还是u32整数。如果实现Add trait时并未指明泛型参数的具体类型,则默认为Self类型,也就是u32类型。
除了整数,String类型的字符串也支持使用加号进行连接。代码清单3-26展示了为String类型实现Add trait的源码。同样,为了突出重点,我们进行了删减。
代码清单3-26:标准库中为String类型实现Add trait

代码清单3-26中的impl Add<&str>指明了泛型类型为&str,并没有使用Self默认类型参数,这表明对于String类型字符串来说,加号右侧的值类似&str类型,而非String类型。关联类型Output指定为String类型,意味着加法返回的是String类型。代码清单3-27展示了String字符串的加法运算。
代码清单3-27:String类型字符串的加法运算

在代码清单3-27中,变量a和b为&str类型,所以将二者相加时,必须将a转换为String类型。
综上所述,使用关联类型能够使代码变得更加精简,同时也对方法的输入和输出进行了很好的隔离,使得代码的可读性大大增强。在语义层面上,使用关联类型也增强了trait表示行为的这种语义,因为它表示了和某个行为(trait)相关联的类型。在工程上,也体现出了高内聚的特点。
trait一致性
既然Add是trait,那么就可以通过impl Add的功能来实现操作符重载的功能。在Rust中,通过上面对Add trait的分析就可以知道,u32和u64类型是不能直接相加的。代码清单3-28尝试重载整数的加法操作,实现u32和u64类型直接相加。
代码清单3-28:尝试重载整数的加法操作

代码清单3-28编译会出错:

这是因为Rust遵循一条重要的规则:孤儿规则(Orphan Rule)。孤儿规则规定,如果要实现某个trait,那么该trait和要实现该trait的那个类型至少有一个要在当前crate中定义。在代码清单3-28中,Add trait和u32、u64都不是在当前crate中定义的,而是定义于标准库中的。如果没有孤儿规则的限制,标准库中u32类型的加法行为就会被破坏性地改写,导致所有使用u32类型的crate可能产生难以预料的Bug。
因此,要想正常编译通过,就需要把Add trait放到当前crate中来定义,如代码清单3-29所示。
代码清单3-29:在当前crate中定义Add trait

代码清单3-29在当前crate中定义了Add trait,这样就不会违反孤儿规则。并且在impl Add的时候,将RHS和关联类型指定为u64类型。注意在调用的时候要用add,而非操作符+,以避免被Rust识别为标准库中的add实现。这样就可以正常编译通过了。
当然,除了在本地定义Add trait这个方法,还可以在本地创建一个新的类型,然后为此新类型实现Add,这同样不会违反孤儿规则,如代码清单3-30所示。
代码清单3-30:为新类型实现Add操作

还需要注意,关联类型Output必须指定具体类型。函数add的返回类型可以写Point,也可以写Self,也可以写Self::Output。
trait继承
Rust不支持传统面向对象的继承,但是支持trait继承。子trait可以继承父trait中定义或实现的方法。在日常编程中,trait中定义的一些行为可能会有重复的情况,使用trait继承可以简化编程,方便组合,让代码更加优美。
接下来以Web编程中常见的分页为例,来说明trait继承的一些应用场景。代码清单3-31以分页为例展示了如何定义trait。
代码清单3-31:以分页为例定义trait

代码清单3-31中定义了Page和PerPage两个trait,分别代表当前页面的页码和每页显示的条目数。并且分别实现了两个默认方法:set_page和set_perpage,分别用于设置当前页面页码和每页显示条目数,默认值被设置为了第1页和每页显示10个条目。
代码第11行定义了MyPaginate结构体。
代码第12行和第13行分别为MyPaginate实现了Page和PerPage,使用空的impl块代表使用trait的默认实现。
在代码第14行到第18行的main函数中,创建了MyPaginate的一个实例my_paginate,并分别调用set_page和set_perpage方法,输出结果为默认值。
假如此时需要多加一个功能,要求可以设置直接跳转的页面页码,为了不影响之前的代码,可以使用trait继承来实现,如代码清单3-32所示。
代码清单3-32:使用trait继承扩展功能

代码清单3-32中定义了Paginate,并使用冒号代表继承其他trait。代码中Page+PerPage表示Paginate同时继承了Page和PerPage这两个trait。总体来说,trait名后面的冒号代表trait继承,其后跟随要继承的父trait名称,如果是多个trait则用加号相连。
代码第6行为泛型T实现了Paginate,并且包括空的impl块。整行代码的意思是,为所有拥有Page和PerPage行为的类型实现Paginate。
然后就可以使用set_skip_page方法了,如代码清单3-33所示。