Rust整体被分为Safe和Unsafe两部分。在Safe Rust中,你可以在规则下构建高度安全的程序。但是有一些操作本身就是不安全的,比如在Rust中调用C接口,如果要进行一些本身就不安全的操作,就需要使用Unsafe Rust了。
在《Rust 编程之道》中这么比喻:
Rust就像一艘遨游于太空的宇宙飞船,不论外太空多么危险,宇航员只要待在飞船内部,就是安全的。当宇航员要去飞船外执行任务,就必须穿好宇航服,经由减压舱到达飞船外部。宇航员一旦进入外太空,就必须自己保证安全,因为此时他已经完全暴露于不安全的环境之下。Safe Rust就是提供安全庇护的飞船,而Unsafe Rust就是宇航服、减压舱,以及飞船外部与宇航员任何有关联的部分。
这个比喻揭示了一个关系,在Unsafe Rust中,程序本身就暴露在不安全的环境下,而Rust提供了部分工具来保证用户的安全,但是无法避免用户本身进行不安全行为。
unsafe
使用unsafe关键字来使用Unsafe Rust,分为两种语法:
unsafe关键字:用于标记函数、方法、traitunsafe block:用于执行非安全操作
例如:
unsafe fn unsafe_ptr() {}
unsafe trait unsafe_trait {}
fn main() {
unsafe {
unsafe_ptr();
}
}
代码中,使用unsafe标记了一个函数和一个trait,并在main中使用unsafe {}调用了非安全函数。
Unsafe Rust是Safe Rust的超集,也就是说在unsafe中所有safe的规则生效,比如说所有权、生命周期、借用、模式匹配等等。
但是在Unsafe Rust中,提供了一些非安全操作:
- 解引用原生指针
- 调用
unsafe的函数和方法 - 修改可变静态变量
- 实现
unsafe trait - 读取联合体
它们必须在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是一个联合体,它包含i和u两个字段。一开始初始化u.f = 3.14,随后使用f接受unsafe block的返回值。
unsafe block也是一种块,因此也是以最后一个表达式作为整体的返回值。从逻辑上这是一个安全的行为,因为初始化是f32,读取时也是f32。
但是后续在unsafe中输出了两次,分别读取u.i和u.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函数有两种场景:
- 函数内部使用了
unsafe操作 - 函数可能因为用户某些操作导致崩溃,通过
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 i32 与 as *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关键字,此时直接创建指定变量的原生指针。并且要写出const和mut关键字,表示是否可变。
原生指针有以下特点:
- 原生指针可以是任意地址(空指针、垂悬指针),因此有可能指向一块非法内存
- 不具有
RAII机制,需要手动管理指向的内存 - 没有生命周期,不进行借用检查
- 不保证多线程安全
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。
add和offset的功能是相同的,但是add只能传入一个正数,而offset可以传入负数向前偏移。
随后ptr::read(p_const.add(1))读取了第一个元素的值,这个过程会发生拷贝,但不是调用copy或者clone,而是直接对栈区数据进行拷贝。
后续使用ptr::write(p_mut.add(4), 999)将第五个元素更改为999,对于i32来说这是安全的。但是如果说数组的元素是一个String,此时就会发生内存泄露,因为write只在栈区覆盖旧值,不会调用析构函数。
最后分别用replace和swap对元素进行替换和交换,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函数,首先使用Box将MyString放到堆区,随后为其创建了一个原生指针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
以上代码中,p1和p2会报错,p3和p4正常。
其实还是因为,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不是被销毁了吗,为什么还能正常运行。
因为x和y都是值类型,它们所有数据都在栈区。而栈区是按照栈帧回收的,它们都在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表示,意味着A是B的子类型。
在面向对象语言中,对象之间有继承关系,那么就有明确的父子关系。在一个需要父类的位置传入子类,也被称为多态。比如Duck <: Animal,那么就可以在需要Animal的函数参数中传入一个Duck的实例,因为Duck继承后,一定实现了Animal所需的所有方法。
型变
假设现有两个类型A和B,关系是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的生命周期为's1,str2的生命周期为's2,longer_str接收两个参数,最后'a = min('s1, 's2) = 's2。
也就是说,经过推断后函数的两个参数以及返回值都是's2。当第一个参数把生命周期's1传入的时候,编译期发现需要的生命周期是's2,但是由于's1更长,所以存在关系's1 <: 's2,所以可以在需要's2的位置传入's1。
在longer_str函数内部,编译期并不知道a和b在外部的生命周期是什么,只知道它们都是'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"的生命周期是'h,str2内部的"world"的生命周期是'w,最后'b = min('h, 'w) = 'w,因此MyStr<'b>就是MyStr<'w>。
函数的两个参数都要接受&MyStr<'w>类型的参数,对于str2当然符合,但是str1的类型是&MyStr<'h>,类型不匹配。但是由于'h长于'w,所以存在'h <: 'w。而Rust绝大多部分数据类型都是协变类型,因此&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中创建了一个静态常量X,cell.value则借用X,随后调用func(&cell),再输出cell.value。
最后这段代码可以成功运行,输出结果:
change value: 2025
end value: 随机值
如果你在合适的版本运行这段代码,最后你会发现两个问题:
- 这个代码居然成功运行了,而且成功修改了借用值
- 最后
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.value和other。我特地写出了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代码时,请拿出十分的精神,将自己的大脑作为编译器,好好推断有没有不安全行为。

1113

被折叠的 条评论
为什么被折叠?



