Rust:unsafe & 型变


Rust整体被分为SafeUnsafe两部分。在Safe Rust中,你可以在规则下构建高度安全的程序。但是有一些操作本身就是不安全的,比如在Rust中调用C接口,如果要进行一些本身就不安全的操作,就需要使用Unsafe Rust了。

在《Rust 编程之道》中这么比喻:

Rust就像一艘遨游于太空的宇宙飞船,不论外太空多么危险,宇航员只要待在飞船内部,就是安全的。当宇航员要去飞船外执行任务,就必须穿好宇航服,经由减压舱到达飞船外部。宇航员一旦进入外太空,就必须自己保证安全,因为此时他已经完全暴露于不安全的环境之下。Safe Rust就是提供安全庇护的飞船,而Unsafe Rust就是宇航服、减压舱,以及飞船外部与宇航员任何有关联的部分。

这个比喻揭示了一个关系,在Unsafe Rust中,程序本身就暴露在不安全的环境下,而Rust提供了部分工具来保证用户的安全,但是无法避免用户本身进行不安全行为。


unsafe

使用unsafe关键字来使用Unsafe Rust,分为两种语法:

  • unsafe关键字:用于标记函数、方法、trait
  • unsafe block:用于执行非安全操作

例如:

unsafe fn unsafe_ptr() {}
unsafe trait unsafe_trait {}

fn main() {
    unsafe {
        unsafe_ptr();
    }
}

代码中,使用unsafe标记了一个函数和一个trait,并在main中使用unsafe {}调用了非安全函数。

Unsafe RustSafe Rust的超集,也就是说在unsafe中所有safe的规则生效,比如说所有权、生命周期、借用、模式匹配等等。

但是在Unsafe Rust中,提供了一些非安全操作:

  1. 解引用原生指针
  2. 调用unsafe的函数和方法
  3. 修改可变静态变量
  4. 实现unsafe trait
  5. 读取联合体

它们必须在unsafe block或者标记为unsafe的函数或方法中使用,在进行这些操作的时候,不会进行安全检查。


Union

联合体在复合类型中已经讲过,它允许多个类型的数据共享一块内存空间。可是联合体并不提供任何字段检查,你无法确定当前联合体存储的是哪一个类型的值,这就导致有可能用错误的类型解释一个数据,从而导致未定义行为。

因此使用联合体是一种不安全行为,需要在unsafe中操作。

示例:

#[repr(C)]
union MyUnion {
    i: i32,
    f: f32,
}

fn main() {
    let u = MyUnion { f: 3.14 };
    let f = unsafe { u.f };

    unsafe {
        println!("i = {}", u.i);
        println!("f = {}", u.f);
    }
}

MyUnion是一个联合体,它包含iu两个字段。一开始初始化u.f = 3.14,随后使用f接受unsafe block的返回值。

unsafe block也是一种块,因此也是以最后一个表达式作为整体的返回值。从逻辑上这是一个安全的行为,因为初始化是f32,读取时也是f32

但是后续在unsafe中输出了两次,分别读取u.iu.f,这就是把一个IEEE-754标准的浮点型强行以整形的规则解析,虽然不会错误,但是逻辑上已经十分不安全了。

输出结果:

i = 1078523331
f = 3.14

可以看出在unsafe中,程序员必须自己保证逻辑的正确性,而无法依赖于编译器的检查。


unsafe方法

使用unsafe标记一个函数后,就可以在函数体中进行unsafe的操作,或者调用其它unsafe的方法。

例如:

static mut NUM: i32 = 10;

unsafe fn change_num() {
    NUM = NUM + 1;
}

函数change_num被标记为unsafe,因此内部可以直接进行静态变量的修改。

Rust 中,方法或函数之所以被标记为 unsafe,并不是因为函数体内一定不安全,而是因为调用它的外部语境无法由编译器验证安全性。换句话说,标注 unsafe 并不是说函数一定错误,而是调用者必须自己确保不会发生错误

例如:

pub unsafe fn from_utf8_unchecked(bytes: Vec<u8>) -> String {
    String { vec: bytes }
}

这是标准库中,String的一个方法。类似于常用的String::from,用于通过一段utf8编码的字节创建字符串。

函数内部非常简单,就是直接拿这些字节创建了一个String返回。没有unsafe操作,为什么要标记为unsafe

因为这个代码没有检查bytes这个数组,from_utf8_unchecked顾名思义就是通过uft8创建一个字符串,但是不进行检查。

如果一个字符串内部的编码不符合要求,那么一旦进行切片,访问等操作,结果就是panic,这会导致整个程序崩溃,是一种不安全的行为。因此如果用户通过from_utf8_unchecked创建字符串,未来就有可能导致错误,用户必须自己保证传入的字节符合编码。

这个场景常出现在调用一个模式的接口时,发现接口报错,这是一个unsafe函数,于是用户去看文档了解到它为什么是一个unsafe函数,就可以避免一些不安全操作。

总结unsafe函数有两种场景:

  1. 函数内部使用了unsafe操作
  2. 函数可能因为用户某些操作导致崩溃,通过unsafe反向约束用户

unsafe trait

有时候,一个 trait 的实现能否保证正确,并不是编译器能自动推断的。当这种 trait 的实现正确性决定了程序的安全性时,Rust 要求你显式标记它为 unsafe trait

示例:

unsafe trait Divide {
    fn div(&self, other: Self) -> Self;
}

这是一个用于除法的trait,内部有一个div方法。trait本身无法确定用户如何实现这个方法,如果other = 0就会发生除零错误,这会直接导致程序崩溃。因此把Divide声明为一个unsafe trait,表示无法确保实现者的实现逻辑是安全的。

实现这个trait

unsafe impl Divide for i32 {
    fn div(&self, other: Self) -> Self {
        *self / other
    }
}

实现时必须写unsafe impl,此时就在提醒实现者你必须按要求实现,否则带来的问题需要自己承担。

此处不对other的值进行检查,因此有可能触发除零错误,此时就已经是一个不太安全的逻辑了。

调用方法:

fn main() {
    10.div(0);
}

对于unsafe trait中的普通方法,调用的时候无需使用unsafe关键字。可以理解为,trait中的unsafe主要用于提醒实现者而非调用者,最后调用者函数是否要放在unsafe block内部只取决于函数本身是不是unsafe

以上代码就因为实现者没有好好处理逻辑,程序运行后直接就panic了。

想要优化,一方面是实现者在div中进行检查,另一方面是把div声明为unsafe,让调用者自己注意。

unsafe trait Divide {
    fn div(&self, other: Self) -> Self;
    unsafe fn div_uncheck(&self, other: Self) -> Self;
}

unsafe impl Divide for i32 {
    fn div(&self, other: Self) -> Self {
        if other == 0 {
            -1
        } else {
            *self / other
        }
    }
    
    unsafe fn div_uncheck(&self, other: Self) -> Self {
        *self / other
    }
}

fn main() {
    unsafe {
        10.div_uncheck(10);
    }
}

此处的div就是实现者进行了检查后,可以安全执行的方法。而div_uncheck本身被声明为unsafe,因此调用者就会注意到参数不能传入0


原生指针

原生指针是 Rust 中暴露出的最底层内存访问工具,它们直接映射到机器层面的地址概念。
这也是唯一一个能彻底突破借用检查的类型。

Rust 提供了两种原生指针:

类型含义
*const T指向不可变数据的原生指针
*mut T指向可变数据的原生指针

&T&mut T 不同,原生指针不遵守借用规则。 读写它指向的数据时必须放到 unsafe 块内,因为编译器无法验证它是否有效。

示例:

fn main() {
    let mut value: i32 = 10;
    let r1 = &value as *const i32;
    let r2 = &mut value as *mut i32;

    unsafe {
        println!("r1 points to {}", *r1);
        *r2 += 5;
        println!("r2 points to {}", *r2);
    }
}

as *const i32as *mut i32 将借用转换为原生指针。原生指针可以直接在Safe Rust中创建,但是必须在unsafe中解引用

Rust 2024版本前,只能通过创建借用和不可变借用,然后再转化为原生指针。在Rust 2024之后,可以使用raw关键字创建原生指针:

let mut value: i32 = 10;
let r1 = &raw const value;
let r2 = &raw mut value;

在借用符号&后面加上raw关键字,此时直接创建指定变量的原生指针。并且要写出constmut关键字,表示是否可变。

原生指针有以下特点:

  1. 原生指针可以是任意地址(空指针、垂悬指针),因此有可能指向一块非法内存
  2. 不具有RAII机制,需要手动管理指向的内存
  3. 没有生命周期,不进行借用检查
  4. 不保证多线程安全
fn main() {
    unsafe {
        let p1 = 0x11223344 as *const i32;
        let p2;
        {
            let x = 10;
            p2 = &x as *const i32;
        }
        
        println!("p1 = {:?}", p1);
        println!("p2 = {:?}", p2);
    }
}

以上代码创建了两个原生指针,p1直接指定了一个地址值,p2则指向了一个生命周期较短的数据。

输出时,两行代码都会直接导致程序panic,分别是访问无效地址以及垂悬指针。这些问题原本在借用场景下完全不可能发生,但是原生指针不被所有权体系所约束。

Rust为原生指针提供了一些常用的方法。

  • as_ptr/as_mut_ptr:将借用转化为原生指针
  • std::ptr::null:创建空指针
  • is_null:判断指针是否为空
  • offset/add:指针偏移
  • read/write:读写指针指向的内容
  • replace/swap:替换指定内存的数据

示例:

use::std::ptr;

fn main() {
    let mut data = [10, 20, 30, 40, 50];

    // 获取指向数组开头的原生指针
    let p_const = data.as_ptr();      // *const i32
    let p_mut = data.as_mut_ptr();    // *mut i32

    unsafe {
        println!("1. 原始指针: {:?}", p_const);
        println!("第一个元素 = {}", *p_const);

        // 创建一个空指针并检查
        let null_p: *const i32 = ptr::null();
        println!("2. null 指针是否为空? {}", null_p.is_null());

        // 使用 offset 偏移指针 (偏移量按元素个数计)
        let third_p = p_const.offset(2);
        println!("3. 偏移后的第三个元素 = {}", *third_p);

        // 使用 read 读取指定位置的值
        let val = ptr::read(p_const.add(1));
        println!("4. read 读取第二个元素 = {}", val);

        // 使用 write 写入新值 (写入第五个元素)
        ptr::write(p_mut.add(4), 999);
        println!("5. write 修改第五个元素 = {}", *p_const.add(4));

        // 使用 replace 替换指定位置的值,并返回旧值
        let old = ptr::replace(p_mut.add(0), 111);
        println!("6. replace: 原第一个值 = {}, 新值 = {}", old, *p_const.add(0));

        // 使用 swap 交换元素
        ptr::swap(p_mut.add(1), p_mut.add(3));
        println!("7. swap 后数组状态 = {:?}", data);
    }
}

示例中定义了一个数组,对数组使用as_ptr,可以直接拿到第一个元素的指针。

进入unsafe后,首先通过 ptr::null()创建了一个空指针,并用null_p.is_null()判断是否为空。

再用offset对指针进行偏移,p_const.offset(3)就是在p_const基础上往后偏移三个元素的位置。要注意这里不是偏移三个字节,指针的类型为i32,长度为4 byte,因此offset(3)实际向后偏移了3 * 4 = 12 byte

addoffset的功能是相同的,但是add只能传入一个正数,而offset可以传入负数向前偏移。

随后ptr::read(p_const.add(1))读取了第一个元素的值,这个过程会发生拷贝,但不是调用copy或者clone,而是直接对栈区数据进行拷贝

后续使用ptr::write(p_mut.add(4), 999)将第五个元素更改为999,对于i32来说这是安全的。但是如果说数组的元素是一个String,此时就会发生内存泄露,因为write只在栈区覆盖旧值,不会调用析构函数

最后分别用replaceswap对元素进行替换和交换,replace会返回旧值,swap则是直接交换。

可以看出,这里面很多方法都非常不安全。比如read强行对栈区数据进行拷贝,如果是一个String,那么就会出现栈区的多个String胖指针指向同一份数据的问题,这会导致资源重复释放。

再比如write时,直接在栈区进行数据覆盖,如果被覆盖的是一个胖指针,那么该指针的RAII机制就无法触发,导致内存泄露。

案例:

struct MyString(String);

impl Drop for MyString {
    fn drop(&mut self) {
        println!("drop MyString: {}", self.0);
    }
}

fn main() {
    let mut s = Box::new(MyString(String::from("hello")));
    let p = &mut s as *mut Box<MyString>;

    unsafe {
        write(p, Box::new(MyString(String::from("world"))));
    }
}

此处使用NewType模式,把String封装后手动实现了Drop,这样调用析构函数时就会输出。

随后进入main函数,首先使用BoxMyString放到堆区,随后为其创建了一个原生指针p。进入unsafe中,使用write用新的Box覆盖旧的Box,实际上只是覆盖了栈区上的胖指针。

函数输出:

drop MyString: world

可以看出world这个字符串成功析构了,但是hello这个字符串就没有成功调用析构函数,因为栈区已经没有指向它的智能指针了,无法触发RAII机制。


可变静态变量

一个静态变量在整个程序期间都存活,假如它不可变,那么往往不会带来什么安全问题。但如果可变,那么在多线程场景下就会出现数据竞争,比如多个线程同时修改一份数据的场景,这是非常不安全的行为。因此如果一个静态变量可变,必须在unsafe中访问。

示例:

static mut NUM: i32 = 10;

fn main() {
    unsafe {
        NUM = NUM + 1;
    }
}

一般场景下不会使用可变静态变量,但是在C语言部分接口中,可能需要修改静态变量,比如全局的错误码,此时Rust就需要提供这种能力于C语言交互。

你不能直接创建static变量的借用:

static mut COUNTER: i32 = 0;

fn main() {
    unsafe {
        let p = & COUNTER; // error
    }
}

这是因为如果在多线程环境下,对于static变量Rust不保证它的所有权。可能在持有借用的同时,别人持有了*mut可变原生指针,此处可能导致读写并发或者写写并发。因此Rust直接不允许创建static变量的借用,只允许创建原生指针

而创建原生指针又有两种语法,分别是先拿到借用,然后通过as转换,另一种是通过raw关键字直接拿到原生指针。

Rust 2024之后,static变量只允许通过raw关键字拿到原生指针。

static mut COUNTER: i32 = 0;

fn main() {
    unsafe {
        let p1 = &COUNTER as *const i32; // error
        let p2 = &mut COUNTER as *mut i32; // error

        let p3 = &raw const COUNTER; // success
        let p4 = &raw mut COUNTER; // success
  

以上代码中,p1p2会报错,p3p4正常。

其实还是因为,static变量不允许直接创建借用和可变借用。如果借用都创建不出来,更别谈把借用转化为原生指针了。所以它只能通过raw的形式直接创建原生指针。


安全抽象

unsafe将程序暴露在不安全的环境下,但是只要开发者遵循规范,那么也可以写出安全的程序,接下来为大家总结一些unsafe
中常常会导致的问题。

未绑定生命周期

未绑定生命周期是一种可以被随意推断的生命周期,在unsafe中由于原生指针的操作,Rust可能拿到一些未知来源的借用,此时Rust往往会乐观地相信开发者,这往往会导致错误。

示例:

unsafe fn foo<'a>(input: *const u32) -> &'a u32 {
    &*input
}

fn main() {
    let x;
    {
        let y = 42;
        x = unsafe {foo(&y)};
    }
    println!("hello: {}", x);
}

函数foo内部接受了一个原生指针*const u32,并返回了一个借用& u32。可以看到原生指针是不需要标注生命周期的,之前也说过原生指针不受生命周期约束。'a这个生命周期就没有来源了,编译器通过返回值知道'a是来自于一个原生指针的,于是乐观的认为'a一定可以满足后续需要,因为相信程序员在unsafe中的操作,实际上是编译期根本分析不出来。

main中,将y的借用传入了foo,并把返回的借用赋值给了x。此时相当于外部作用域的x借用了内部作用域的y。在生命周期体系下这根本不可能。但是当编译期分析外部x的生命周期时,发现这个生命周期是'a,来自一个原生指针,于是乐观推断它可以满足要求,最后代码不会报错,编译正常通过。

甚至如果运行这段代码,还能正常运行。有人可能要问,y不是被销毁了吗,为什么还能正常运行。

因为xy都是值类型,它们所有数据都在栈区。而栈区是按照栈帧回收的,它们都在main函数的栈帧中,所以println!的时候,y在内存层面还没有销毁,只是从语言层面,已经超出了生命周期和作用域。

如果把u32换成String,等到离开作用域,那么自动调用drop销毁堆区数据,再去访问就真的会panic!了。

比如:

unsafe fn foo<'a>(input: *const String) -> &'a String {
    return &*input
}

fn main() {
    let x;
    {
        let y = String::from("world");
        x = unsafe {foo(&y)};
    }
    println!("hello: {}", x);
}

这段代码同样编译通过,但是一旦运行,println就会尝试输出胖指针指向的堆区字符串,这个时候堆区被回收了,直接panic!导致程序崩溃。避免这种未绑定生命周期,就要靠自己在逻辑上保证生命周期,而无法依赖于编译器。

未绑定生命周期带来的结果,既可能像u32一样无关痛痒,也可能像String一样直接崩溃,这种行为就叫做未定义行为。在C/C++体系下,未定义行为几乎随处可见,但是在Safe Rust中几乎不存在,因为这也被纳入了Rust的安全设计范畴。


子类型与型变

子类型

在现代的类型系统理论中,子类型往往相对于父类型而存在,在需要父类型的位置,往往可以传入一个子类型来代替。这种关系用A <: B表示,意味着AB的子类型。

在面向对象语言中,对象之间有继承关系,那么就有明确的父子关系。在一个需要父类的位置传入子类,也被称为多态。比如Duck <: Animal,那么就可以在需要Animal的函数参数中传入一个Duck的实例,因为Duck继承后,一定实现了Animal所需的所有方法。


型变

假设现有两个类型AB,关系是A <: B。将这两个类型作为一个结构体的泛型参数,生成两个新的类型Wrapper<A>Wrapper<B>,请问这两个新的类型关系是什么?

将两个原始类型以相同方式包装为更加复杂的类型后,新类型之间的关系经过一定规则发生变化,就称为型变

型变分为三种形式:

  • 协变:若A <: B,则Wrapper<A> <: Wrapper<B>,也就是父子类型关系不变
  • 逆变:若A <: B,则Wrapper<B> <: Wrapper<A>,也就是父子类型关系交换
  • 不变:若A <: B,则Wrapper<B>Wrapper<A>没有任何关系,也就是断开原有的关系

打个比方,在男生宿舍中存在一种“共轭父子”的关系。现在有“张三”和“李四”两个人,他们是舍友。原本李四每天给张三带饭,上课签到,因此张三叫李四“爸爸“,也就是张三 <: 李四

协变场景:某天上课张三没来,老师突然课上留了一个作业,李四直接帮张三完成了,经过这个事情张三还是叫李四”爸爸“,也就是HomeWork<张三> <: HomeWork<李四>

逆变场景:张三虽然每天都不去上课,实际是天天在偷偷写算法题。某次ACM比赛,张三带上了李四,拿到了金奖。此时关系就发生逆转了,李四要反过来喊张三”爸爸“了,也就是ACM<李四> <: ACM<张三>

不变场景:李四每天都给张三带饭,其实是看上张三了,于是李四和张三表白了,但是张三根本不喜欢李四,于是他们关系破裂了,谁也不喊对方”爸爸“了。经过这件事两者毫无关系,既没有逆转关系,还破坏了原有的关系。

那么这种型变对Rust有什么意义?Rust并不存在类型继承的概念,也自然没有父子关系,型变更是无从谈起。

还记得生命周期属于泛型体系吗?生命周期也是一种类型,而生命周期存在这种父子替代关系。长生命周期可以替换短生命周期,因此长生命周期是子类型,短生命周期是父类型

比如'static是最长的生命周期,那么它就是所有生命周期的子类型。

回到最经典的案例:

fn longer_str<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() {
        a
    } else {
        b
    }
}

fn main() {
    let str1 = String::from("Hello");
    let ret;
    {
        let str2 = String::from("World");
        ret = longer_str(str1.as_str(), str2.as_str());
    }
    println!("{}", ret);
}

这是初学生命周期时的案例,现在从子类型的角度来理解这段代码。

假设str1的生命周期为's1str2的生命周期为's2longer_str接收两个参数,最后'a = min('s1, 's2) = 's2

也就是说,经过推断后函数的两个参数以及返回值都是's2。当第一个参数把生命周期's1传入的时候,编译期发现需要的生命周期是's2,但是由于's1更长,所以存在关系's1 <: 's2,所以可以在需要's2的位置传入's1

longer_str函数内部,编译期并不知道ab在外部的生命周期是什么,只知道它们都是'a,但实际上是's2。如果你有面向对象语言的经验,可以结合多态的思想理解。当父类型参数接收到了子类型,函数内部根本不知道最后具体的类型是谁,只把它当做父类型来看。

生命周期是一种泛型,意味着它可以作为参数单态化出一种类型的多个版本。或者说它可以被接受生命周期参数的复合类型所包装,因此生命周期可以型变

对刚才的例子做出调整:

struct MyStr<'b> {
    value: &'b str,
}

fn longer_str<'a, 'b>(a: &'a MyStr<'b>, b: &'a MyStr<'b>) -> &'a MyStr<'b> {
    if a.value.len() > a.value.len() {
        a
    } else {
        b
    }
}

fn main() {
    let str1 = MyStr { value: "hello" };
    let ret;
    {
        let str2 = MyStr { value: "world" };
        ret = longer_str(&str1, &str2);
    }
    println!("{}", ret.value);
}

此处使用了一个内含生命周期参数的结构体MyStr,在longer_str签名中包含了两个生命周期参数,'a描述MyStr整体的生命周期,'b描述MyStr内部的生命周期。

现在针对'b生命周期分析,假设str1 内部的"hello"的生命周期是'hstr2内部的"world"的生命周期是'w,最后'b = min('h, 'w) = 'w,因此MyStr<'b>就是MyStr<'w>

函数的两个参数都要接受&MyStr<'w>类型的参数,对于str2当然符合,但是str1的类型是&MyStr<'h>,类型不匹配。但是由于'h长于'w,所以存在'h <: 'wRust绝大多部分数据类型都是协变类型,因此&MyStr<'h> <: &MyStr<'w>,关系保持。所以在需要&MyStr<'w>的位置可以传入一个&MyStr<'h>的类型,这就是型变在Rust中的作用。


内部可变性

为什么要在unsafe章节突然讲到这些东西?因为协变加上unsafe有时候会带来一些错误。

案例:

struct MyCell<'a> {
    value: &'a i32,
}

impl<'a> MyCell<'a> {
    fn set(&self, other: &'a i32) {
        unsafe {
            std::ptr::write(&self.value as *const &i32 as *mut &i32, other);
        }
    }
}

以上代码要在Rust 1.70前测试,新版本会报错。

现有一个MyCell结构体,它内部包含一个借用。在set方法汇总,通过write修改了self.value的值。重点分析一行代码:

std::ptr::write(&self.value as *const &i32 as *mut &i32, other);

首先self.value本身是一个借用,&self.value就是借用的借用,可以理解为一种二级借用,此时&self.value这个整体指向了MyCell内部的value本身。随后把这个借用先转化为 *const &i32 原生指针这个指针指向的类型是&i32借用,再把不可变指针转化为可变指针*mut &i32原生指针*const可以在unsafe中直接转化为*mut指针,但是不能直接从不可变借用转为可变指针,因此这里要先变成不可变指针,再变成可变指针。

write函数中,先传入了指向self.value的可变指针,再传入另一个指向i32的借用。此时相当于改变了self.value的指向,让它和参数other的指向相同。

随后定义一个函数:

fn func<'b>(c: &MyCell<'b>) {
    let tmp: i32 = 2025;
    c.set(&tmp);
    println!("change value: {}", c.value);
}

func中,接受一个MyCell,随后函数内部创建一个临时变量tmp。调用c.set(&val),相当于把c内部的借用指向这个临时变量,最后输出修改后的值c.value

其实到这一步就已经非常奇怪了,这个step1居然妄图让来自外部的一个借用指向函数内部的临时变量,很明显生命周期不够长。

最后在main函数调用试一试:

static X: i32 = 2005;
fn main() {
    let cell = MyCell { value: &X };
    func(&cell);
    println!(" end value: {}", cell.value);
}

main中创建了一个静态常量Xcell.value则借用X,随后调用func(&cell),再输出cell.value

最后这段代码可以成功运行,输出结果:

change value: 2025
end value: 随机值

如果你在合适的版本运行这段代码,最后你会发现两个问题:

  1. 这个代码居然成功运行了,而且成功修改了借用值
  2. 最后main函数输出的值,每次运行都不一样,是一个随机值

这段代码不长,但是内部的机制可不简单。它利用了协变机制,成功瞒天过海逃过了编译器的借用检查

main中,借用的是一个static静态变量,因此cell.value的生命周期是'static

进入func函数,此时c.value的生命周期就是'static。创建的临时变量tmp假设生命周期为't。当c.set(&tmp)c的类型是&MyCell<'static>&tmp的生命周期是't

此时就进入了set函数,回看函数签名:

impl<'a> MyCell<'a> {
    fn set(&self, other: &'a i32) { ... }
    // 等效于
    fn set(self: &MyCell<'a>, other: &'a i32) { ... }
}

此处'a生命周期同时作用与&self.valueother。我特地写出了set方法函数签名的非省略版本,就是要强调'a同时作用于两处。

因此'a = min('static, 't) = 't,可以理解为set此时接受两个参数&MyCell<'t>&'t i32。对于tmp来说,类型完全匹配,当然没问题。

但是c的类型是&MyCell<'static>,类型不匹配了。类型不同没关系,由于'static长于't,存在关系'static <: 't。而MyCell是协变类型,所以&MyCell<'static> <: &MyCell<'t>c可以作为参数传给set

但是在set的内部,它只认识'a = 't,才不知道self原本是&MyCell<'static>。因此函数内部将self.value修改为other的过程,两者生命周期都是'v,编译器可高兴了,生命周期完全匹配!于是错误就发生了。

协问题在于,它会擦除类型原本的生命周期,也就是我们一直在用的min这个方法。每次只保留最短的生命周期。导致丢失了长生命周期的信息。但这不是协变的问题,而是协变和内部可变性共同导致的。

内部可变性是指,对于一个&T的借用,它本身是不可变借用,但是可以通过一定手段对内部的数据进行修改。也就是本身不可变,但是内部可变,所以叫做内部可变性。

Rust以为你拿到一个不可变借用&MyCell<'a>不会修改任何数据,结果你通过unsafe偷偷修改了,这是你自己在干坏事,不是所有权机制不严谨。

这个场景之所以重要,不在于它能通过 unsafe 偷天换日,而在于它编译器安全假设不可变引用不会修改数据的规则被打破后,会发生未定义行为。

如果你一开始就明确写明可变借用&mut MyCell<'a>Rust立马就能分析出来你有问题:

impl<'a> MyCell<'a> {
    fn set(&mut self, other: &'a i32) {
        unsafe {
            std::ptr::write(&self.value as *const &i32 as *mut &i32, other);
        }
    }
}

fn func<'b>(c: &mut MyCell<'b>) {
    let val: i32 = 2025;
    c.set(&val);
    println!("step1 value: {}", c.value);
}

static X: i32 = 2005;
fn main() {
    let mut cell = MyCell { value: &X };
    func(&mut cell);
    println!(" end value: {}", cell.value);
}

这段代码和之前的变化在于,所有对于Mycell的借用都改为可变借用了,此时Rust知道你要修改它了,立马报错val的生命周期不够长。

在型变之前,我们讲了未绑定生命周期,有人可能会疑惑,这个协变导致的问题,和未绑定生命周期有什么区别?

区别在于,未绑定生命周期中,在函数参数中使用了原生指针。而原生指针不受生命周期约束,因此从进入unsafe之前,编译器就已经失去生命周期信息了。

unsafe fn foo<'a>(input: *const u32) -> &'a u32 {
    &*input
}

fn main() {
    let x;
    {
        let y = 42;
        x = unsafe {foo(&y)};
    }
    println!("hello: {}", x);
}

比如此处的foo函数,在input传进来之前就已经丢失了生命周期,只能乐观认为'a可以满足外部要求。

但是你可以回看MyCell的例子,全程所有函数参数都是借用,它们都被生命周期约束,编译器掌握所有参数的生命周期。而内部可变性与协变的组合,成功在如此严格的约束下瞒骗过编译器。

也就是说,未绑定生命周期是编译器确实无能为力了,编译器知道你已经进入了不受监管的区域。但是协变与内部可变性组合下,编译器不知道这里有问题,它检查后认为它是安全的,这不是一种疏忽,而是不可变的约定被用户提前破坏。

关于内部可变性,此处也只是在unsafe做出一个引子(也已经不简单了),后续会有专门的章节讲解更多相关内容。


总结

想要避免未定义行为,只能通过程序员优良的编码习惯,严谨的思维逻辑。所以C++曾经被认为是最难的语言,就是因为所有不安全行为都要靠程序员自己避免。就算有哪个小漏洞开发时没发现,也没有人告诉你,直到大规模上线到生产环境再暴露出来,这个时候可就不是程序崩溃这么简单了,进一步还可能影响你的年终奖。

Rust将这种不安全场景极致缩小到了unsafe块内部,一旦有问题先去unsafe里面找,测试时也会重点关注这个区域。因此编写unsafe代码时,请拿出十分的精神,将自己的大脑作为编译器,好好推断有没有不安全行为。


评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值