玩转Rust高级应用 如何避免对空指针做“解引用”操作,在C/C++ 里面就是未定义行为
如果你想表达这个类型对T 类型成员有拥有关系,那么可以使用PhantomData。例如std::core::ptr::Unique:
pub Unique<T:?Sized>{pointer:NonZero<*const T>,_marker:PhantomData<T>,
}
如果你想表达这个类型对T 类型成员有借用关系,那么可以使用PhantomData<&'a T>。你还可以用它来表明当前这个类型不可Send 、Sync,示例如下:
struct MyStruct{data:String,_marker:PhantomData<*mut()>
}
下面同样用比较完整的示例来演示一下这个类型的具体作用。假设我们现在有两个类型:
use std::fmt::Debug;
#[derive(Clone,Debug)]
struct S;
#[derive(Debug)]
struct R<T:Debug>{x:*const T
}
其中R 类型想表达一种借用关系,它内部需要用裸指针实现。上面这种简单的写法是有 问题的,因为我们可以很容易制造出悬空指针:
fn main(){let mut r=R{x:std::ptr::null()};{let local =S{};r.x =&local;}//r.x now is dangling pointer
}
为了让编译器使用borrow checker 检查这种内存错误,我们可以给R 类型添加一个生命 周期参数,并且利用PhantomData 使用这个生命周期参数,避免“未使用泛型参数”的错误。 同时给R 类型增加一个成员方法,在成员方法中改变指针的地址,并且通过模块系统禁止外 部用户直接访问R 的内部成员。完整代码如下所示:
use std::fmt::Debug;
use std::ptr::null;
use std::marker::PhantomData;
#[derive(Clone,Debug)]
struct S;
#[derive(Debug)]
struct R<'a,T:Debug +'a>{X:*const T,marker:PhantomData<&'a T>,
}
impl<'a,T:Debug>Drop for R<'a,T>{fn drop(&mut self){unsafe{println!("Dropping R while S{:?}",*self.x)}}
}
impl<'a,T:Debug +'a>R<'a,T>{pub fn ref_to<'b:'a>(&mut self,obj:&'bT){self.x =obj;}
}
fn main(){let mut r=R{x:null(),marker:PhantomData };{let local =S{};r.ref_to(&local);}
}
再编译,我们可以看到,这次编译器就可以成功发现生命周期错误,禁止悬挂指针的产 生。在写FFI 给 C 代码做封装的时候,需要经常使用裸指针,这时就可以用类似的技巧来处 理生命周期的问题。
未定义行为
在 C/C++ 等语言中,未定义行为 (undefined behavior, 简称UB) 指的是,在某些情况下语 言标准允许编译器做任何事情,无论发生什么后果都是正常的,不属于编译器的bug 。比如, 对空指针做“解引用”操作,在C/C++ 里面就是未定义行为,编译器可以决定做任何事情。
在 C/C++ 标准里面,很多情况下,都有充分的理由将某些行为定义为UB, 这是语言本 身的定位决定的。它可以简化编译器的设计,也可以最大化执行效率,还可以最大化跨平 台,等等。但是我们也应该承认,过多的UB 是对用户极其不友好的。在Rust 里 面 ,UB 被 限制在了一个较小的范围内,只有unsafe代码有可能制造出UB, 这也是在写unsafe代码的 时候需要注意的。
下面列举一些undefined behavior, 摘抄自Rust 的 Reference 文档:
- 数据竞争
- 解引用空指针或者悬空指针
- 使用未对齐的指针读写内存而不是使用read_unaligned或者write_unaligned 口读取未初始化内存
- 破坏了指针别名规则
- 通过共享引用修改变量(除非数据是被UnsafeCell包裹的)
- 调用编译器内置函数制造UB
- 给内置类型赋予非法值
●给引用或者Box 赋值为空或者悬空指针
● 给bool类型赋值为0和1之外的数字
● 给enum 类型赋予类型定义之外的 tag标记
● 给char类型赋予超过char::MAX 的值
● 给str类型赋予非utf-8编码的值
以上只是一些典型的问题,完整列表请大家参考官方文档。这些问题只可能在写unsafe 代码的时候出现,这都是需要读者注意的地方。
Rust 的 unsafe 关键字是一个难点,也是很多初学者困惑的地方。很多人有这样的疑惑: 既然Rust 允许使用unsafe 来完成许多危险的操作,那么Rust 的安全性保证是不是就没什么 意义了?
这件事情不能这么理解。unsafe 的存在不是来故意破坏安全性的,它只是一种面向更底 层操作的接口。不同的高级语言对于什么是底层的定义是不同的,但是所有的高级语言,只 要不断往底层探究,总会碰到safe与 unsafe之间的分界线。比如,Java有自己的JNI机制, C# 也有unsafe 关键字,Python 也可以调用C 模块,甚至C/C++ 语言都可以内嵌汇编。当你 在高级语言中与底层操作交互的时候,必须确保高级语言中的一些规则和约定。
Java 、C# 这 类语言,利用GC 实现了内存安全,但是用户同样可以使用JNIunsafe 实现不安全的操作,但 这件事情并不意味着Java 、C# 语言本身有安全性缺陷。同理,在C 语言里面用内嵌汇编搞乱 了堆栈,也不能说是C 语言的设计缺陷。只不过是用户使用这些机制的时候,没有一个自动 检查工具来保证安全性,而是必须由自己来保证上层代码和下层代码之间交互的正确性。
Rust 的 unsafe 最大的问题在于,到目前为止,依然没有一份官方文档来明确哪些东西是 用户可以依赖的、哪些是编译器实现相关的、哪些是以后永远不变的、哪些是将来可能会有 变化的。所以,哪怕用户能确保自己写出来的unsafe 代码在目前版本上是完全正确的,也没 办法确保不会在以后的版本中出问题。
如果以后编译器的实现发生了变化,导致了unsafe 代 码无法正常工作,究竟算是编译器的bug 还是用户错误地依赖了某些特性,还说不清楚。正 式的unsafe guideline 还在继续编写过程中。(当然这种错误情况几率是很低的,绝大多数用 户使用unsafe 的时候都是在FFI 场景下,不会涉及那些精微细密的语义规则。)
我们既不能过于滥用unsafe,也不该对它心怀恐惧。它只是表明,某些代码的安全性依 赖于某些条件,而我们无法清晰地在代码中表达这些约束条件,因此无法由编译器帮我们自 动检查。
unsafe 是 Rust 的一块重要拼图,充分理解unsafe 的意义和作用,才能让我们更好地理解 safe 的来源和可贵。
本节将通过一个比较完整的例子,把内存安全问题分析一遍。本节选择的例子是标准库 中的基本数据结构Vec 源代码分析。之所以选择这个模块作为示例,其一是因为这个类 型作为非常基础的数据结构,平时用得很多,大家都很熟悉;第二个原因是,恰好它的内部 实现又完全展现了Rust 内存安全的方方面面,深入剖析它的内部实现非常有利于加深我们对 Rust 内存安全的认识。本章中用于分析的代码是1.23 nightly 版本,Vec 的内部实现源码在 此之前一直有所变化,以后也很可能还会有变化,请读者注意这一点。
我们先从使用者的角度分析 一 下Vec 是如何自动管理内存空间的:
fn main(){let mut v1 =Vec::<i32>::new();println!("Start:length{}capacity{}",v1.len(),v1.capacity());for i in 1..10{v1.push(i);println!("[Pushed{}]length{}capacity{}",i,v1.len(), v1.capacity());}let mut v2 =Vec::<i32>::with_capacity(1);println!("Start:length{}capacity{}",v2.len(),v2.capacity());v2.reserve(10);for i in 1..10{v2.push(i);println!("[Pushed{}]length{}capacity{}",i,v2.len(), v2.capacity());}
}
编译,执行,从结果中可以看出,如果用new 方法构造出来,一开始的时候是没有分配内存空间的,capacity 为0。我们也可以使用with_capacity来构造新的Vec, 可以自行 指定预留空间大小,还可以对已有的Vec 调用reserve 方法扩展预留空间。在向容器内部 插入数据的时候,如果当前容量不够用了,它会自动申请更多的空间。当变量生命周期结束 的时候,它会自动释放它管理的内存空间。
