rust入坑小记-11-面向对象与不安全
4 rust与面向对象式编程
面向对象编程(Object-Oriented Programming,OOP)是一种模式化编程方式。现代众多语言基本都支持面向对象范式,例如C++、Java、python等等。rust则并不完全是面向对象的,在一些定义下,rust是面向对象的,在其他定义下,rust则不是。至于rust到底遵循哪一种编程范式,至今仍有争论。rust是基于表达式的编程语言,但它也是面向过程的,同时提供了函数式编程的一些特性。这里并不会过多地讨论rust到底遵循哪一种确定的范式,就像我在Go面向对象中提到的:面向对象只是一种实现形式,本章主要就是介绍如何在rust中实现面向对象设计模式,并讨论这么做与利用rust自身的一些优势实现的方案相比有什么取舍。
4.1 面向对象语言的特点
面向对象的特性为:封装、继承、多态。另外,对象(Object)也是整个理念的重要概念,它来源于 20 世纪 60 年代的 Simula 编程语言。
对象包含数据和行为
由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(Addison-Wesley Professional, 1994)编写的书 Design Patterns: Elements of Reusable Object-Oriented Software 被俗称为 The Gang of Four (字面意思为 “四人帮”),它是面向对象编程模式的目录。它这样定义面向对象编程:
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.
面向对象的程序是由对象组成的。一个对象包含数据和操作这些数据的过程。这些过程通常被称为方法或操作。
在这个定义下,rust是面向对象的:结构体和枚举包含数据而 impl 块提供了在结构体和枚举之上的方法。虽然带有方法的结构体和枚举并不被称为对象,但是参考 The Gang of Four 中对象的定义,它们提供了与对象相同的功能。
封装
另一个通常与面向对象编程相关的方面是封装(encapsulation)的思想:对象的实现细节不能被使用对象的代码获取到。所以唯一与对象交互的方式是通过对象提供的公有 API;使用对象的代码无法深入到对象内部并直接改变数据或者行为。封装使得改变和重构对象的内部时无需改变使用对象的代码。
rust中,可以使用 pub 关键字来决定模块、类型、函数和方法是公有的,而默认情况下其他一切都是私有的。比如,我们可以定义一个包含一个 i32 类型 vector 的结构体AveragedCollection 。结构体也可以有一个字段,该字段保存了 vector 中所有值的平均值。这样,希望知道结构体中的 vector 的平均值的人可以随时获取它,而无需自己计算。换句话说,AveragedCollection 会为我们缓存平均值结果。
1 | pub struct AveragedCollection { |
注意,正如前面控制结构体和枚举的公有中介绍的,结构体自身被标记为 pub,这样其他代码就可以使用这个结构体,但是在结构体内部的字段仍然是私有的。这是非常重要的,因为我们希望保证变量被增加到列表或者被从列表删除时,也会同时更新平均值。可以通过在结构体上实现 add、remove 和 average 方法来做到这一点,
1 | impl AveragedCollection { |
公有方法 add、remove 和 average 是修改 AveragedCollection 实例的唯一方式。当使用 add 方法把一个元素加入到 list 或者使用 remove 方法来删除时,这些方法的实现同时会调用私有的 update_average 方法来更新 average 字段。
list 和 average 是私有的,所以没有其他方式来使得外部的代码直接向 list 增加或者删除元素,否则 list 改变时可能会导致 average 字段不同步。average 方法返回 average 字段的值,这使得外部的代码只能读取 average 而不能修改它。
因为我们已经封装好了 AveragedCollection 的实现细节,将来可以轻松改变类似数据结构这些方面的内容。例如,可以使用 HashSet<i32> 代替 Vec<i32> 作为 list 字段的类型。只要 add、remove 和 average 公有函数的签名保持不变,使用 AveragedCollection 的代码就无需改变。相反如果使得 list 为公有,就未必都会如此了: HashSet<i32> 和 Vec<i32> 使用不同的方法增加或移除项,所以如果要想直接修改 list 的话,外部的代码可能不得不做出修改。
如果封装是一个语言被认为是面向对象语言所必要的方面的话,那么rust满足这个要求。在代码中不同的部分使用 pub 与否可以封装其实现细节。
继承
继承(Inheritance)是一个很多编程语言都提供的机制,一个对象可以定义为继承另一个对象定义中的元素,这使其可以获得父对象的数据和行为,而无需重新定义。
如果一个语言必须有继承才能被称为面向对象语言的话,那么rust就不是面向对象的。因为没有宏则无法定义一个结构体继承父结构体的成员和方法。
然而,如果你过去常常在你的编程工具箱使用继承,根据你最初考虑继承的原因,rust也提供了其他的解决方案。
选择继承的原因是:一旦为一个类型实现了特定行为,继承可以对一个不同的类型重用这个实现。rust代码中可以使用默认特征方法实现来进行有限的共享,在默认实现这一小节中,介绍过这一点。任何实现了 Animal 特征的类型都可以使用 introduce 方法而无须进一步实现。这类似于父类有一个方法的实现,而通过继承子类也拥有这个方法的实现。当实现 Animal 特征时也可以选择覆盖 introduce 的默认实现,这类似于子类覆盖从父类继承的方法实现。
另外,在深入理解Fn特征中,也介绍过定义依赖于某个特征的特征:
1 | trait A {} |
特征之间的继承也可以看作rust所部分实现的面向对象式功能。
多态
另外一个使用继承的原因与类型系统有关:表现为子类型可以用于父类型被使用的地方。这也被称为多态(polymorphism),这意味着如果多种对象共享特定的属性,则可以相互替代使用。
很多人将多态描述为继承的同义词。不过它是一个有关可以用于多种类型的代码的更广泛的概念。对于继承来说,这些类型通常是子类。 rust则通过泛型来对不同的可能类型进行抽象,并通过特征约束对这些类型所必须提供的内容施加约束。这有时被称为约束参数多态(bounded parametric polymorphism)。
近来继承作为一种语言设计的解决方案在很多语言中失宠了,因为其时常带有共享多于所需的代码的风险。子类不应总是共享其父类的所有特征,但是继承却始终如此。如此会使程序设计更为不灵活,并引入无意义的子类方法调用,或由于方法实际并不适用于子类而造成错误的可能性。
另外某些语言还只允许单继承(意味着子类只能继承一个父类),进一步限制了程序设计的灵活性。
因为种种原因,rust选择了一个不同的途径,使用特征对象而不是继承。
4.2 特征对象
让我们看一下rust中的特征对象如何实现多态。在前面返回实现了特征的类型小节中,曾经介绍了这样的例子:
1 | struct Dog { |
其中 Dog 和 Cat 都实现了 Animal 特征,因此上面的函数试图通过返回 impl Summary 来返回这两个类型,但是编译器却报错了,这是由于 impl Trait 的返回值类型并不支持多种不同的类型返回。
在使用动态数组和枚举来存储不同类型的数据小节中,我们也谈到了动态数组只能存储同种类型元素的局限。在当时,我们通过组合使用动态数组和枚举的替代方案解决了这一点,然而有时,对象集合并不能明确地知道,并且还希望库用户在特定情况下能够扩展有效的类型集合。
为了解决上述的问题,这里将创建一个图形用户接口(Graphical User Interface,GUI)工具的例子,它通过遍历列表并调用每一个项目的 draw 方法来将其绘制到屏幕上 —— 这是一个 GUI 工具的常见技术。
在编写代码的时候,我们不可能知晓并定义所有其他程序员希望创建的类型。我们所知晓的是需要一个统一的类型来处理这些对象,并需要能够对其中每一个值调用 draw 方法。这里无需知道调用 draw 方法时具体会发生什么,只要该值会有那个方法可供我们调用。
在拥有继承的语言中,可以定义一个名为 Component 的类,该类上有一个 draw 方法。其他的类比如 Button、Image 和 SelectBox 会从 Component 派生并因此继承 draw 方法。它们各自都可以覆盖 draw 方法来定义自己的行为,但是框架会把所有这些类型当作是 Component 的实例,并在其上调用 draw。不过这些在rust中需要另寻出路。
定义通用行为的特征
为了实现这个GUI,让我们定义一个 Draw 特征,其中包含名为 draw 的方法。接着可以定义一个存放特征对象(trait object)的 vector:
1 | pub trait Draw { |
我们通过指定某种指针来创建特征对象,可以使用 & 引用或 Box<T> 智能指针,还有 dyn 关键字,以及指定相关的特征(在动态大小类型中会介绍特征对象必须使用指针的原因),我们可以使用特征对象代替泛型或具体类型。任何使用特征对象的位置,rust的类型系统会在编译时确保任何在此上下文中使用的值会实现其特征对象的特征。如此便无需在编译时就知晓所有可能的类型。
具体来说,上面的代码中,定义了一个存放了名叫 components 的 vector 的结构体 Screen。这个 vector 的类型是 Box<dyn Draw>,此为一个特征对象:它是 Box 中任何实现了 Draw 特征的类型的替身。
接下来,我们为Screen定义一个方法,该方法会对其 components 上的每一个组件调用 draw 方法:
1 | impl Screen { |
这与定义使用了带有特征约束的泛型类型参数的结构体不同。泛型类型参数一次只能替代一个具体类型,而特征对象则允许在运行时替代多种具体类型。
例如,如果定义
Screen结构体来使用泛型和特征约束:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}这就限制了
Screen实例必须拥有components内包含了同一个类型T的组件列表。如果只需要相同类型的集合,则倾向于使用泛型和特征约束,因为其定义会在编译时采用具体类型进行单态化。不过,这不符合我们本节内容的需求。
通过使用特征对象的方法,一个 Screen 实例可以存放一个既能包含 Box<Button>,也能包含 Box<TextField> 的 Vec<T>。接下来就先定义这两个类型:
1 | pub struct Button { |
这里增加了两个实现了 Draw 特征的类型,Button和SelectBox,二者都拥有一些字段。由于这并不是真实的GUI库,我们只是模拟,因此draw方法中没有具体的实现。
现在,我们就可以让使用者在他们的 main 函数中创建一个 Screen 实例。至此可以通过将 SelectBox 和 Button 放入 Box<T> 转变为特征对象来增加组件。
1 | fn main() { |
当编写库的时候,我们不知道何人会在何时增加 SelectBox 类型,不过 Screen 的实现能够操作并绘制这个新类型,因为 SelectBox 实现了 Draw 特征,这就意味着它实现了 draw 方法。
在python中有一个概念叫做鸭子类型(duck typing):如果一只鸟走起来像鸭子,叫起来像鸭子,那么它就是一只鸭子。
在上面 Screen 的 run 实现中,run 并不需要知道各个组件的具体类型是什么。它并不检查组件是 Button 或者 SelectBox 的实例。通过指定 Box<dyn Draw> 作为 components 动态数组中值的类型,我们就定义了 Screen 为需要可以在其上调用 draw 方法的值。
使用特征对象和rust类型系统来进行类似鸭子类型操作的优势是无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现特征对象所需的特征则rust不会编译这些代码。比如:
1 | fn main() { |
下面是本节实例中完整的代码:
1 | pub trait Draw { |
注意 dyn 不能单独作为特征对象的定义,原因是特征对象可以是任意实现了某个特征的类型,编译器在编译期不知道该类型的大小,不同的类型大小是不同的,而 &dyn 和 Box<dyn> 都是指针,在编译期已知大小。
特征对象执行动态分发
在泛型中,当对泛型使用特征约束时编译器会执行单态化处理:编译器为每一个被泛型类型参数代替的具体类型生成了函数和方法的非泛型实现。单态化产生的代码在执行静态分发(static dispatch)。静态分发发生于编译器在编译时就知晓调用了什么方法的时候。这与动态分发 (dynamic dispatch)相对,这时编译器在编译时无法知晓调用了什么方法。在动态分发的场景下,编译器生成的代码到运行时才能确定调用了什么方法。
当使用特征对象时,rust必须使用动态分发。编译器无法知晓所有可能用于特征对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,rust在运行时使用特征对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。尽管编写GUI示例代码的过程中确实获得了额外的灵活性,但仍然需要权衡取舍性能上的问题。
下面这张图很好地解释了静态分发 Box<T> 和动态分发 Box<dyn Trait> 的区别:

结合上文的内容和这张图可以了解:
- 特征对象大小不固定:这是因为,对于特征
Draw,类型Button可以实现特征Draw,类型SelectBox也可以实现特征Draw,因此特征没有固定大小 - 几乎总是使用特征对象的引用方式,如
&dyn Draw、Box<dyn Draw>- 虽然特征对象没有固定大小,但它的引用类型的大小是固定的,它由两个指针组成(
ptr和vptr),因此占用两个指针大小 - 一个指针
ptr指向实现了特征Draw的具体类型的实例,也就是当作特征Draw来用的类型的实例,比如类型Button的实例、类型SelectBox的实例 - 另一个指针
vptr指向一个虚拟方法表(virtual method table),虚拟方法表也通常被称为虚函数表(vtable),vtable中保存了类型Button或类型SelectBox的实例对于可以调用的实现于特征Draw的方法。当调用方法时,直接从vtable中找到方法并调用。之所以要使用一个vtable来保存各实例的方法,是因为实现了特征Draw的类型有多种,这些类型拥有的方法各不相同,当将这些类型的实例都当作特征Draw来使用时(此时,它们全都看作是特征Draw类型的实例),有必要区分这些实例各自有哪些方法可调用
- 虽然特征对象没有固定大小,但它的引用类型的大小是固定的,它由两个指针组成(
简而言之,当类型 Button 实现了特征 Draw 时,类型 Button 的实例对象 btn 可以当作特征 Draw 的特征对象类型来使用,btn 中保存了作为特征对象的数据指针(指向类型 Button 的实例数据)和行为指针(指向 vtable)。
一定要注意,此时的 btn 是 Draw 的特征对象的实例,而不再是具体类型 Button 的实例,而且 btn 的 vtable 只包含了实现自特征 Draw 的那些方法(比如 draw),因此 btn 只能调用实现于特征 Draw 的 draw 方法,而不能调用类型 Button 本身实现的方法和类型 Button 实现于其他特征的方法。也就是说,btn 是哪个特征对象的实例,它的 vtable 中就包含了该特征的方法。
trait 对象需要类型安全
只有对象安全(object-safe)的特征可以实现为特征对象。这里有一些复杂的规则来实现特征的对象安全,但在实践中,只有两个相关的规则。如果一个特征中定义的所有方法都符合以下规则,则该特征是对象安全的:
- 返回值不是
Self - 没有泛型类型的参数
Self 关键字是我们在特征与方法上的实现的别称,特征对象必须是对象安全的,因为一旦使用特征对象,rust将不再知晓该实现的返回类型。如果一个特征的方法返回了一个 Self 类型,但是该特征对象忘记了 Self 的确切类型,那么该方法将不能使用原本的类型。当特征使用具体类型填充的泛型类型时也一样:具体类型成为实现特征的对象的一部分,当使用 trait 对象却忘了类型是什么时,无法知道应该用什么类型来填充泛型类型。
一个非对象安全的特征例子是标准库中的 Clone 特征:
1 | pub trait Clone { |
String 类型实现了 Clone 特征,当我们在 String 的实例对象上调用 clone 方法时,我们会得到一个 String 类型实例对象。相似地,如果我们调用 Vec<T> 实例对象上的 clone 方法,我们会得到一个 Vec<T> 类型的实例对象。clone 方法的标签需要知道哪个类型是 Self 类型,因为 Self 是它的返回类型。
当我们尝试编译一些违反特征对象的对象安全规则的代码时,我们会收到编译器的提示。
1 | pub struct Screen { |
这个错误意味着我们不能将此特征用于特征对象。如果你想了解更多有关对象安全的细节,请参考 Rust RFC 255 或查看 Rust Reference。
5 不安全的rust
目前为止讨论过的代码都有rust在编译时会强制执行的内存安全保证。然而,rust还隐藏有第二种语言,它不会强制执行这类内存安全保证:这被称为不安全rust(unsafe Rust)。它与常规rust代码无异,但是会提供额外的超能力。
在之前很多例子中可以看出,尽管代码可能没问题,但如果rust编译器没有足够的信息可以确定,它将拒绝代码。
不安全rust之所以存在,是因为静态分析本质上是保守的。当编译器尝试确定一段代码是否支持某个保证时,拒绝一些合法的程序比接受无效的程序要好一些。这必然意味着有时可能是合法的,但如果rust编译器没有足够的信息来确定,它将拒绝该代码。在这种情况下,可以使用不安全代码告诉编译器,“相信我,我知道我在干什么。” 不过千万注意,使用不安全rust风险自担:如果不安全代码出错了,比如解引用空指针,可能会导致不安全的内存使用。
另一个rust存在不安全一面的原因是:底层计算机硬件固有的不安全性。如果rust不允许进行不安全操作,那么有些任务则根本完成不了。rust需要能够进行像直接与操作系统交互,甚至于编写你自己的操作系统这样的底层系统编程。这也是 rust语言的目标之一。
5.1 不安全的超能力
可以通过 unsafe 关键字来切换到不安全rust,接着可以开启一个新的存放不安全代码的块。这里有五类可以在不安全rust中进行而不能用于安全rust的操作,它们称之为不安全的超能力(unsafe superpowers),这些超能力是:
- 解引用裸指针
- 调用不安全的函数或方法
- 访问或修改可变静态变量
- 实现不安全特征
- 访问
union的字段
有一点很重要,unsafe 并不会关闭借用检查器或禁用任何其他rust安全检查:如果在不安全代码中使用引用,它仍会被检查。unsafe 关键字只是提供了那五个不会被编译器检查内存安全的功能。你仍然能在不安全块中获得某种程度的安全。
再者,unsafe 不意味着块中的代码就一定是危险的或者必然导致内存安全问题:其意图在于作为程序员你将会确保 unsafe 块中的代码以有效的方式访问内存。
人是会犯错误的,错误总会发生,不过通过要求这五类操作必须位于标记为 unsafe 的块中,就能够知道任何与内存安全相关的错误必定位于 unsafe 块内。保持 unsafe 块尽可能小,如此当之后调查内存 bug 时就会感谢你自己了。
为了尽可能隔离不安全代码,将不安全代码封装进一个安全的抽象并提供安全 API 是一个好主意,当我们学习不安全函数和方法时会讨论到。标准库的一部分被实现为在被评审过的不安全代码之上的安全抽象。这个技术防止了 unsafe 泄露到所有你或者用户希望使用由 unsafe 代码实现的功能的地方,因为使用其安全抽象是安全的。
让我们按顺序依次介绍上述五个超能力,同时我们会看到一些提供不安全代码的安全接口的抽象。
5.2 解引用裸指针
在之前悬垂引用小节,提到了编译器会确保引用总是有效的。不安全rust有两个被称为裸指针(raw pointers)的类似于引用的新类型。和引用一样,裸指针是不可变或可变的,分别写作 *const T 和 *mut T。这里的星号不是解引用运算符;它是类型名称的一部分。在裸指针的上下文中,不可变意味着指针解引用之后不能直接赋值。
裸指针与引用和智能指针的区别在于
- 允许忽略借用规则,可以同时拥有不可变和可变的指针,或多个指向相同位置的可变指针
- 不保证指向有效的内存
- 允许为空
- 不能实现任何自动清理功能
通过去掉rust强加的保证,裸指针本质上等同于C或C++的指针,你可以放弃安全保证以换取性能或使用另一个语言或硬件接口的能力,此时rust的保证并不适用。
下面的代码从引用同时创建不可变和可变裸指针:
1 | let mut num = 5; |
注意这里没有引入 unsafe 关键字。可以在安全代码中创建裸指针,只是不能在不安全块之外解引用裸指针,稍后便会看到。
这里使用 as 将不可变和可变引用强转为对应的裸指针类型。因为直接从保证安全的引用来创建他们,可以知道这些特定的裸指针是有效,但是不能对任何裸指针做出如此假设。
作为展示,接下来会创建一个不能确定其有效性的裸指针,
1 | let address = 0x012345usize; |
这里创建一个指向任意内存地址(0x012345)的裸指针。尝试使用任意内存是未定义行为:此地址可能有数据也可能没有,编译器可能会优化掉这个内存访问,或者程序可能会出现段错误(segmentation fault)。虽然这是可行的,但通常没有理由去编写这样的代码。同样,可以在安全代码中创建裸指针,不过不能在安全代码中解引用裸指针和读取其指向的数据。
现在我们要做的就是对裸指针使用解引用运算符 *,这需要一个 unsafe 块:
1 | fn main(){ |
创建一个指针不会造成任何危险,只有当访问其指向的值时才有可能遇到无效的值。
注意这里创建了同时指向相同内存位置 num 的裸指针 *const i32 和 *mut i32。相反如果尝试同时创建 num 的不可变和可变引用,将无法通过编译,因为rust的所有权规则不允许在拥有任何不可变引用的同时再创建一个可变引用。通过裸指针,就能够同时创建同一地址的可变指针和不可变指针,若通过可变指针修改数据,则可能潜在造成数据竞争,因此需要非常小心这样的操作。
还有一种创建裸指针的方式,那就是基于智能指针来创建:
1 | let a: Box<i32> = Box::new(10); |
既然存在这么多的危险,为何还要使用裸指针呢?一个主要的应用场景便是调用 C 代码接口,这在下一小节调用不安全函数或方法中会讲到。另一个场景是构建借用检查器无法理解的安全抽象。
5.3 调用不安全函数或方法
定义与使用不安全函数
unsafe的第二个超能力是调用不安全函数或方法。不安全函数和方法与常规函数方法十分类似,除了其开头有一个额外的 unsafe。在此上下文中,关键字unsafe表示该函数具有调用时需要满足的要求,而rust不会保证满足这些要求。通过在 unsafe 块中调用不安全函数,表明我们已经阅读过此函数的文档并对其是否满足函数自身的契约负责。
不安全的函数需要使用unsafe关键字定义:
1 | unsafe fn dangerous() {} |
调用也需要在unsafe块中,通过 unsafe 块,我们向rust保证了我们已经阅读过函数的文档,理解如何正确使用,并验证过其满足函数的契约。另外,unsafe函数体本身也是有效的 unsafe 块,所以在不安全函数中进行另一个不安全操作时无需新增额外的 unsafe 块。
创建不安全代码的安全抽象
仅仅因为函数包含不安全代码并不意味着整个函数都需要标记为不安全的。事实上,将不安全代码封装进安全函数是一个常见的抽象。在标准库中有大量的安全函数,它们内部的一些实现包含了 unsafe 块。比如标准库中的函数split_at_mut,它需要一些不安全代码,让我们探索如何可以实现它。这个安全函数定义于可变切片之上:它获取一个 切片并从给定的索引参数开始将其分为两个切片。先来看一下用法:
1 | fn main() { |
这个函数无法只通过安全rust实现,尝试自己实现一个split_at_mut:
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) { |
此函数首先获取切片的长度,然后通过检查参数是否小于或等于这个长度来断言参数所给定的索引位于切片当中。该断言意味着如果传入的索引比要分割的切片的索引更大,此函数在尝试使用这个索引前panic。
之后我们在一个元组中返回两个可变的切片:一个从原始切片的开头直到 mid 索引,另一个从 mid 直到原切片的结尾。
如果尝试编译,会得到一个错误:
1 | error[E0499]: cannot borrow `*values` as mutable more than once at a time |
本质上借用切片的不同部分是可以的,因为这两个切片不会重叠,但是rust的借用检查器不能理解我们要借用这个切片的两个不同部分:它只知道我们借用了同一个切片两次。编译器还没有智能到能够理解这些。当我们知道某些事是可以的而 rust不知道的时候,就是触及不安全代码的时候了。
接下来,使用 unsafe 块和裸指针和一些不安全函数调用来实现 split_at_mut:
1 | use std::slice; |
切片是一个指向一些数据的指针,并带有该切片的长度。可以使用 len 方法获取切片的长度,使用 as_mut_ptr 方法访问切片的裸指针。在这个例子中,因为有一个 i32 值的可变切片,as_mut_ptr 返回一个 *mut i32 类型的裸指针,储存在 ptr 变量中(在5.2中提到过:可以在安全代码中创建裸指针,但不能使用)。我们保留索引 mid 位于切片中的断言。
接着是不安全代码:slice::from_raw_parts_mut 函数获取一个裸指针和一个长度来创建一个切片。这里使用此函数从 ptr 中创建了一个有 mid 个项的切片。之后在 ptr 上调用 add 方法并使用 mid 作为参数来获取一个从 mid 开始的裸指针,使用这个裸指针并以 mid 之后项的数量为长度创建一个切片。这两个切片都是可变的,最后将其返回。
slice::from_raw_parts_mut 函数是不安全的,因为它获取一个裸指针,并必须确信这个指针是有效的。裸指针上的 add 方法也是不安全的,因为其必须确信此地址偏移量也是有效的指针。因此必须将 slice::from_raw_parts_mut 和 add 放入 unsafe 块中以便能调用它们。通过观察代码,和增加 mid 必然小于等于 len 的断言,我们可以说 unsafe 块中所有的裸指针将是有效的切片中数据的指针。这是一个可以接受的 unsafe 的恰当用法。
我们无需将 split_at_mut 函数的结果标记为 unsafe,并可以在安全rust中调用此函数。我们创建了一个不安全代码的安全抽象,其代码以一种安全的方式使用了 unsafe 代码,因为其只从这个函数访问的数据中创建了有效的指针。
与此相对 slice::from_raw_parts_mut 在使用切片时很有可能会崩溃。这段代码获取任意内存地址并创建了一个长为一万的切片:
1 | use std::slice; |
我们并不拥有这个任意地址的内存,也不能保证这段代码创建的切片包含有效的 i32 值。试图使用臆测为有效的 values 会导致未定义的行为。当我们使用values时,会得到一个Segmentation fault的错误。
FFI
外部函数接口(Foreign Function Interface,FFI)是一个编程语言用以定义函数的方式,其允许不同(外部)编程语言调用这些函数。
由于rust语言仍在发展,许多功能和生态还处于空白状态,在这种情况下,能够调用其它语言现有的库是一种非常方便的选择,这省去了将这些优秀的库重写为rust的重复工作。另外,还可以在将其它语言的代码重构为rust时,先将相关代码引入到rust项目中,然后渐进式地重构。
使用 extern 函数调用外部代码
正如上文所说,在一些情况下,rust代码可能需要与其它语言编写的代码交互,为此rust有一个关键字extern,有助于创建和使用FFI。下面这个例子展示了如何集成 C 标准库中的 abs 函数。extern 块中声明的函数在rust代码中总是不安全的。因为其它语言不会强制执行rust的规则且rust无法检查它们,所以确保其安全是程序员的责任:
1 | extern "C" { |
在 extern "C" 块中,列出了我们希望能够调用的另一个语言中的外部函数的签名和名称。"C" 部分定义了外部函数所使用的应用二进制接口(application binary interface,ABI) —— ABI 定义了如何在汇编语言层面调用此函数。"C" ABI 是最常见的,并遵循 C 编程语言的 ABI。
从其它语言调用rust函数
也可以使用 extern 来创建一个允许其他语言调用rust函数的接口。不同于创建整个 extern 块,就在 fn 关键字之前增加 extern 关键字并为相关函数指定所用到的 ABI。还需增加 #[no_mangle] 注解来告诉rust编译器不要 mangle 此函数的名称。Mangling发生于当编译器将我们指定的函数名修改为不同的名称时,这会增加用于其他编译过程的额外信息,不过会使其名称更难以阅读。每一个编程语言的编译器在编译时都会以稍微不同的方式 mangle 函数名,所以为了使rust函数能在其它语言中指定,必须禁用rust编译器的 name mangling。
在如下的例子中,一旦其编译为动态库并从 C 语言中链接,call_from_c 函数就能够在 C 代码中访问:
1 |
|
extern 的使用无需 unsafe。
5.4 访问或修改可变静态变量
目前为止,我们都尽量避免讨论全局变量(global variables),rust确实支持他们,不过这对于rust的所有权规则来说是有问题的。如果有两个线程访问相同的可变全局变量,则可能会造成数据竞争。
全局变量在rust中被称为静态(static)变量,这种变量一般用于全局数据统计(如计数器、全局状态等):
1 | static HELLO_WORLD: &str = "Hello, world!"; |
这里定义和使用了一个不可变静态变量,静态(static)变量类似于常量(const),静态变量只能储存拥有 'static 生命周期的引用(见静态生命周期),这意味着rust编译器可以自己计算出其生命周期而无需显式标注。访问不可变静态变量是安全的。
常量与不可变静态变量的区别如下:
- 静态变量中的值有一个固定的内存地址,使用这个值总是会访问相同的地址。另一方面,常量则允许在任何被用到的时候复制其数据。
- 静态变量可以是可变的,常量则不可以。访问和修改可变静态变量都是不安全的。
- 存储在静态变量中的值必须要实现
Sync特征。
1 | static mut COUNTER: u32 = 0; |
上面的例子中,展示了如何声明、访问和修改名为 COUNTER 的可变静态变量。就像常规变量一样,我们使用 mut 关键来指定可变性。任何读写 COUNTER 的代码都必须位于 unsafe 块中。这段代码可以编译并如期打印出 COUNTER: 3,因为这是单线程的。拥有多个线程访问 COUNTER 则可能导致数据竞争。
拥有可以全局访问的可变数据,难以保证不存在数据竞争,这就是为何rust认为可变静态变量是不安全的。任何可能的情况,可以优先使用并发编程中介绍的内容,这样编译器就能检测不同线程间的数据访问是否是安全的。
5.5 实现不安全特征
unsafe 的另一个操作用例是实现不安全特征。当特征中至少有一个方法中包含编译器无法验证的不变式(invariant)时 trait 是不安全的。可以在 trait 之前增加 unsafe 关键字将特征声明为 unsafe,同时特征的实现也必须标记为 unsafe,比如:
1 | unsafe trait Foo { |
这样,相应的正确性由我们自己来保证。在为裸指针实现 Send 和 Sync中的Sync 和 Send 标记特征,编译器会自动为完全由 Send 和 Sync 类型组成的类型自动实现它们。作为例子,我们还为裸指针实现了 Send 或 Sync ,其中必须用到unsafe,这是因为rust不能验证我们的类型保证可以安全的跨线程发送或在多线程间访问,所以需要我们自己进行检查并通过 unsafe 表明。
5.6 访问union中的字段
最后一个超能力是是访问union中的字段,到目前为止还没有介绍过union,它主要用于和 C 代码中的union进行交互。 union 和 struct 类似,但是在一个实例中同时只能使用一个声明的字段。union主要用于和 C 代码中的联合体交互。访问联合体的字段是不安全的,因为rust无法保证当前存储在联合体实例中数据的类型。
1 |
|
可以看出,union 的使用方式与结构体很相似,但是前者的所有字段都共享同一个存储空间,意味着往 union 的某个字段写入值,会导致其它字段的值会被覆盖。
这与C中的
union特性相同,在C中,union的内存占用以成员列表中最大的数据类型为准,与结构体不同的是,union的所有成员占用同一段内存,修改一个成员会影响其余所有成员的值:
1
2
3
4
5 union A {
int aa;
char bb;
long long cc;
}foo;这里,最大的数据类型为
long long,它占用8字节内存。
更多细节,查看Unions了解。