本章覆盖有:
- 不使用trait,继承方式实现关联类型的函数
- Rust面向对象和C++面向对象的区别
- 那些trait可以实现哪些type,哪些不能
- 如何指定一个方法更改对象
- 构造对象的一些约定方法
- 为什么Rust不适用数据继承
- 什么是静态派遣,什么是动态派遣,如何实现,如何使用
¶Inherent Implementations
前面章节,我们看到如何解决下面的问题。你有一个结构体Stru,用于两个方面:用作名称空间,里面有一个函数f1,用作表达式Stru::f1(500_000)的调用;用作创建实例,实例名为s,这个实例可以调用f2方法,例如s.f2(456).x。
1 | trait Tr { |
结果将打印:false 24。
首先,Tr被声明,带有两个方法签名,f1和f2,Stru结构体被声明。然后,traitTr有该结构体的实现。最后实例化结构体变量,并调用这两个方法。
这种模式很常见,下面有一种简化的写法,
1 | struct Stru { |
这段代码将trait部分移除了,但需要推断trait的名字;因此,对于impl语句,它直接作用于类型,所以不需要trait。这种类型有继承实现。
从面向对象的角度,它表示:我们有一个自定义类型,Stru,带有某些数据成员,x和y;以及某些方法,f1和f2。
C++的类似实现如下,
1 |
|
Rust方法中,参数以self开头的称为“对象方法object methods”;不以self开头的称为“类方法class methods”。
在一个对象方法内,self关键表示当前对象,类似其它面向对象语言的self或this。
要调用带有self参数的方法,使用点操作,如s.f2(456);调用不带self参数的方法,使用函数调用方式,语法像Stru::f1(500_000),类型名后带两个冒号,其后跟着方法名。
Rust和C++字段访问方式的不同在于,Rust中必须写self.x,但C++或其它语言对应可能是this -> x,甚至可以不写,例如Java中的字段this.x和x都一样。
Rust和其它面向对象语言的不同在于,大部分面向对象语言对当前对象的参考(this、self或Me)总是一个指针(pointer)或一个引用(reference)。在Rust中,方法前面中的&self是个引用reference,self则是当前对象的一个拷贝。
¶Peculiarities of Rust Object-Orientation
Rust和其它面向对象语言还有几点不同的地方:
1 | S::f2(); |
impl的实现只要在同一个范围,可以不用关心它的位置和顺序。结构体和函数也可以在调用之后再定义。不过为了便于阅读,通常会先声明,后面再使用。
在同一个范围内,只允许有一个struct S语句;而对于impl S语句可以有多个。
1 | struct S1 {} |
在Rust中,同一个范围不允许有同名函数。一个类型表示一个范围。因此,对于S1类型,不能有两个f的方法,即使它有不同的参数。
1 | enum Continent { |
在Rust中,不仅可以在结构体添加方法,其它任何定义的类型都可以,诸如枚举和元组-结构体。
但原生数据类型不能直接添加方法。
1 | impl i32 {} |
这段代码尝试给i32原生类型添加方法,即使方法体是空的,编译器会报错:“only a single inherent implementation marked with #[lang = "i32"] is allowed for the i32 primitive”。意思是说,i32原生类型仅能有一处实现,也就是仅能在语言自身和标准库中提供。
另外也不能直接对标准库或其它库中的非原生类型添加方法,
1 | impl Vec<i32> {} |
对于这段代码,编译器会报错:“cannot define inherent impl for a type outside of the crate where the type is defined.”。这里的“crate”指一个程序或一个库。因为Vec泛型类型被定义在标准库,这段错误信息告诉你,Vec不能在标准库的外部继承实现。
对于trait,也不能在标准库或其它库的外部实现,
1 | impl std::iter::Iterator for i32 {} |
这段代码,编译器会报错:“only traits defined in the current crate can be implemented for arbitrary types”。意思是说,“Iterator”并没有被声明在你的代码范围内,“i32”没有声明在代码中,trait不能对该类型实现。
所以,反过来说,定义在可见范围的任何类型、任何trait都可以实现,
1 | trait Tr { |
结果打印:“Tr::f1 Tr::f2”。
任何类型都可以通过trait实现代码,
1 | struct Pair(u32, u32); |
将会打印:“None”。
首先是“Pair”已经定义,以及“Iterator”定义在标准库中,被用来实现“Pair”类型。两者都在作用域可见并且不冲突。
总结!
- 如果
Ty是一个类型,允许有impl Ty,要求Ty被声明在当前crate。 - 如果
Tr是一个trait,允许有impl Tr for Ty,要求Tr或Ty被声明在当前crate,不能两者都是标准库或其它库的一部分。
Tr在当前rate |
Tr在其它crate |
|
|---|---|---|
Ty在当前crate |
impl Tr for Ty 允许 |
impl Tr for Ty 允许 |
Ty在其它crate |
impl Tr for Ty 允许 |
impl Tr for Ty 不合法 |
¶Mutating Methods
Rust中,任何不带mut关键字的,都是immutable的。这对于虚拟参数(pseudo-argument)self也一样。如果想通过方法,更改其作用的对象,需要带上关键字mut。
1 | struct S { x: u32 } |
C++的等价实现如下,
1 |
|
¶Constructors
每次用到构造对象时,我们都需要指定它的所有字段。
为了独立于方法实现的方式处理对象,某些语言会提供“构造器”这个特性。Rust中也提供了一个或多个方法,不需要接收self参数,但有Self返回的实现。这类方法就是构造器,Rust中没有构造器的明确语法写法,但有一些惯例(convention)。
1 | struct Number { |
new和from方法是构造器。按照惯例,不带参数的构造器命名为new,带有一个参数的构造器命名为from。然而,通常有很多构造器仅带一个参数的;这些构造器中仅有一个命名为from。
这种惯例在标准库中有实例,
1 | let a = String::new(); |
¶Composition Instead of Inheritance
面向对象中有三种继承:数据继承、方法实现继承、方法接口继承。Rust中不支持数据继承,因为它会带来很多问题,Rust中使用组合方式来替代数据继承的实现。
假设我们有一个类型,它表示将字符文本画在屏幕上,另外要创建一个类型表示这个文本的框。为了简单,用控制台打印,替代画文本,用中括号代替这个矩形。
1 | struct Text { characters: String } |
第二条语句定义了两个方法:from,它是一个构造器;draw,打印输出字符内容。
现在想利用已有的结构和方法,来创建一个新的结构BoxedText。它是继承的一种常见方法。
在Rust中,如其使用继承,你可以创建一个BoxedText结构体来“包含”Text类型对象。然后创建对应的方法封装这个类型with_text_and_borders。这段代码中,代码复用出现在几个地方:
struct BoxedText的第一个字段是Text类型,它复用了数据结构,BoxedText构造器用到了Text::from(text),它复用了Text的构造器,BoxedText的方法体draw内,用到了self.text.draw();。它复用了Text的方法draw,
¶Memory Usage of Composition
组合和继承的内存使用没有区别。它们都需要内存,
1 | struct Base1 { |
打印输出为:“8 0 8 16”。Base1是一个仅包含8字节数的结构体,所以它占8个字节;Base2结构体不包含任何东西,占0个字节;Derived1是一个包含两个结构体的结构体,一个占8,一个占0,总共占8个字节;Derived2是一个包含8字节结构体,以及一个8字节字段,总共占16字节。我们看到内存使用是非常高效的。
¶Static Dispatch
Rust不是动态语言,所以,下面写法是不允许的,
1 | fn draw_text(txt) { |
这里希望,如果txt的类型是Text,则调用Text对应的draw方法;如果txt的类型是BoxedText,则调用BoxedText的方法draw。因为Rust是强静态语言,要实现这个方案,有两种不等价的实现方式,
1 | trait Draw { |
这里定义了泛型函数,并使用where从句确定类型边界。我们需要在这里引申解析静态派遣(static dispatch)这个概念。
首先声明了Draw,作为一个对象,拥有drawn的能力。
然后Text和BoxedText类型被声明,有对应的方法,有两个构造函数Text::from和BoxedText::with_text_and_borders;它们的draw函数的实现都继承来自Draw。
SOLUTION 1中的方法,draw_text泛型方法接收类型参数T,T是任何实现了Draw的类型。
因此,不乱编译器计数器在哪里调用draw_text函数,它会决定参数的类型,并检测该类型是否实现Draw。如果没有对应类型,编译器报错,若有具体的类型,会生成具体版本的draw_text函数,泛型函数体内的draw方法的调用,会被替换为对应T的实现的draw的方法。
这种技术称为“静态派遣static dispatch”。在计算机科学中,dispatch表示有几个同名函数时,选择调用哪个函数。在这段程序中,有两个函数命名为draw,因此派遣从两者中选择一个。在该程序中,选择由编译器处理,在编译期,这种派遣是“静态的static”。
¶Dynamic Dispatch
上面的程序可以稍作改变,改变最后几行代码,
1 | // SOLUTION 1/bis // |
这里把接收参数,改为了一个reference,即在方法签名的参数带上&,以及两处调用带上&。
这种方案仍然是静态派遣。因此,可以看到静态派遣工作在值传递(pass-by-value)和引用传递(pass-by-reference)上。
上面的代码可以改变下,
1 | // SOLUTION 2 // |
该程序保留原来的行为,但使用了另一种技术。仅改变了draw_text的签名,删除了T类型参数,删除了where从句,参数用&Draw替换了&T。现在,由原来的泛型函数,替换为具体的函数,它的参数是对trait的一个引用。
不同的是,一个trait不是一个类型(type)。你不能声明一个变量或一个函数参数用trait来表示它的类型。但对trait的reference是一个有效的类型。然而,它不是普通的引用。
在第一个地方,如果它是一个普通引用,它不能将引用,传递Text或BoxedText中函数的参数;但实际上,它是允许的,考虑如下,
1 | trait Tr {} |
这里bool类型实现了Trtrait,所以&true的引用的值类型是bool,可以初始化给变量_a,_a是Tr的一个引用。
相反,下面写法是不合法的,
1 | trait Tr {} |
这里,bool 没有Tr的实现,因此&true这个对bool的值引用,不能被初始化为Tr的引用。
通常地,任何对类型T的引用,都可以初始化为一个实现T的trait的一个引用。将参数传递给函数,是一种初始化处理,因此任何对类型T的引用,可以作为函数参数进行传递,这个参数引用的trait是T的实现。
在第二处,如果&Draw是一个普通指针,txt是指针的类型,表达式txt.draw()会调用相同的函数,取决于引用对象txt的名字。如其说需要一个dispatch,实际我们需要的是,当draw_text接收一个Text时,Text类型关联的draw方法被调用;当draw_tet接收一个BoxedText时,BoxedText类型关联的draw方法被调用。
所以,这里的&Draw并不是一个普通的指针,而一个能够根据引用对象的类型,选择调用方法的指针。这是一种派遣(dispatch),但发生在运行时,因此叫做“动态派遣(dynamic dispatch)”。
动态派遣在C++中的通过virtual关键字处理,尽管机制略有不同。
¶Implementation of References to Traits
回到原来代码关于派遣的问题,将最后几行替换如下代码,
1 | use st::mem::size_of_val; |
在64位目标机器上,会打印:“24 8 8, 32 8 8, 24 16 8 Hello, 32 16 8 [Hi]”。
size_of_val定义在标准库的一个泛型函数,接收对象的引用,返回该引用对象的字节大小。
首先,greeting变量被处理。它的类型是Text结构体,仅包含一个String对象。我们已经探讨过String对象在栈上占24个字节,附带在堆一个缓冲区。这个缓冲区不会在size_of_val函数的计算范围内。
接着打印Text引用的大小,Text的引用是普通引用,占8个字节。
类似地,boxed_greeting变量是个结构体,有两个char对象。每个占4个字节,一共有24 + 4 + 4 = 32字节。
对于表达式&greeting,有类型&Text,它作为参数传递给draw_text函数,该函数实例化txt参数,参数类型是&Draw。
由于txt是一种引用,所以可以由表达式size_of_val(txt)计算。它会返回引用对象的大小。但就是哪个才是&Draw的引用对象?明显,一定不是Draw,因为Draw不是类型。实际上,在编译期不能确定。它需要运行期,有初始化txt参数的表达式决定。首先,第一次接收的txt参数的引用类型是Text,占24个字节。
当接收的txt参数的引用类型是BoxedText时,它占32个字节,将被打印。
回到greeting的调用处,我们发现表达式size_of_val(&txt)的值是16。这很奇怪。这个表达式是求类型&Draw对象的大小,由类型&Text的对象初始化。所以,实际上用了一个常规8字节引用来初始化一个16字节的trait引用?为什么对trait的引用这么大?
实际上,任何对trait的引用有两个字段。第一个字段,是初始化引用的一个拷贝;第二个字段,是一个指针,用于选择合适“版本”的draw函数,或者说其它函数的动态派遣。它的名字是“虚拟表指针,virtual table pointer”。该名称来源于C++。
最后,trait的引用的引用被打印,它是个常规引用,所以占8个字节。
¶Static vs. Dynamic Dispatch
我们可以用静态派遣,也可以用动态派遣,哪个更适合?
“静态static”意味着“编译期”,“动态dynamic”意味着“运行期”,静态要求更多的编译时间,以及生成更多更快的代码,但如果编译期没有足够的可用信息,动态方案是唯一可能的选择。
假设将原来的示例程序更改一下需求,要求如果字符串是“b”,则输出带边框,否则,直接输出文本。
使用静态派遣,程序最后部分会变为,
1 | // SOLUTION 1/ter // |
当使用动态派遣,
1 | // SOLUTION 2/bis // |
静态派遣要求写几个函数调用,动态派遣允许你将选择的对象派遣给变量dr,然后仅需要些一个函数接收这个变量。
另外,静态派遣使用了泛型方法,这个技术可以会导致代码膨胀,可能最后会变得越来越慢。