1 Star 0 Fork 11

coder_lw / wiki

forked from deepinwiki / wiki 
加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
rust 借用规则.md 30.89 KB
一键复制 编辑 原始数据 按行查看 历史
htqx 提交于 2021-12-29 16:37 . dyn TA + 'a

rust 借用规则

前言

rust 是一门系统级别的底层语言,它的竞争对手是 c / c++ 。

它比 c 优秀的地方是支持更强大的语法特性。但是又避免以性能为代价。(当然 c 看上去比较简单)。

它比 c++ 优秀的地方,在于它把精力花在现在行业比较注重的领域,那就是内存安全性。而c++ 历史包袱太重,各种领域都想涉及,庞然大物。这也是 linux 内核作者抨击 c++ 的地方。

由此可见,rust 的核心创新,就是内存管理。其他特性不过是甜头而已。

所有权

rust 的对象有且只有一个拥有者,其他使用者只能通过引用临时借用。拥有者决定了对象的生存和消亡。

怎么做到?

  1. 移动:对象的赋值并不是拷贝,而是移动,被移出的变量自动无效(未初始化),因此只有一个拥有者
    1. 有些基本类型默认支持拷贝
    2. 也可以为对象自定义拷贝语义
fn drop(_:T){
// 什么代码也没有
}

drop(A); // A 移动进去,却没有返回,所以 A 销毁了

在所有权移动语义下,要销毁一个对象变得很容易,也很自然。如果一个对象不被谁特意去持有,它自动就消亡。在 rust 中,消亡是主旋律,不断持有才是精心设计(比如函数要返回所有内部的变量,谁那么得闲?)。

因此, rust 虽然没有内置垃圾回收,但是它是一个拥有高效智能内存管理的编程语言。

借用规则

类似的概念,比如 c 里面的指针, c++ 里面的引用,或者 java 里面的托管对象。它的含义就是一个指向实际地址的指针。因为有这个指针,就能对实际内存地址里面的数据间接操作。

c / c++ 这种看上去很合理的指针,但是被 rust 设计者们认为是存在风险的。多个访客对同一块内存地址的读写操作,是存在安全风险的。

应该对此作严格的限制。而建立这种严格限制的语言设施,就是“借用”。

rust 认为,数据安全的界限是:

  1. 一个数据只有一个拥有者时
  2. 多个访客只读时
  3. 只有一个可以读写的访客时(包括一个写和另一个读都是不允许的)

不安全怎么办?传统的技能是:

  1. 原子操作

这些人工的维护代码,对于传统编程语言的工程师来说,是极具挑战性的。

rust 也无法解决所有内存问题。因为所谓的安全不安全,由业务决定。老板就是要两个随机写的访客,就是玩,那老板也是对的!

但是借用就限定了在它认为安全的界限内,它能从语言层面给予支持,保证你不会出错。

这带来的好处,就是大部分非主观性的内存错误都不会发生。除非你跨越了 rust 设立的安全界限。

借用是怎么做到的?

首先, rust 定义了所有权的概念:

一、一个对象只有一个拥有者(对应了第一条规矩)

  1. 数据传递,就是改变拥有者(移动)

虽然移动不等于复制,但是跨函数移动必然是复制。我们知道数据完整的复制,是代价比较高的。有一些语言到这一步就止步了,虽然保证了安全性,但是效率也失去了。作为一个系统语言,是不能对效率妥协的。

所以:

二、可以出借所有权,使用完毕归还

  1. 只读借用可以多个
  2. 读写借用只能唯一一个(也不允许其他只读借用)

多个访客可以,但是只能读,不能修改,修改就会出现数据同步问题。甚至一个读,另一个写都是不允许的。这也许是很严格的要求,但是它是很好的界限,因为只要有写,另一个读的访客究竟该如何合理的处理数据?这就是具体业务逻辑才能说清楚的问题。rust 语言作者是永远无法确认哪一种默认逻辑才是合理的。

以上就是借用的设计逻辑。

但是具体实施到代码中去的时候,还有更多需要考虑的点。那就是怎么确定只读借用有几个?怎么确定读写借用有几个?

借了啥时候还?这些都是具体的问题。

另一个点是,出借方销毁了数据,那么借用不就没有意义了?这就涉及到生命周期的管理。

总体而言, rust 的借用相对指针受限制的点:

  1. 永远有效(生命周期管理实现)
  2. 只读和读写不相容
  3. 允许多个只读

指针不管你(数据)有没有效,读写也相对随意,多个写也无所谓。

作用范围

和变量的作用范围类似,借用的范围要稍微缩小一些。

一个变量在一个层级里面定义,它会从定义的开始到离开层级都保持。

而借用的范围是定义到最后使用的范围。如果这个范围内,出现不兼容的借用(比如读写借用),那就会编译报错。

当跨层级时,比如函数调用,进入内层事情会变得更复杂一些。

函数会有两种情况:

  1. 同步函数
  2. 异步函数

同步函数结束后,借用无疑会归还。但是异步函数呢?它在后面的代码中可能还是占用着借用的。这就不能假定它会归还了。然后同步函数内调用异步函数又如何?是不是应该假定所有函数都视为异步函数?

rust 没有这么简单化的处理,因为这样处理将会大大缩小借用的价值,rust 根据具体问题具体解决,但是也进一步提高它的复杂性:

  1. 同步函数:归还
  2. 异步函数:参数限定类型

归根结底,这是一系列生命周期问题。下面展开生命周期的概念:

  1. 假设 'a 为对象 A 的生命周期,那么借用 a 使用这个限定自己,意义为 a 的生命周期小于等于 A;

这就保证了 a 永远有效(A 还活着)。这种约束是双向的,即要求 a 满足,也要求 A 满足。A 不能在存在 a 的时候移动自己,释放自己。a 不能跨越 'a 的限制。

所谓 'a 的限制是:

  1. 'b > 'a 时,a不能转移到 'b 限定的借用中

具体例子:

{
    let b:&i32; // 'b
    {
        let A = 1; // 'c
        let a = &A; // 'a; 'a < 'c, ok
        b = a; // 'b > 'a, not ok
        // A 的作用范围到这里
    }
    // b 这里还有效呢
    println!(b);
}

生命周期标注

别问为啥用 'a 这种怪异的表示方法,rust 就是用这个标识生命周期的。当然名字可以修改。

不过这种一眼就能判断出生命周期的场合,rust 是不要求你写生命周期标注的,生命周期标注在一些不那么明确的场合,如函数调用,结构内部。

fn max<'c,'a:'c,'b:'c>(x:&'a i32, y:&'b i32)->&'c i32{
    if *x > *y{
      x
    }else{
      y
    }
}

如何阅读以上代码:

  1. 'a 是 x 的实参的生命周期
  2. 'b 是 y 的实参的生命周期
  3. max 被 'a 'b 限定,也就是max自身的生命周期应该小于 x,y 的实参(任意一个)。
  4. 'a:'c 的意思是 'a > 'c, 因此它可以复制给'c。 (记忆方法:类似继承的赋值关系)
  5. 返回值被 'c 限定,即返回值生命周期应该小于实参。
// 用法
let A = 1;
let B = 2;
let C = max(&A,&B);

标注是不是很麻烦?但是如果不标注,却返回一个借用,它究竟借用了谁?这种借用是不是安全的?比如:

  1. x
  2. y
  3. 内部变量
  4. 全局变量

这都是有可能的。但是标注了,编译器就能用这些信息来检查调用函数时实际的参数和返回值。

我们习惯了没有生命周期的指针,一时半刻可能还不能习惯借用是依赖生命周期这一事实。你可以把生命周期理解为借用的一部分(哪怕它有时候不标注出来)。

但是你细想一下,以上代码如果用指针来实现,是不是漏洞百出?这就是指针不安全的地方。

在 rust 中,生命周期和泛型参数是类似的技术,也就是静态生成(不同)类型的技术。

struct B<'a,T:'a>(&'a T);

视角:

  1. 可以将生命周期参数视为泛型参数
  2. 可以将任意带生命周期(或借用成员)的对象视为一种类借用。

因此可以认为借用有很多种形态(类引用):

  1. 类型 T 借用:&'a T
  2. 函数 F 借用:fn F<'a>(&'a)
  3. 方法 M 借用:impl T{ fn M<'a>(&'a) }
  4. 结构 S 借用:struct T<'a>
  5. 特征 T 借用:trait T<'a>
  6. 泛型 T 借用:fn F<'a,T:Base+'a>(&'a T)
    1. T 内部的借用都比 ’a 长
  7. 基类 S 借用:trait T<'a>:S<'a>
  8. Fn 特征借用:Fn<'a>(&'a i32)->&'a i32
    1. for<'a> Fn(&'a): 高阶特征边界

可以说相当的繁复,梳理一下有三种:

  1. 针对对象的:
    1. &'a T 和 T 的生命周期关系是:&'a T < T
      1. 推论:&'a &'b T : 'a <= 'b
  2. 针对泛型参数的:
    1. T:'a 和 T 的生命周期关系是:T 内部成员指针 &'a x < x(指向的对象)
  3. 针对Fn (函数指针)特征的:
    1. for<'a> Fn:'b 和 &'a(&'a T) 的生命周期关系是: Fn 具有独立的生命周期 'a。

这里侧重补充泛型 T:'a 为什么是必要的。因为 rust 的生命周期安全检查不能让长的指针指向短的指针。假设 T 是 &'b X, 且 'a > 'b, 则 &'a T 是错误的,因为结果是 &'a &'b X:即一个双重指针,且是一个长指针指向短生命周期的指针。

而 T:'a 的作用结果是 <'b:'a>&'a &'b X 是合理的。因此,泛型参数往往需要用生命周期来约束,毕竟 T 可以是任何类型。

特殊的生命周期:

  1. 'static : 和静态常量一样长的生命周期
    1. T:'static : 如果 T 是类引用,内部的指向就应该保持静态生命周期的长度。如果 T 不是类引用,它是不受这个限制的。因为当客户用变量持有对象时,这个对象的持有者就是客户,所以它的生命周期必然能满足客户的需要。
  2. 无标记生命周期(非安全上下文): 可以任意转换到不同的生命周期

注意: T:'static 约束的意义,它几乎就是表明 T 不是引用(指向静态常量范围太窄,意义不大)。

生命周期标注省略

  1. fn(&a,&b) --> fn(&'a a, &'b b)
  2. fn(&a)->&b --> fn(&'a a)->&'a b
  3. fn(&self,&b,&c)->&d --> fn(&'a self, &'b b,&'c c)->&'a d
    1. &self
    2. &mut self
  4. struct A<'a>;fn(&a:A<'_>) --> fn(&'a a:A<'a>)

生命周期子类型(变性)

所谓子类型,类似面向对象中的基类出现的位置可以用子类代替。

rust 只有生命周期支持这种关系。

如 'a:'b(即 'a > 'b):

  1. 'a 子类型
  2. 'b 基类

这里 ‘a 取代 ’b 是什么含义? 意思是一个生命周期长的对象 'a,你在更短的生命周期范围内('b)使用它。明显是安全的!

生命周期只是类型的一部分,它不能独立的表示类型,所以要和 T 相关:

  1. 协变(用 'a 取代 'b):T<'a> : T<'b>
    1. &'b i32
    2. &'b mut i32
    3. &'b &'b i32
    4. f(&'b i32)
    5. Box<T:'b>
  2. 逆变(用 'b 取代 'a):T<'a> : T<'b>
    1. fn(&'a i32)->&'b i32 : 参数逆变,返回类型协变
  3. 不变(相互不能取代):T<'a> 独立于 T<'b>
    1. Cell<T>
    2. &mut &i32

先来理解为什么会有逆变。注意,fn(&'a i32)->&'b i32 这里是指函数指针,而不是函数。

首先要理解函数指针的作用,它是一个接口规范,数据从它流到实际的函数。因此流动是逆反的:

  1. 参数从接口 ’static -> 'a 流动,写法:
    1. 流动上:fn ('static) --> f1('a)
    2. 使用上:f(f1) : 'a --> 'static :刚好相反
  2. 返回值的流动是从实际函数 --> 接口,流动是正常的
    1. 流动上:f()->'static --> fn()->'a
    2. 使用上:f(f1) : 'static -> 'a

比较绕,需要慢慢理解。这里'static 是静态生命周期,是 rust 最大(长)的生命周期。

let f = |f:fn(&'static i32)->&i32|f(&1);

fn f1(_:&i32)->&'static i32{
   &2
}

// 传入参数 'a --> 'static
// 返回 'static --> 'a
f(f1);

不变的情况是内部可变类型。还有可变双重指针,内层借用也是不可变的。如果短的内容可能存储到长的变量中,是不安全的,所以只能是不变。

fn f1<T:Copy>(a:& mut T, b:& mut T){
   *a = *b; // 如果可以,a 将持有 b,而b 是 f 的内部变量
}
fn f(a:&mut &'static i32){
   let b = 1;
   f1(a, &mut &b); // &mut &'static -X-> &mut &'a
}
let mut a = &1;
f(&mut a);
// b 已经析构, a 持有无效的指针

如何使用生命周期?

使用生命周期会在三个地方:

  1. 函数
  2. 结构(成员有引用时)
  3. 函数指针

标注的目的:

  1. 产生关联,如参数和返回之间的关联
  2. 产生派生关系,如 'a : 'b 表示 'a > 'b, 'a 约束的对象可以用在 'b 位置。
  3. 避免默认值。 如参数不标注,默认都是独立的 'a, ‘b ,只有标注相同才能相互替代。泛型类型 T 默认是 'static, 用 T:'a 可以修改默认值。

标注的意义:

  1. 标注并不改变实际的生命周期长短。对象该什么时候释放就什么时候释放。
  2. 标注只是让编译器进行检查。检查的逻辑是被标注的参数和实际对象之间的联系,如参数a和返回值相同标注,那编译器就认为指向相同的对象,它认为是同一个东西(如果实际对象是多个,而标注都是'a ,那么会认为是实际对象中生命周期最短那个。如果 'a :'b ,且返回'b,那么实际对象就包含所有'a 和‘b 中的最短的那个)。注意最终是根据实际对象(运算结果最短那个)来检查的,而并不强制要求实际参数一定要满足'a > 'b,有点饶口,需要好好理解。

简单来说,就是'a 只是占位符,你只需要将实际的对象放上去替换,根据替换后的实际对象的生命周期分析。f(a,b) 实际返回是a,还是b,编译器原本是不知道的,标注后,编译器就可以假定 a 或者 b了,这就是标注的作用。

如果有约束'a:'b, 它是要求实际的参数 a > b么,并不要求。如果 'a:'b ,且实际生命周期 a < b, 那么它只需要假定返回是 a 即可。标注的运算结果是让编译器选择返回的假定对象,而不是对参数a,b 的生命周期做任何约束,哪怕 a < b 一样可以传入参数。

fn f1(a:&u8, b:&u8)->&u8; // 默认 'a 'b 'c ,无关联,但返回值必须关联参数,所以报错
fn f2<'a>(a:&'a u8, b:&u8)->&'a u8; // 标注 'a, a 和 返回值相同
fn f3<'a,'a:'b>(a:&'a u8, b:&'b u8)->&'b u8;// 标注 'a > 'b, 返回值可能是 a 也可能是 b

struct A(&u8, &u8); // 结构没有默认值,报错
struct B<'a>(&'a u8, &'a u8);  // 关联
struct C<'a,'b>(&'a u8, &'b u8); // 独立

impl<'a> B<'a>{
   fn f1(a:&u8, b:&u8)->&u8{} // 实现函数和结构没有必然联系,可以视为独立的函数看待
   fn f2(&'a self)->&'a u8{self.0} // 使用了结构的标注'a。实际对象可以来自 self (结构对象),也可以来自标注'a 的参数
   fn f3(&self)->&u8{}
   fn f3_5<'b,'a:'b>(&'b Self<'a>)->&'b u8{} // 上一个写法等价于这个。也就是和f2 有微妙的不同。
   fn f4<'b>(a:&'b u8, b:&u8)->&'b u8{a} // 可以有自己独立的标注
}

trait TA{}
trait Into_Dyn<'a,T>  // 默认 T:'static ,也就是和当前对象无关
where T:'a { // 修改 T:'a,也就是和当前对象相关
   fn into(&'a self)->&'a T; // 返回的 T 和 self 有关,从而避免必须返回'static T
}
impl TA for B<'_>{}
impl<'a> Into_Dyn<'a, dyn TA> for B<'a>{
   fn into(&self)->&dyn TA{
      self
   }
}

// 或者在使用时(而非定义时)用 dyn T + 'a 的方式也能改变默认的 'static
// 如 impl<'a> Into_Dyn<dyn TA + 'a> for B<'a>
// fn into(&self)->&(dyn TA + 'a){...}
// 这样只需要定义 trait Into_Dyn<T>,不需要格外定义 'a 参数了

trait Into //类型内部(非函数部分)出现的引用默认和Self 有关
where 
for<'a> &Self:Into_Dyn<'a, &'a dyn TA>{} // 高阶 for<'a> 和 Self无关,和描述对象 Into_Dyn 有关

// 相关实现
impl<'a,'b> Into for C<'a,'b>{}
impl<'a> Into_Dyn<'a, &'a dyn TA> for C<'a,'_>{
   fn into(&self)->&dyn TA{
      self
   }
}
impl TA for C<'_, '_>{}

fn f4<'a>()->&'a u8{painc!()} // 和输入参数无关的标注,可以转换为任意生命周期

安全边界

借用规则内的是安全代码,但规则外也是必须要涉猎的,如果你的代码足够底层,你的需求足够奇葩。

这就是为啥 rust 提供非安全代码的缘故。

安全边界:

  1. 一个访客读写
  2. 读 读
  3. 读 写 (非安全)
  4. 写 写 (非安全)
  5. 对象的全局局部读写(非安全)
  6. 数组的不同部分写入(非安全)
  7. static mut a:i32 (可变静态变量,非安全)
  8. 'static -> 'a
  9. ‘a -> 'static (非安全)
  10. && a
  11. &mut &a (非安全)
  12. 借用
  13. * ptr(原生指针,非安全)
  14. 实现 --> 特征
  15. 类型转换
  16. 虚构类型、强制转换(非安全)

rust 的非安全代码,要么是被封装好了,要么使用 unsafe 给界定。非安全代码的安全职责在程序员身上。

解决的问题:

  1. 数据争用(data race) : 对内存的修改影响到第三方

安全边界内还是会出现的问题:

  1. 死锁 : 无限等待
  2. 竞态条件(race condition) :执行顺序导致结果不同
  3. 内存泄漏 : 无效持有
  4. 析构失败
  5. 程序终止
  6. 业务逻辑 bug

指针操作

智能指针和引用

不要混淆原生的引用和智能指针,智能指针不是一个引用,它只是模拟引用的操作。你可以把理解为一个能够绑定(转移和持有)所有权的包装对象,虽然它的操作很多是类似指针操作,但是它的根本性质,是类似普通变量的。

动态尺寸类型和胖指针

DST(dynamic size type) 动态尺寸类型包含: 特征(trait)和切片(slice)。和 c++ 设计不同的地方,动态尺寸类型的尺寸信息是指针的一部分,即类似:

胖指针:

  1. 指针
  2. 大小 / 虚表指针

而 C++ 的设计是:

  1. 指针 ——> 对象:
    1. 大小 / 虚表指针
    2. 对象数据

rust 这种设计的好处就是虚表指针不需要占用对象的大小,要用到这种运行时多态(特征指针)时才生成对应的虚表指针。

不足的地方是胖指针的设计比较呆板,它只能有一个虚表指针(或大小),即胖指针本身占用的空间是固定的,否则静态代码无法生成。如果要添加点什么其他信息呢?这就不是 rust 内置的胖指针设计能够满足的。

Sized 特征约束:

  1. T:Sized : 静态尺寸
  2. T:!Sized: 动态尺寸
  3. T:?Sized: 两者

特殊尺寸的类型:

  1. ZST(zero size type) 零尺寸类型,没有数据成员,大小为 0 的类型。
  2. Void 空类型: enum Void{}

当使用指针操作的时候,这些特殊性是需要考虑的。

对齐: #[repr(C)] 按 C 语言对齐。

操作规则

let mut a = String::from("aaa");

  1. *a: 反引用,对一个借用执行反引用操作,就是取其指向的值,即 aaa
  2. &a: 取引用,结果是双重指针 &&
  3. &mut a: 取可变引用,当 a 是可变的,可以取其可变引用。结果:&mut &
  4. 不能移动引用指向的对象,如 let b = *a ,借用不过是访客,是没有理由去移动主人的所有权的。
    1. 成员函数第一个参数是 self / &self / &mut self,它会自动的转换到合适的类型取调用。(*a).push('w') --> String::push(&mut *a, 'w'),所以并不会导致移动引用对象的所有权

类型转换

相关 api:

api & T --> U T --> & U T --> &mut U
new new(&T)->U
Clone clone(&T)->U
ToOwned to_owned(&T)->&U
Deref Deref(&T)->&U Deref(&mut T)->&mut U
DerefMut Deref(&mut T)->&mut U
AsRef AsRef(&T)->&U
AsMut AsMut(&mut T)->&mut U
Borrow Borrow(&T)->&U
BorrowMut BorrowMut(&mut T)->&mut U
Cow to_mut(&mut T)->&mut U
  1. Deref / DerefMut : 实现了反引用运算符 *,T 也叫智能指针
    1. Box<T> : 最基础的智能指针,在堆上分配对象,对对象拥有所有权
  2. 克隆数据:new、Clone、ToOwned
  3. AsRef / AsMut : 提供了 &T -> &U 的类型转换
  4. Borrow / BorrowMut : 语义上要求是对 T 的借用
  5. Cow : 写入时克隆(clone on write)智能指针,实现了 Deref,但没有实现 DerefMut。
    1. .to_mut() : 得到拥有所有权的可变借用。
    2. .into_owned(&self) : 创建新对象。

类型转换种类:

  1. 强制转换
  2. as 转换
  3. 任意转换

强制转换(coercion)这个术语有点怪,因为它实际上类似其他语言中的自动转换或者隐式转换:

  1. &mut T --> &T
  2. *mut T -->*const T
  3. &T --> *const T
  4. &mut T --> *mut T
  5. Deref<T> --> &T
  6. DerefMut<T> -->&mut T

使用 as 的转换(显式转换 Explicit coercions):

  1. 所有隐式转换
  2. 数值转换:数值 --> 数值
  3. 枚举转换:枚举 --> 整型
  4. bool --> 整型
  5. char --> 整型
  6. u8 --> char
  7. 指针 --> 整型
    1. 整型 --> 指针
  8. * T --> * U : U 不是动态类型(即不是胖指针)
  9. * dyn T -x-> * dyn U : 动态类型指针之间不能直接转换,会判断虚表不一致
  10. &[T;n] --> *const T
  11. fn() --> * T
  12. fn() --> 整型

函数的类型关系有点复杂:

  1. fn bar(){...} : bar 是零大小的 function item 类型,每个函数有独特的类型
  2. let f = &bar: 这是指向 bar 的引用(几乎没用)
  3. let f2:fn() = bar; 这才是函数指针
  4. let f3:&dyn Fn() = &bar; 这是特征对象,函数实现了这个特征,函数指针也实现了这个
  5. &dyn Fn --> const dyn Fn --> ptr

根据规则,只要将引用转换成对应的指针格式,然后指针可以无限制的转换成任意指针 *T, 在 unsafe 上下文利用反引用得到任意非动态大小类型 T,或者直接转成整数。

万能转换公式:

  1. 将引用转换为对应格式的指针
  2. 指针转换为任意目标的指针 *T where T:Sized
    1. 修改:const / mut
    2. 修改:'static / 'a
  3. unsafe 上下文获得 T : unsafe{ *t }
    1. 如果想获得引用: unsafe{ &*t },注意指针 --> 引用的转换是不存在的,但可以通过反引用和取引用运算来得到(哪怕中间过程无效也没关系,比如对特征对象反引用是无效的,但 &* 可以)
  4. 目标如果是特征 S,那么可以选择 T:S
let a:&dyn Fn()->i32 = &||3;
let a:*const dyn Fn()->i32 = a;
let a:fn()->i32 = unsafe { *(a as *const fn()->i32) };
a.type_id(); //因为 fn() : Any
let a:&dyn Any = &a;
a.type_id(); 

// 注意这段代码没有实际意义,因为 any 不解析特征对象

隐式和显式转换都是安全的(注意:转换到指针是安全的,访问指针指向对象即反引用操作才是不安全的)。

非安全转换:

  1. mem::transmute::<T,U>(T)->U

特殊的指针和特征

  1. Box<T>
  2. Rc<T>
  3. Arc<T>
  4. Pin<T>
  5. UnsafeCell<T>
  6. PhantomDate<T>
  7. std::ops
  8. std::cmp
  9. Deref
  10. DerefMut
  11. Drop
  12. Copy
  13. Clone
  14. Send
  15. Sync
  16. Sized
  17. 自动特征
    1. Send
    2. Sync
    3. Unpin
    4. UnwindSafe
    5. RefUnwindSafe

内部可变性

安全边界一节已经提到大概不安全的操作,明显有很多算是比较日常的需求,按借用规则也是做不到的。如果什么都要由程序员来进入非安全边界去实现,这样这门语言本身就变得不那么可靠了。

要避免程序员出现错误,最好的方法就是提供更优秀的基础类库,比如数据结构之类的。而 rust 因为安全边界比较严格,所以它要提供一些基础措施来解决一些日常开发需求。

rust 的方案就是: 内部可变性。

解决:

  1. 读 写 (非安全)
  2. 写 写 (非安全)

方案:

  1. 同步:Rc<RefCell<i32>>
  2. 异步:Arc<Mutex<i32>>

这里面是个组合:

  1. 运行时共享 :Rc、Arc
  2. 运行时借用指针 : RefCell、Mutex

借用的局限性:

  1. 受制于生命周期,不持有对象 (智能指针持有对象,相当于拥有 'static 生命周期)
  2. 静态检查

RC 智能指针模拟了借用的共享特性,如: RC<T>,T 是其指向(也是其管理)的对象,Rc::clone(&self) 将返回新的指针,不过都是指向同一个对象 T。但是要注意 &self,是只读借用,那么理论上它是没有修改自己。如果只是复制一个新的指针,a 指针和 b 指针怎么协调它背后共同管理的 T 对象呢? a, b 怎么知道啥时候该释放 T 呢?答案:

Rc 是一个内部可变类型。所以 &self 并不阻止它内部发生了修改。

Rc 的原理是用引用计数来统计 T 的引用个数,到 0 的时候就释放 T。

同样的, RefCell 也是内部可变,它模拟了借用规则,返回 Ref 智能指针,和 RefMut 智能指针。在其内部维护借用规则的动态实施。

这一顿操作下来有什么意义?

fn f1(a:&'static i32, b:&mut i32){
   *b = 1;
}

fn f2(a:Rc<RefCell<i32>>, b:Rc<RefCell<i32>>){
   *b.borrow_mut() = 1;
}

fn f(){
   let mut a = 0;
   f1 (&a, &mut a); // 做不到

   let a = Rc::<RefCell<i32>>::new(RefCell::<i32>::new(0));
   f2 (a.clone(), a.clone()); // 轻松秒杀
   println!("{}", a.borrow());
}

也许以上代码让你一头雾水,但是它要表达的意思就是,接口可以更灵活。

将本来在编译器检查的问题,推迟到运行期。这样的好处,在于读写的操作,写写的操作,不会被编译器就地否决,而变为了可能,从而将编译器问题变为业务逻辑问题。

虽然同步代码也许意义不大,但是对异步代码来说,这是很有必要的。因为异步代码的接口就是非常严格。

分析一下 f1 接口,借用规则下,是不能同时存在两个访客,a,b 拥有对同一个对象的读写权的,那么 f2 是否违反了借用规则?其实并不是,它只是将检测放到了函数内部,在函数内部你可以先写,也可以先读,但是读和写是有顺序的,写之前关闭读,读之前关闭写。这样来实现读写的目的。

从函数整体来看他是实现了两个访客同时拥有读写接口,但从内部逻辑来看它还是遵从借用规则。写写逻辑也是同样的道理。

能做到这点的根本原因是什么?就是 a,b 都是一个内部可写类型,内部封装了不安全代码,它能在需要的时候改写只读的对象,但又在外部保持安全的外观和 rust 的其他安全代码兼容。

代价是什么?

  1. 失去编译器前期的检查,逻辑问题需要运行时才能发现
  2. 需要有两套 api,一套适用在同步环境,一套适用在异步环境。

所以,除非你真的需要同时传递“读”和“写”两种借用出去,否则应该调整逻辑,自己安排读写顺序和逻辑,才能有效利用编译器的排错。

当然,在复杂的异步环境下,这个可能是个奢望。

同步异步

特征:

  1. send : 对象可以跨线程传递
  2. sync : 对象可以跨线程共享

哪些能跨线程传递?

  1. 基本类型
  2. 大部分类型
// & mut
impl<'_, T> Send for &'_ mut T
where
    T: Send + ?Sized

// &
impl<'_, T> Send for &'_ T
where
    T: Sync + ?Sized

哪些能跨线程共享?

  1. 基本类型,i32 等
  2. 大部分类型
  3. 定义:只有 &T 是 Send 时, T 才是 Sync

编译器会在符合安全条件下自动实现这两个特征。

那究竟怎样的条件才是符合? rust 安全界限内的产品,都是符合的。它实际上是借用规则的翻版。

  1. String : Send ,因为移动所有权,只有一个对象拥有所有权,不会出现两个线程拥有所有权
  2. &mut String :Send , 因为只有一个可读借用,逻辑同上
  3. &String : Send ,因为是只读借用,任意多也不会修改对象
    1. 所以根据定义: String : Sync 支持共享,是同步对象

可见同步规则都没有违反借用规则。也就是借用规则和同步规则是类似的。那为什么还需要定义同步规则呢(Send / Sync)?

这是因为我们之前引入了内部可变性类型,这些类型中,有一部分是不支持同步的。他们在穿越线程时,会有问题。比如: Rc, RefCell。另外,其他不安全组件,如原生指针,或者封装了不安全代码却没有对跨线程优化的组件,也是不支持跨线程的。

也就是如果经过对跨线程精心优化的不安全组件,它大可以通过标记 Send、Sync 来表明支持同步。这就是 rust 类库的做法,也是为什么需要标记的原因。

问题二:为什么需要封装成智能指针,而不用原生的借用?

这就要从生命周期去理解,线程什么时候消亡,这个是很难确定的,也很难去安排不同线程的读写顺序,就算安排好了,颗粒度也是比较粗糙的,并发性能会降低。也就是在多线程环境下,往往都是要求你同时拥有读写线程的,不能说等到读线程完毕,再来启动写线程。因此静态的借用规则就完全没有用了,只能依赖智能指针在运行时动态执行借用规则。

不过好消息是:借用规则永远值得信赖!只要借用规则执行到位,不管是静态还是动态,它实际都是安全的。这并不影响 rust 安全语言的承诺。

自引用结构

如果成员指向自身,这类结构称为自引用结构。这类结构一般都是个麻烦,但是现实中却有用。rust 中的生成器和异步函数(async) 会产生一个自引用的内部结构。

此类结构在交换和移动对象指针的时候,会产生 bug,原本是引用自身,生命周期和自己是相同的,但是如果指向外部,那么生命周期就不相同了。也就是,编译器对其生命周期的假设就没有原本的对自引用结构的假设那么强。而生成器和异步函数的实现很依赖对自引用结构生命周期的假设。

为了解决这个问题,Pin 能够固定指针,让上下文不能安全的访问 &mut T 指针,从而避免无意间的改变自引用结构。

对于非自引用结构,自动实现了 UnPin 特征,所以 Pin 对其无效(因为操作指针是安全的,所以禁止操作没有任何意义)。对于特定的自引用结构,用户可以实现 !UnPin 特征,从而让 Pin 机制得以生效。

对于栈对象,Pin 固定是非安全操作(unsafe),因为固定后的对象会随着栈释放而丢失,所以它不是一种强烈的保证,可能隐含 bug。对于堆对象,Pin 固定是安全操作。

参考

  1. 别混淆数据争用(data race) 和竞态条件(race condition): https://blog.csdn.net/gg_18826075157/article/details/72582939
  2. Rust太难?那是你没看到这套Rust语言学习万字指南!:https://bbs.huaweicloud.com/forum/thread-97929-1-1.html
  3. 类型转换: https://www.cntofu.com/book/55/content/Casting%20Between%20Types%20%E7%B1%BB%E5%9E%8B%E8%BD%AC%E6%8D%A2.md
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
1
https://gitee.com/coder_lw/wiki.git
git@gitee.com:coder_lw/wiki.git
coder_lw
wiki
wiki
master

搜索帮助