回上级页面

全局变量

2023 年 07 月 05 日


问题

在前面一些章节里,在使用正则表达式对文本进行分割时,皆使用局部变量存储正则表达式,且皆为硬代码——在程序运行时无法修改正则表达式。本章尝试构造一个可在运行时被修改的全局变量用以表达正则表达式。

失败的全局原始指针

倘若将原始指针作为全局变量,在程序运行时,可以令其指向与其类型相匹配的任何一个值,这是我想要的全局变量。于是,试着写出以下代码:

use regex::Regex;
use std::ptr::null_mut;
let a: *mut Regex = null_mut();

fn main() {
    a = Box::into_raw(Box::new(Regex::new(" *@ *")));
    let v = (*a).unwrap().split("num@ 123@456  @ 789");
    for i in v {
        println!("{}", i);
    }
}

Rust 编译器编译上述代码时会报错,建议使用 conststatic 代替全局变量 a 的定义语句中的 let,亦即 Rust 语言不允许使用 let 定义全局变量。const 修饰的全局变量,其值不可修改。static 修饰的全局变量,其值可修改。故而,我将变量 a 的定义修改为

static a: *mut Regex = null_mut();

Rust 编译器依然报错,称 *mut regex::Regex 类型的值不能被不同的线程安全共享,虽不甚知其意,但也应知此路不通了。

也许在素有经验的 Rust 程序员看来,上述代码会令他一言难尽,但是如果我说通过以上代码可以看出 Rust 语言并不希望程序员使用全局变量,料想不会引起他的反对。Rust 不希望什么,那是它的事,而我却需要它。现在的问题是,无法构造全局原始指针。Rust 编译器给出的建议是,如果想让 *mut regex::Regex 类型的指针作为全局变量,前提是需要为该类型实现 Sync 特性。这个建议对于目前的我来说是超纲的,所以我完全可以认为,在 Rust 语言中不允许出现全局原始指针。

Option<T> 于事无补

在表示空值方面,Option<T> 类型可以代替原始指针,用该类型封装原始指针是否能作为全局变量呢?试试看:

static foo: Option<*mut i32> = None;

fn main() {
    let a = 3;
    foo = Some(&a as *mut i32);
    println!("{:?}", foo);
}

答案是否定的。Rust 编译器依然称:

`*mut i32` cannot be shared between threads safely

并建议

shared static variables must have a type that implements `Sync`

此路依然不通。

结构体屏障

无论是直接用原始指针,还是用 Option<T> 封装原始指针,在构造全局变量时,都会导致原始指针直接暴露在 Rust 编译器面前,而编译器坚持认为,所有的全局变量类型都应该实现 Sync 特性。现在,换一个思路,倘若将原始指针类型封装在结构体中,是否可以骗过编译器呢?

以下代码将 *mut i32 类型的指针封装在一个结构体类型中,并使用该结构体类型构造全局变量:

#[derive(Debug)]
struct Foo {
    data: *mut i32
}

static mut A: Foo = Foo{data: std::ptr::null_mut()};

fn main() {
    unsafe {
        println!("{:?}", A);
    }
}

上述程序可以通过编译,其输出为

Foo { data: 0x0 }

以下代码尝试能否修改 A.data 的值:

let mut a = 3;
unsafe {
    A.data = &mut a as *mut i32;
    println!("{:?}", A);
    println!("{}", *A.data);
}

依然能通过编译,其输出结果与以下结果类似:

Foo { data: 0x7fff64cdecb4 }
3

这样骗编译器,好么?我不知道。Rust 标准库在 std::marker::Sync 的文档中提到,所有的基本类型,复合类型(元组、结构体和枚举),引用,Vec<T>Box<T 以及大多数集合类型等皆实现了 Sync 特性,所以上述手法并不能称为「骗」。

回到本章开始的问题,现在可写出以下代码:

use regex::Regex;
use std::ptr::null_mut;

#[derive(Debug)]
struct Foo {
    data: *mut Regex
}

static mut A: Foo = Foo{data: null_mut()};

fn main() {
    unsafe {
        A = Foo {data: Box::into_raw(Box::new(Regex::new(" *@ *").unwrap()))};
        let v = (*A.data).split("num@ 123@456  @ 789");
        for i in v {
            println!("{}", i);
        }
        let _ = Box::from_raw(A.data);
    }
}

注意,上述代码中的 let _ = ... 表示不关心右侧函数调用的返回值,但是该行代码可将 A.data 指向的内存空间归还于 Rust 的智能指针管理系统,从而实现自动释放。

制造内存泄漏

上述基于原始指针的全局变量构造方法似乎并不为 Rust 开发者欣赏,因为在他们眼里,任何一个原始指针都像一个不知道什么时候会被一脚踩上去的地雷,他们更喜欢是引用。

下面尝试使用引用构造全局变量。由于引用不具备空值,所以必须使用 Option<T> 进行封装,例如

use regex::Regex;
static mut A: Option<&Regex> = None;

fn main() {
    unsafe {
        let re = Regex::new(" *@ *").unwrap();
        A = Some(&re);
        // ... 待补充
    }
}

Rust 编译器对上述代码给出的错误信息是,re 被一个全局变量借用,但是前者的寿命短于后者,亦即当后者还存在时,前者已经死亡,导致后者引用失效。在 C 语言中,这种错误就是鼎鼎有名的「悬垂指针」错误,Rust 编译器会尽自己最大能力去阻止此类错误。

不过,Rust 标准库给我们留了一个后门,使用 Box<T>leak 方法可将位于堆空间的值的寿命提升为全局变量级别的寿命:

unsafe {
    let re = Box::new(Regex::new(" *@ *").unwrap());
    A = Some(Box::leak(re));
    let v = A.unwrap().split("num@ 123@456  @ 789");
    for i in v {
        println!("{}", i);
    }
}

需要注意的是,Box::leak 名副其实,会导致内存泄漏,因为堆空间的值其寿命经 Box::leak 提升后,与程序本身相同,无法回收。Rust 官方说,如果你介意这样的内存泄漏,那就需要考虑走原始指针路线。

延迟初始化

对于支持运行时修改的全局变量,还有一类方法是将全局变量的初始化推迟在程序运行时,但该类方法要么依赖第三方库(crate),例如 lazy_static,要么是标准库目前尚未稳定的功能 OnceCell,此外该类方法只能对全局变量完成一次赋值。这些方法,rzeo 并不打算使用,故而略过。

值的所有权转移

基于值的所有权转移也能实现在程序的运行时修改全局变量的值。例如

use regex::Regex;
static mut A: Option<Regex> = None;

fn main() {
    unsafe {
        let re = Regex::new(" *@ *").unwrap();
        A = Some(re);
        let v = A.unwrap().split("num@ 123@456  @ 789");
        for i in v {
            println!("{}", i);
        }
    }
}

不过,上述代码无法通过编译,原因是 Option<T> 的实例方法 unwrap 需要转移实例的所有权——消耗一个临时变量,但是上述代码中的 Option<T> 的实例 A 是全局变量,与程序同寿,其所有权无法转移。有两种方法可规避该错误,一种是

unsafe {
    let re = Regex::new(" *@ *").unwrap();
    A = Some(re);
    match A {
        Some(ref b) => {
            let v = b.split("num@ 123@456  @ 789");
            for i in v {
                println!("{}", i);
            }
        },
        None => panic!("...")
    }
}

另一种是使用 Option<T>as_ref 方法,将类型 &Option<T> 转换为类型 Option<&T>,然后使用 Option<&T>unwrap 方法:

unsafe {
    let re = Regex::new(" *@ *").unwrap();
    A = Some(re);
    let v = A.as_ref().unwrap().split("num@ 123@456  @ 789");
    for i in v {
        println!("{}", i);
    }
}

不妨将 as_ref 方法视为上述模式匹配代码的简化。

小结

全局变量是构成程序的不安全因素之一,但它并非洪水猛兽,只要保证程序在任一时刻全局变量不会被多个线程同时修改即可。如果全局变量给程序带来了灾难,这往往意味着是程序的设计出现了严重问题。我认为 Rust 对全局变量的限制太过于严厉,特别是在禁止直接将原始指针作为全局变量这一方面,毕竟即使不使用原始指针,对全局变量的修改在 Rust 语言看来,也是不安全的。既然都不安全,何必五十步笑百步。