[Rust编程之道笔记]所有权

Rust编程之道一书 第5章 所有权

Rust受现代c++的启发,引入智能指针来管理堆内存。在rust中,所有权是系统性的概念,是rust语言的基础设施。

5.1 通用概念

编程语言中的值主要分成两类:

  • 值类型(Value):数据直接存储在栈中的数据类型
  • 引用类型(Reference):将数据存在堆中,而栈中值存放指向堆中数据的地址(指针)

为了更精确的对复杂类型进行描述,引入值语义和引用语义:

  • 值语义(Value Semantic):按位复制以后,和原始对象无关
  • 引用语义(Reference Semantic):也叫指针语义。一般是指将数据存储于对内存中,通过栈内存的指针来管理堆内存的数据,并且引用语义禁止按位复制。

按位复制就是指栈复制,也叫浅复制,只复制栈上的数据。深复制就是对栈上和堆上的数据一起复制。

对于实现Copy trait的类型,其 clone 方法必须是按位复制的。

rust通过 Copy 这个标记 trait 将类型按值语义和引用语义做了精准的分类,帮助编译期检测出潜在的内存安全问题。

引用语义不能实现copy,但是可以实现 Clone 的 clone 方法,以实现深度复制,在需要时可以显示调用。

5.2 所有权机制

在所有权体制下,Rust引入新的语义:

  • 复制(copy):对应值语义。对于可以安全在栈上进行按位复制的类型,就只需要按位复制
  • 移动(move):对应引用语义。对于在堆上存储的数据,无法安全地在栈上进行按位复制。如果堆上的数据不变,只需要在栈上移动指向堆内存的指针地址,不仅保证了内存安全,也拥有与栈复制同样的性能。

一个值的所有权被转移给另外一个变量绑定的过程,就叫做所有权转移。

rust中每个值都有一个所有者,更进一步说就是,rust中分配的每块内存都有其所有者,所有者负责该内存的释放和读写权限,并且每次每个值只能有唯一的所有者,这就是rust的所有权机制(ownership)

所有权的特点

所有者拥有以下三种权限:

  • 控制资源(不仅仅是内存)的释放
  • 出借所有权,包括不可变(共享)的和可变(独占)的
  • 转移所有权

对于可以实现 Copy 的复制语义类型来说,所有权并未改变。对于复合类型来说,是复制还是移动,取决于其成员的类型。

  • 结构体:即使结构体的成员都是复制语义类型,但是rust也不会默认为其实现 Copy。需要手工添加 #[derive(Debug, Copy, Clone)]
  • 枚举:类似结构体
  • 元组:本身实现了 Copy,如果元素均为复制语义类型,则默认是按位复制,否则会执行移动语义。
  • 数组:类似元组
  • Option类型:类似元组

5.3 绑定、作用域和生命周期

Rust 使用 let 关键字来生命变量。let 有 let banding 之意,let 声明的变量实际不是传统意义上的变量,而是指一种绑定语义。let 绑定了标识符和内存,而且使得标识符对那块内存拥有了所有权,因此被称为“绑定”。

5.3.1 不可变与可变

不可变(Immutable)的优点:

  • 多线程并发时,不可变的数据可以安全地在线程间共享
  • 函数的“副作用”可以得到控制

Rust声明的绑定默认不可变,如果需要修改,Rust 提供关键字 mut 来声明可变绑定。

5.3.2 绑定的时间属性-声明周期

变量绑定具有“时空”双重属性:

  • 空间属性:指标识符与内存空间进行了绑定
  • 时间属性:指绑定的时效性,也就是它的生存周期

除了 let 声明外,还有一些场景会创建新的词法作用域:

  • 花括号{}

  • match 匹配

  • 循环语句

  • if let 和 while let 块

  • 函数

    函数参数是复制语义时按位复制,是移动语义时会转移所有权

  • 闭包

    闭包会创建新的作用域,对于环境变量来说有一下三种捕获方式:

    1. 对于复制语义类型,以不可变应用(&T)来捕获
    2. 对于移动语义类型,执行移动语义转移所有权来捕获
    3. 对于可变绑定,如果在闭包中包含对其进行修改的操作,则以可变引用(&mut)来捕获

5.4 所有权借用

引用与借用

引用(Reference)是Rust提供的一种指针语义。应用是基于指针的实现,和指针的区别是:

  • 指针保存的是指向内存的地址
  • 引用可以看做某块内存的别名(alias),使用引用需要满足编译期的各种安全检查规则。

引用分为不可变引用(使用&操作符)和可变引用(使用&mut操作符)。

在所有权系统中,引用&x可称为 x 的借用(Borrowing),通过 & 操作符来完成所有权的租借。借用所有权不会引起变量所有权的转移。

借用所有权会让所有者(owner)受到如下限制:

  • 在不可变借用期间,所有者不能修改资源,并且也不能再进行可变借用
  • 在可变借用期间,所有者不能访问资源,并且也不能再出借所有权

引用在离开作用域之时,就是归还所有权之时。

借用规则

为了保证内存安全,借用必须遵循以下三个规则:

  • 规则一:借用的生命周期不能长于出借方(拥有所有权的对象)的生命周期

  • 规则二:可变借用(引用)不能有别名(Alias,即其他不可变引用),因为可变借用具有独占性

  • 规则三:不可变借用(引用)不能再次出借为可变借用

规则一是为了防止出现悬垂指针,规则二和规则三总结为一条核心原则:共享不可变,可变不共享。规则二和规则三描述的不可变借用和可变借用就相当于内存的读写锁,同一时刻,只能拥有一个写锁,或者多个读锁,不能同时拥有。

Rust的借用检查带来如下好处:

  • 不可变借用保证了没有任何指针可以修改值的内存,便于将值存储在寄存器中
  • 可变借用保证了在写的时候没有任何指针可以读取值的内存,避免了脏读
  • 不可变借用保证了内存不会在读取之后被写入新数据
  • 保证了不可变借用和可变借用不相互依赖,从而可以对读写操作进行自由移动和重新排序

解引用会获得所有权。

5.5 生命周期参数

跨函数使用借用,需要显式地对借用参数或返回值使用生命周期参数进行标注。

5.5.1 显式生命周期参数

生命周期参数必须以单引号开头,参数名通常都是小写字母,比如 'a 。生命周期参数位于引用符号 & 后面,并使用空格来分割生命周期参数和类型。如:

&'a i32;
&'a mut i32;

标注生命周期参数并不能改变任何引用的生命周期长短,它只用于编译器的借用检查,来防止悬垂指针。

函数签名中的生命周期参数

函数签名中的生命周期参数使用如下标注语法:

fn foo<'a>(s: &'a str, t: &'a str) -> &'a str;

函数名后面的 <'a> 为生命周期参数的声明,与范型参数类似,必须先声明才能使用。

  • 输入生命周期(input lifetime):函数或者参数的生命周期
  • 输出生命周期(output lifetime):返回值的生命周期

函数签名的生命周期参数有这样的限制条件:输出(借用方)的生命周期长度必须不长于输入(出借方)的生命周期长度。(遵循借用规则一)

禁止在没有任何输入参数的情况下返回引用。因为明显会造成悬垂指针。

从函数中返回(输出)一个引用,其生命周期参数必须与函数的参数(输入)相匹配,否则,标记生命周期参数毫无意义。

函数生命中的 'a 可以看作是一个生命周期范型参数,输入引用和输出引用都标记为 'a 意味着输出引用(借用方)的生命周期不长于输入引用(出借方)的生命周期。

对于多个输入参数的情况,也可以标注不同的生命周期参数,如:

fn the_longest<'a, 'b: 'a>(s: &'a str, t: &'b str) -> &'a str;

'b: 'a 的意思是范型生命周期参数 'b 的存活时间长于范型生命周期参数 'a (即 'b outlive 'b)。

生命周期参数的目的:生命周期参数是为了帮助借用检查器验证非法借用。函数间传入和返回的借用必须相关联,并且返回的借用生命周期必须比出借方的生命周期短。

生命周期参数是为了帮助借用检查器验证合法的引用,消除悬垂指针。

结构体定义中的生命周期参数

结构体在含有引用类型成员的时候也需要标注生命周期参数。

struct Foo<'a> {
    part: &'a str,
}

这里的生命周期参数标记,实际是和编译器约定了一个规则:结构体实例的生命周期应短于或者等于任意一个成员的生命周期

方法定义中的生命周期参数

需要在 impl 关键字之后申明生命周期参数:

impl<'a> Foo<'a> {
    fun new(s: &'a str) -> Self {
        ......
    }
    ......
}

枚举和结构体对生命周期参数的处理方式是一样的。

静态生命周期参数

字符串字面量是全局静态类型,它的数据和程序代码一起存储于可执行文件的数据段中,其地址在编译器是已知的,无法更改。

在 rust 2018 版本中,使用 const 和 static 定义字符串字面量时,都可以省掉 ‘static 静态生命周期参数。

5.5.2 省略生命周期参数

Rust针对某些场景确定了一些常见的模式,将其编码到Rust编译器中,以便编译器可以自动补齐函数签名中的生命周期参数,这样就可以省略生命周期参数。

被硬编码进编译器的模式被成为生命周期省略规则(lifetime Elision Rule),一共包含三条规则:

  • 每个输入位置上省略的生命周期都将成为一个不同的生命周期参数
  • 如果只有一个输入生命周期的位置(不管是否省略),则该生命周期都将分配给输出生命周期
  • 如果存在多个输入生命周期的位置,但是其中包含找 &self 或者 &mut self,则 self 的生命周期将被分配给输出生命周期。

5.5.3 生命周期限定

生命周期参数可以像 trait 那样作为范型的限定,有以下两种形式:

  1. T: 'a : 表示 T 类型中的任何引用都要"活得" 和 'a 一样长
  2. T: Trait + 'a : 表示 T 类型必须实现 Trait 这个trait,并且T类型中任何引用都要"活得" 和 'a 一样长

5.5.4 trait对象的生命周期

trait 对象和生命周期有默认遵循的规则:

  • trait 对象的生命周期默认是 'static
  • 如果实现 trait 的类型包含 &'a X 或者 &'a mut X,则默认生命周期就是 'a
  • 如果实现 trait 的类型只有 T: 'a,则默认生命周期就是 'a
  • 如果实现 trait 的类型包含多个类似 T: 'a 的从句,则生命周期需要明确指定

5.6 智能指针与所有权

智能指针和普通引用的区别之一就是所有权的不同:

  • 智能指针拥有资源的所有权
  • 普通引用只是对所有权的借用

Box<T> 智能指针可以使用解引用操作符进行解引用。

之所以可以进行解引用,是因为 Box<T> 实现了 deref 方法:

impl<T: ?Sized> Deref for Box<T> {
    type Target = T;
    fn deref(&self) -> &T {
        &**self
    }
}

对于 Box<T> 类型来说,如果包含的类型T属于复制语义,则执行按位复制;如果属于移动语义,则移动所有权。

5.6.1 共享所有权 Rc<T>Weak<T>

Rust 中提供了 Rc<T> 智能指针来支持引用计数。

Rc<T> 可以将多个所有权共享给多个变量。 Rc<T> 主要用于系统共享堆上分配的数据可以供程序的多个部分读取的场景,并且主要确保共享的资源的析构函数都能被调用。

Rc<T> 是单线程引用计数指针,不是线程安全的类型。

  • 强引用: 通过 clone 方法共享的引用所有权被成为强引用
  • 弱引用: 通过 downgrade 方法创建的 Weak<T> 智能指针属于 Rc<T> 的另一种版本,它共享的指针没有所有权,所以被成为弱引用。

5.6.2 内部可变性 Cell<T>RefCell<T>

Rust 中的可变或不可变主要是针对一个变量绑定而言,比如对于结构体来说,可变或者不可变只能对其实例进行设置,而不能设置单个成员的可变性。但是在实际的开发中,某个字段是可变而其他字段不可变的情况是确实存在的。

Rust 提供Cell<T>RefCell<T> 来应对这种情况,他们本事上不属于智能指针,只是提供内部可变性(Interior Mutability)的容器。

Cell<T>

内部可变性实际上是 Rust 中的一种设计模式。

内部可变性容器是对 Struct 的一种封装,表面不可变,但内部可以通过某个方法来改变里面的值。

使用 Cell<T> 内部可变容器确实方便了编程,它提供的 set/get 方法像极了 oop 语言中常见的 getter/setter 方法,封装了对象属性的获取和设置方法。

Cell<T> 通过对外暴露的 get/set 方法实现了对内部值的修改,而其本身却是不可变的。所以,实际上 Cell<T> 包裹的 T 本身合法的避开了借用检查。

对于包裹在 Cell<T> 中的类型 T ,只有实现了 Copy 的类型T,才可以使用 get 方法获取包裹的值,因为 get 方法返回的是对内部值的复制。但是任何类型T都可以使用set方法修改其包裹的值。

对于实现了 Copy 的T,可以任意读取;对于没有实现 Copy 的类型T,则提供了 get_mut 方法来返回可变借用,依然遵循 Rust 的借用检查规则。

Cell<T> 内部每次 get/set 都会执行一次按位复制。

RefCell<T>

对于没有实现 Copy 的类型,使用 Cell<T> 有许多不变。 Rust 提供的 RefCell<T> 适用的范围更广,对类型T并没有 Copy 的限制。

RefCell<T> 提供 borrow/borrow_mut 方法,对应 RefCell<T> 的 get/set 方法。

RefCell<T> 维护一个运行时借用检查器,有运行时开销。

小结

Cell<T>RefCell<T> 使用最多的场景就是配合只读引用来使用,比如 &T 或者 Rc<T>

Cell<T>RefCell<T> 之间的区别可以总结为:

  • Cell<T> 使用 set/get 直接操作包裹的值,而 RefCell<T> 通过 borrow/borrow_mut 返回包装过的引用 Ref<T>RefMut<T> 来操作包裹的值。
  • Cell<T> 一般适合复制语义类型(实现了 Copy), RefCell<T> 一般适合移动语义类型(未实现 Copy)
  • Cell<T> 无运行时开销,而且永远不会在运行时引发 panic 错误。 RefCell<T> 需要在运行时执行借用检查,有运行时开销。而且一旦发现违反借用规则的情况,则会引发看线程 panic 。

5.6.3 写时复制Cow<T>

写时复制(Copy On Write / Cow) 是一种优化策略。

Rust 中的 Cow<T> 是一个枚举体的智能指针,包括两个可选值:

  • Borrowed:用于包裹引用
  • Owned:用于包裹所有者

Cow<T> 提供的功能是: 以不可变的方式访问借用内容,以及在需要可变借用或所有权的时候再克隆一份数据Cow<T> 旨在减少复制操作,提供性能,一般用在读多写少的场景。