第6章 未初始化内存
Rust 程序中所有运行时分配的内存在开始时都是未初始化的。在这种状态下,内存的值是一堆不确定的比特,什么都有可能。试图将这个内存解释为任何类型的值都将导致未定义行为。请不要这样做。
Rust 提供了一些机制,以检查(安全)和不检查(不安全)的方式处理未初始化的内存。
经检查的未初始化的内存
和 C 语言一样,Rust 中的所有堆栈变量都是未初始化的,直到为它们明确赋值。与 C 不同的是,Rust 静态地阻止你读取它们,直到你为它们赋值。
fn main() { let x: i32; println!("{}", x); }
|
3 | println!("{}", x);
| ^ 使用了没有初始化的 `x`
这基于一个基本的分支分析:每个分支都必须在第一次使用x之前给它赋值,方便起见,我们会说“x 被初始化了”或者“x 未初始化”。有趣的是,如果每个分支恰好赋值一次,Rust 不要求变量是可变的,以执行延迟初始化。然而这个分析并没有利用常量分析或类似的东西。所以下述的代码是可以编译的:
fn main() { let x: i32; if true { x = 1; } else { x = 2; } println!("{}", x); }
但这个不行:
fn main() { let x: i32; if true { x = 1; } println!("{}", x); }
|
6 | println!("{}", x);
| ^ 使用了可能没有初始化的 `x`
这个又可以了:
fn main() { let x: i32; if true { x = 1; println!("{}", x); } // 不需要担心还有没有初始化 x 的分支, // 因为我们实际上并没有在别的分支使用 x }
当然,虽然分析不考虑实际值,但它对依赖关系和控制流有相对复杂的理解。例如,这样是可以编译通过的:
#![allow(unused)] fn main() { let x: i32; loop { // Rust 不知道这个分支会被无条件执行, // 因为它依赖于实际值 if true { // 但是它确实知道循环只会有一次, // 因为我们会无条件 break, // 所以 x 不需要是可变的 x = 0; break; } } // Rust 知道如果没有执行 break 的话,代码不会运行到这里 // 所以一旦运行到这里,x 一定已经初始化了 println!("{}", x); }
如果一个值从一个变量中移出,并且该值的类型不是 Copy,该变量在逻辑上就会变成未初始化。也就是说:
fn main() { let x = 0; let y = Box::new(0); let z1 = x; // x 仍然是有效的,因为 i32 可以 Copy let z2 = y; // 现在 y 逻辑上未初始化,因为 Box 不能 Copy }
然而,在这个例子中重新给y赋值需要将y标记为可变,这样一个安全的 Rust 程序就可以观察到y的值发生了变化:
fn main() { let mut y = Box::new(0); let z = y; // 现在 y 逻辑上未初始化,因为 Box 不能 Copy y = Box::new(1); // 重新初始化 y }
否则y就像是一个全新的变量。
丢弃标志
上一节的例子为 Rust 引入了一个有趣的问题。我们已经看到,可以完全安全地对内存位置进行有条件的初始化、非初始化和重新初始化。对于实现了Copy的类型来说,这并不特别值得注意,因为它们只是一堆随机的比特。然而,带有析构器的类型是一个不同的故事。Rust 需要知道每当一个变量被赋值,或者一个变量超出范围时,是否要调用一个析构器。它怎么能用条件初始化来做到这一点呢?
请注意,这不是所有赋值都需要担心的问题。特别是,通过解引用的赋值会无条件地被丢弃,而相对的,在let中的赋值无论如何都不会被丢弃:
#![allow(unused)] fn main() { let mut x = Box::new(0); // let 创建了一个全新的变量,所以一定(也没有必要)调用 drop let y = &mut x; *y = Box::new(1); // 解引用假设原先的变量已经初始化了,因此一定会 drop }
仅当覆盖先前初始化的变量或其子字段之一时,这才是个问题。
这种情况下,Rust 实际上是在运行时跟踪一个类型是否应该被丢弃。当一个变量被初始化和未初始化时,该变量的丢弃标志被切换。当一个变量可能需要被丢弃时,这个标志会被读取,以确定它是否应该被丢弃。
当然,通常的情况是,一个值的初始化状态在程序的每一个点上都是静态已知的。如果是这种情况,那么编译器理论上可以生成更有效的代码。例如,直线型代码就有这样的静态丢弃语义(static drop semantics):
#![allow(unused)] fn main() { let mut x = Box::new(0); // x 未初始化;仅覆盖值 let mut y = x; // y 未初始化;仅覆盖值,并设置 x 为未初始化 x = Box::new(0); // x 未初始化;仅覆盖值 y = x; // y 已初始化;销毁 y,覆盖它的值,设置 x 为未初始化 // y 离开作用域;y 已初始化;销毁 y // x 离开作用域;x 未初始化;什么都不用做 }
类似地,所有分支都在初始化方面具有相同行为的代码具有静态丢弃语义:
#![allow(unused)] fn main() { let condition = true; let mut x = Box::new(0); // x 未初始化;仅覆盖值 if condition { drop(x); // x 失去值;设置 x 为未初始化 } else { println!("{}", x); drop(x); // x 失去值;设置 x 为未初始化 } x = Box::new(0); // x 未初始化;仅覆盖值 // x 离开作用域;x 已初始化;销毁 x }
然而像这样的代码需要运行时的信息来正确地 Drop:
#![allow(unused)] fn main() { let condition = true; let x; if condition { x = Box::new(0); // x 未初始化;仅覆盖值 println!("{}", x); } // x 离开了作用域,可能未初始化 // 检查 drop 标志位! }
当然,在这种情况下,获得静态丢弃语义是很简单的:
#![allow(unused)] fn main() { let condition = true; if condition { let x = Box::new(0); println!("{}", x); } }
丢弃标志在栈中被跟踪。
在旧的 Rust 版本中,丢弃标志曾经是隐藏在实现Drop的类型中。
未经检查的未初始化的内存
这个规则的一个有趣的例外是与数组一起工作。Safe Rust 不允许你部分初始化一个数组。当你初始化一个数组时,你可以用let x = [val; N]将每个值设置为相同的东西,或者你可以用 let x = [val1, val2, val3]单独指定每个成员。不幸的是,这是很死板的,特别是当你需要以更多的增量或动态方式初始化你的数组时。
不安全的 Rust 给了我们一个强大的工具来处理这个问题:MaybeUninit。这个类型可以用来处理还没有完全初始化的内存。
使用MaybeUninit,我们可以对一个数组进行逐个元素的初始化,如下所示:
#![allow(unused)] fn main() { use std::mem::{self, MaybeUninit}; // 数组的大小是硬编码的,可以很方便地修改(改变几个硬编码的常数非常容易) // 这表示我们不能用 [a, b, c] 这种方式初始化数组,因为我们必须要和硬编码中的 `SIZE` 保持同步! const SIZE: usize = 10; let x = { // 创建一个未初始化,类型为 `MaybeUninit` 的数组 let mut x = [const { MaybeUninit::uninit() }; SIZE]; // 因为 drop 一个 `MaybeUninit` 什么都不做, // 所以使用直接的裸指针赋值(而非 ptr::write)不会导致原先未初始化的变量被 drop // 不需要在这里考虑异常安全,因为 Box 永远不会 panic for i in 0..SIZE { x[i] = MaybeUninit::new(Box::new(i as u32)); } // 一切都初始化完毕,将未初始化的类型强制转换为初始化的类型 unsafe { mem::transmute::<_, [Box<u32>; SIZE]>(x) } }; println!("{x:?}"); }
这段代码分三步进行:
-
创建一个
MaybeUninit<T>的数组。 -
初始化数组。这个问题的微妙之处在于,通常情况下,当我们使用
=赋值给一个 Rust 类型检查器认为已经初始化的值时(比如x[i]),存储在左边的旧值会被丢掉。这将是一场灾难。然而,在这种情况下,左边的类型是MaybeUninit<Box<u32>>,丢弃这个类型什么都不会发生,关于这个drop问题的更多讨论,见下文。 -
最后,我们必须改变我们数组的类型,以去除
MaybeUninit。在当前稳定的 Rust 中,这需要一个transmute。这种转换是合法的,因为在内存中,MaybeUninit<T>看起来和T一样。然而,请注意,在一般情况下,
Container<MaybeUninit<T>>与Container<T>看起来并不一样! 假如Container是Option,而T是bool,那么Option<bool>就利用了bool只有两个有效值,但Option<MaybeUninit<bool>>不能这样做,因为bool不需要被初始化。所以,这取决于
Container是否允许将MaybeUninit转化掉。对于数组来说,它是允许的(最终标准库会通过提供适当的方法来达到这一点)。
让我们在中间的循环上多花一点时间,特别是赋值运算符和它与drop的交互。比如这样的代码:
*x[i].as_mut_ptr() = Box::new(i as u32); // 错误!
我们实际上会覆盖一个Box<u32>,导致在未初始化数据上调用drop,这将给你带来很多乐子。
如果由于某种原因我们不能使用MaybeUninit::new,正确的选择是使用ptr模块。特别是,它提供了三个函数,允许我们将字节分配到内存中的某个位置而不丢弃旧值。write、copy和copy_nonoverlapping。
ptr::write(ptr, val)接收一个val并将其移动到ptr所指向的地址ptr::copy(src, dest, count)将count个 T 所占用的位从 src 复制到 dest(这等同于 C 的 memmove —— 注意参数顺序是相反的!)ptr::copy_nonoverlapping(src, dest, count)做的是copy的工作,但是在假设两个内存范围不重叠的情况下,速度更快(这等同于 C 的 memcpy —— 注意参数顺序是相反的!)
自然不用说,这些函数如果被误用,会造成严重的破坏,或者直接导致未定义行为。这些函数本身需要的唯一东西是,你想读和写的位置已经被分配并正确对齐。然而,向内存的任意位置写入任意位的方式所带来的问题是无穷无尽的。
值得注意的是,你不需要担心在未实现Drop或者不包含Drop类型的类型上使用ptr::write带来的问题,因为 Rust 知道这个信息,并且不会调用drop。这也是我们在上面的例子中所依赖的。
然而,当你处理未初始化的内存时,你需要时刻警惕 Rust 试图在它们完全初始化之前丢弃你创建的这些值。如果它有一个析构器的话,该变量作用域内的每个控制路径必须在结束前初始化该值。这包括 panic。MaybeUninit在这方面有一点用,因为它不会隐式地丢弃它的内容——但在 panic 的情况下,这实际上意味着不是对尚未初始化的部分进行双重释放,而是对已经初始化的部分导致了内存泄漏。
注意,为了使用ptr方法,你需要首先获得一个你想初始化的数据的raw pointer。对未初始化的数据构建一个引用是非法的,这意味着你在获得上述原始指针时必须小心:
- 对于一个
T的数组,你可以使用base_ptr.add(idx),其中base_ptr: *mut T来计算数组索引idx的地址。这依赖于数组在内存中的布局方式 - 然而,对于一个结构体,一般来说,我们不知道它是如何布局的,而且我们也不能使用
&mut base_ptr.field,因为这将创建一个引用。因此,当你使用raw 引用的时候,你必须非常小心,这将跳过中间层直接创建一个指向该字段的裸指针:
#![allow(unused)] fn main() { use std::{ptr, mem::MaybeUninit}; struct Demo { field: bool, } let mut uninit = MaybeUninit::<Demo>::uninit(); // `&uninit.as_mut().field`将会创建一个指向未初始化的`bool`的指针,而这是 UB 行为。 let f1_ptr = unsafe { &raw mut (*uninit.as_mut_ptr()).field }; unsafe { f1_ptr.write(true); } let init = unsafe { uninit.assume_init() }; }
最后一句话:在阅读旧的 Rust 代码时,你可能会无意中发现被废弃的mem::uninitialized函数。这个函数曾经是处理栈上未初始化内存的唯一方法,但它被证明不能与语言的其他部分很好地结合在一起。在新的代码中你总是应该使用MaybeUninit来代替,并且当你有机会的时候,可以把旧的代码移植过来。
这就是与未初始化内存打交道的方法。基本上没有任何地方希望得到未初始化的内存,所以如果你要传递它,一定要非常小心。