高级 The Rust Project Developers 著,Pure White 中译 2026-09-13 14:59:14 · 0 阅读

第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:?}");
}

这段代码分三步进行:

  1. 创建一个MaybeUninit<T>的数组。

  2. 初始化数组。这个问题的微妙之处在于,通常情况下,当我们使用=赋值给一个 Rust 类型检查器认为已经初始化的值时(比如x[i]),存储在左边的旧值会被丢掉。这将是一场灾难。然而,在这种情况下,左边的类型是MaybeUninit<Box<u32>>,丢弃这个类型什么都不会发生,关于这个drop问题的更多讨论,见下文。

  3. 最后,我们必须改变我们数组的类型,以去除MaybeUninit。在当前稳定的 Rust 中,这需要一个transmute。这种转换是合法的,因为在内存中,MaybeUninit<T>看起来和T一样。

    然而,请注意,在一般情况下,Container<MaybeUninit<T>>Container<T>看起来并不一样! 假如ContainerOption,而Tbool,那么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模块。特别是,它提供了三个函数,允许我们将字节分配到内存中的某个位置而不丢弃旧值。writecopycopy_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 试图在它们完全初始化之前丢弃你创建的这些值。如果它有一个析构器的话,该变量作用域内的每个控制路径必须在结束前初始化该值。这包括 panicMaybeUninit在这方面有一点用,因为它不会隐式地丢弃它的内容——但在 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来代替,并且当你有机会的时候,可以把旧的代码移植过来。

这就是与未初始化内存打交道的方法。基本上没有任何地方希望得到未初始化的内存,所以如果你要传递它,一定要非常小心。

评论 (0)