从字符串来浅谈Rust内存模型

总算是把期末考最忙的一阵子熬过去了,来整理整理快发霉的博客。这篇文章躺在草稿箱快有一个学期了,期间我也对Rust有了更深的认识,于是正好改写作为假期的第一篇文章。

最近我尝试在课设程序中引入了Rust,理由很简单——Rust是我心目中不可多得的在语言层面尝试改进内存安全与高性能的现代编程语言。不过这种尝试确实相当前卫,以至于让Rust对初学者显得不是那么友好。在这篇文章中,我将尝试通过字符串的实现来对Rust的存储管理进行分析。本文的目标读者是对Rust没有了解或了解不多的初学者。

std::string——可行的做法

逻辑上来讲,字符串就是一系列连续的字符,因此只需要存储字符串长度(可以是\0的方式)、字符串数据就足够了。暂且不考虑静态区,那么能分配字符串的就只有栈、堆两处了。

首先是分配在栈上。由于栈空间上分配的数据生命期仅在一个函数的调用中有效,因此这种方式只能适用于局部变量、参数等的存储。而且在有堆的语言中(这里说的堆栈都是编程语言级别的概念)通常栈的大小相对有限,因此分配到栈上显然不妥。

在剩下的选项中,Java选择了都分配在堆上。用C++来说明这种分配方式就是

string* reverse(string* str) {
    string* result = new string;
    for (int i = str->length() - 1; i >= 0; i--) {
        result->push_back((*str)[i]);
    }
    return result;
}

int main() {
    auto a = new string("abc");
    auto ret = reverse(a);
    cout << *ret << endl;
    delete ret;
    delete a;
}

这种方式虽然规避了传参、返回时对字符串数据的复制,但是却引入了一些混乱。首先就是参数语义上的不一致,由于通过指针传入的字符串和调用方共享,因此对形式参数字符串的修改会影响到调用方的实际字符串,而这和基本类型的行为并不相同。

另外一个问题是需要程序员手动进行释放,而这很可能会引发混乱。比如这个例子

std::string* longest(std::string* a, std::string* b) {
    return a->length() > b->length() ? a : b;
}

int main() {
    auto a = new std::string("abc");
    auto b = new std::string("233333");
    auto ret = longest(a, b);
    cout << *ret << endl;
    delete ret;
    delete b; // 错误:二次释放!
    delete a;
}

同样都是调用函数返回字符串,但reverselongest的不同行为却导致了释放代码的不同。稍有不慎就可能导致二次释放或内存泄露的问题。因此这种方式适合Java这种有GC帮助回收内存的语言。至于语义的问题,Java通常使用“不可变对象”来解决,比如Java字符串。而C则可以使用const关键字来限制指针只可读。

那如何解决堆上存储的一系列问题呢?一种可行的方案是把堆指针交给程序来管理,也就是依旧把字符串数据分配在堆上,但是另在栈上分配一个管理字符串的对象(维护指向字符串数据的指针)。虽然本质上来讲,之前的对象也会把指针放在栈上,但是从语言的角度来看那仅仅是个指针,并没有管理的逻辑。

C++的std::string就采用了这种实现方式。改写之前的longest函数,可以发现现在对于形参的改变不会再影响实参了,并且去配(销毁)也不需要手动进行。

std::string longest(std::string a, std::string b) {
    // 传参时完整复制(堆+栈)a和b的数据
    // 因此函数内修改a,main中的a也不会变
    return a.length() > b.length() ? a : b;
    // 返回时也完整复制
    // 同时,退出函数时自动去配a、b
}

int main() {
    std::string a = "abc";
    std::string b = "233333";
    auto ret = longest(a, b);
    cout << ret << endl;
    // 退出函数时自动去配a、b
}

不过对于部分情况,参数和返回值其实没必要多复制一次。比如longest函数其实只需要直接返回其中的一个参数即可。对于这种情况,C++的办法是回到解决堆问题的老办法——提供类似指针的“引用”机制来表达这种操作。

std::string& longest(std::string& a, std::string& b) {
    // 由于a、b通过引用传入,因此C++不会复制字符串数据
    return a.length() > b.length() ? a : b;
}

看起来不错!如果需要限制写操作,同样需要在类型签名中加上const。为了让使用更加便捷,C++还开了个后门——允许常量左值引用可以通过右值初始化。比如上面的函数可以通过这种形式调用:longest("str", "string")

但是使用引用/指针只能减少参数的重复复制,对于返回值却不一定可用。比如reverse函数,此时我们期望reverse返回新的字符串来表示逆序的结果。一种可能的错误写法是

std::string& reverse(const std::string& str) {
    std::string result;
    for (int i = str.length() - 1; i >= 0; i--) {
        result.push_back(str[i]);
    }
    return result;  // 悬垂引用!
}

这里返回的result引用实际上是无效的,因为函数调用结束的时候result就已经被释放了,而result的引用却仍旧存在。这就是悬垂引用问题,编译器通常会在此处给出一个警告。那如果不在栈上分配,而是改为使用new std::string的方式返回新的堆字符串呢?行倒是行,但那样就需要手动去配字符串了。

说到底,问题还是出在直接使用了不受管理的指针/引用。因此最合适的方法是将堆上字符串的数据转交给新的管理对象,这样就只需要创建新的管理对象了(代价极小)。C++对此给出的方案是引入了“右值引用”,也就是针对“值”语义的引用。简单的讲,就是C++允许对象创建的时候对“右值”进行特殊判断,这个特殊的构造器称为“移动构造器”。比如std::string(std::string("233"))里面的std::string("233")就是右值,因此会调用移动构造器(不过这种情况应该会被编译优化,可以通过参数-fno-elide-constructors禁用)。显然它的引用不会泄露出来,因此可以用这个特殊判断甄别需要转交的情况。所以可以做如下改进

std::string reverse(const std::string& str) {
    std::string result;
    for (int i = str.length() - 1; i >= 0; i--) {
        result.push_back(str[i]);
    }
    return std::move(result);
}

int main() {
    std::string a = "abc";
    auto ret = reverse(a);
    cout << ret << endl;
}

在返回时调用的std::move是将左值result转换为右值引用的标准函数。因此在构建返回的对象时,C++将使用字符串的移动构造器。移动构造器征用了result在堆上的内存,并在栈上分配了结构体,而这就是ret变量对应的std::string对象。

移动构造器的运行过程

因此,这个时间点发生在返回对象的构建中,而不是std::move函数的执行过程中(虽然函数的名字就叫“移动”)。可以通过如下代码测试

std::string reverse(const std::string& str) {
    std::string result;
    for (int i = str.length() - 1; i >= 0; i--) {
        result.push_back(str[i]);
    }
    std::string&& rRef = std::move(result);
    cout << "After std::move() call: " << result;
    // 打印:After std::move() call: cba
    return rRef; // 此处执行移动构造器,“消耗”了 rRef
}

这样作为返回值的字符串也不会被重复复制了!不过你应该也发现了问题,C++的“移动”并不是完美匹配必须移动的场景,而是用“右值”来辅助判断。若是正常情况倒是无所谓,但是如果遇到不讲武德的代码,那就可能会发生内存问题了。比如这个年轻人

obj a;
obj b(std::move(a));
a.test();  // 错误!a已经被移动了

这里错误的在移动a之后继续使用a,可能产生潜在的内存问题。所以在使用的时候还需要讲规矩,不能乱打。

不过总之,我们还是得到了一个开销不大的字符串设计。可以发现其中的关键在于使用管理对象和复用堆数据(即移动构造器和std::move)。

Rust的内存管理

上一节中已经介绍了C++的字符串,可以看到在C++强大的表达能力下是可以实现开销相对小的字符串的(个人觉得比较完美)。不过由于各种原因C++并未对编码进行过多的检查,这导致C++允许违反设计意图的代码通过编译,从而造成潜在的内存错误。Rust则从语言本身解决了这个问题。Rust的字符串类似C++,但是它将移动升级为语言的一种核心机制,并配合其他机制共同保证内存安全。

所有权:转移的安全

在Rust中,传参、返回甚至变量绑定等都默认进行移动操作。但是由于并不是所有数据都像字符串一样分为两个部分,因此Rust建立了一个抽象概念——所有权。对Rust而言变量占有了它存储的数据(获得所有权),因此其他变量无法再拥有这个数据,除非通过操作转移所有权。这就保证了转移操作的安全。

let a = "123".to_string();
let b = a; // 所有权转移给b
println!("{}", a);  // 错误:不能借用已被移动的值a

对于函数参数,可以视为形参的值被移入实参。而返回值则是被移出了函数。

fn method(a: String) -> String {
    a + "233"
}

fn main() {
    let a = String::from("hi ");
    println!("{}", method(a)); // a的所有权转移给实参
    println!("{}", a); // 错误:不可以再使用a
}

拷贝、克隆

C++中默认的拷贝操作在Rust中变成了额外的Copy trait(你可以理解成Copyable接口,类似Java中的Comparable一类接口),只有部分类型(通常是拷贝代价小的)才会实现。

let a = 1;
let b = a; // 基本类型默认实现Copy trait
println!("{}", a); // 可以继续使用a,因为并没有转移所有权而只是复制

通常Copy trait只用于栈上数据的复制,也就是浅拷贝,编译器会自动调用。此外Rust还提供了深拷贝用的Clone trait。不过由于深拷贝通常性能损耗较大,因此编译器不会自动调用,需要手动调用clone函数。

虽然看起来Rust只是把默认的操作从拷贝换成了移动,并没有本质区别。但其实这种改变影响了编程者的编码思维。编程者通常会考虑权重更高、更频繁的操作,C++中默认是复制,因此考虑“移动”的频率大大降低,这使得一些本来不需要复制的操作使用了复制(甚至是深复制)。而Rust选择了更为简单的“移动”作为默认操作,把不一定可实现的“复制”“克隆”作为附加的trait,这就引导编码者在编码时就考虑是否需要额外的“复制”“克隆”来达到目的。这不仅给编译优化提供了空间,更重要的是它在编码阶段就引导用户写出更高效的代码。Rust中有不少这样按功能复杂程度设计的机制,它们引导编码者先用相对轻量、简单的机制编写,遇到困难再考虑其他复杂机制提供的“能力”。在我看来,这就是Rust哲学的一种体现。

引用与生命周期

虽然所有权有助于改善代码结构,但是它实际上使得数据只能被绑定在一个变量上,而这会使部分功能难以甚至无法实现。比如对于reverse函数,如果每次调用都移入字符串,那字符串在调用函数后就没法使用了。因此,就必须返回两个字符串以防止失去参数字符串的所有权,“有借有还”。

fn reverse(a: String) -> String {
    a.chars().rev().collect()
}

fn reverse2(a: String) -> (String, String) {
    (a.chars().rev().collect(), a)
}

fn main() {
    let a = "ordered".to_string();
    let r = reverse(a);
    println!("Reverse: {}", r);
    println!("Original: a = {}", a); // 错误!a已经被移动

    let (r, a) = reverse2(a); // 重新绑定函数“返还”的a
    println!("Reverse: {}", r);
    println!("Original: a = {}", a);
}

这样显然不太合理,因此Rust还是提供了一种让其他变量也能访问数据的方式:引用。引用可以看作C/C++中指针的抽象版本,它允许一个数据可以被反复引用。对于函数,这个操作相当于函数借用了参数的所有权,但并不进行移动。因此reverse函数可以更改如下

fn reverse(a: &String) -> String {
    a.chars().rev().collect()
}

fn main() {
    let a = "ordered".to_string();
    let r = reverse(&a);
    println!("Reverse: {}", r);
    println!("Original: a = {}", a);
}

&a就是在传参时取a的引用,这样a就不会被移入reserve。实际上能调用a.chars的原因也是chars自动获取了a的引用。

但是在longest的改写中就会遇到问题,如果直接改写的话编译器会报缺少生命期界定符(lifetime specifier)。

fn longest(a: &String, b: &String) -> &String {
    if a.len() > b.len() { a } else { b }
}

生命期界定符是Rust给引用加的一个限制。由于引用的数据本身有可能失效,而如果在数据失效时通知每个引用销毁又要带来额外的开销,因此Rust的思路是在编译期就确定好引用的有效界限,也就是“生命期”。生命期就是数据创建后、销毁前的若干代码行,由于Rust通常会在离开作用域时销毁变量,因此生命期的长度通常是从变量赋值直到作用域结束。

{
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {}", r); //          |
}                         // ---------+

比如这段代码,由于x的生命期'b在内层作用域退出后就结束了,但是r的生命期'a是在外层嵌套后结束的,也就是说r引用了生命期比他还短的对象x,因此发生了错误。

回到longest,编译器正是因为无法确定返回值的生命期才会报错。而根据编译器报错的提示,可以更改为如下代码

fn longest<'a>(a: &'a String, b: &'a String) -> &'a String {
    if a.len() > b.len() { a } else { b }
}

这里在每个引用后面引入了'a符号,用来标记引用的生命期为a。方法名后使用<'a>声明了方法声明中使用的生命期变量。而最后返回值的'a表示返回值的生命期。注意虽然这里两个参数都使用了同一个生命期,但Rust会选择较短的一个生命期,而不是选择较长字符串的生命期,比如

let a = "longer".to_string();
let r;

{
    let b = "short".to_string();
    r = longest(&a, &b);
}

println!("Longer: {}", r);  // 错误!b生命期太短

这是因为Rust是在编译期确定生命期的,因此没办法根据函数逻辑决定生存期的长短。

所有权与可变性

另一个Rust重新调整操作顺序的例子就是可变性。在Rust中,默认的变量和引用都是不可变的,必须加上mut才能使其可变。比如对于变量

let mut a = 1;
a = 2;

不过由于Rust允许重新绑定同一个名字的变量,因此除了分支、循环等情况,一般并不需要对基本类型使用可变变量。

let a = 1;
let a = "123"; // 重新绑定a,但注意这是编译期的行为,因此不能用于循环等

因此能使用重新绑定的情况,编码者通常也会优先考虑使用重新绑定。

对于引用,也可以区分为可变引用、不可变引用。不可变引用之前已经介绍过了,不再赘述。与其他语言不同的是可变引用,Rust不允许有多个可变引用同时存在,并且不允许可变、不可变引用同时存在。如果把可变、不可变引用看作对同个数据的读写操作,那Rust在编译期就尝试排除了读写冲突。

let mut s = "hello".to_string();

{
    let r1 = &s; // 正确
    let r2 = &s; // 正确
    let r3 = &mut s; // 错误。已经有不可变引用

    println!("{}, {}, {}", r1, r2, r3);
}

let r4 = &mut s; // 正确。之前的不可变引用已经销毁
println!("{}", r4);

可变引用的常见使用是结构体的方法。当需要修改结构体(也就是修改“数据”)时,结构体方法可以获得一个可变的自身引用以修改自身结构体的数据,比如Vecpush方法等。

静态区与unsafe:无法避免的例外

Rust也有全局的静态变量,它使用static声明。但是静态变量存在一个问题,那就是修改静态变量是无法检查是否有读写冲突的。由于静态变量作为一个非常特殊的存在,所有函数都可以访问它,因此编译器没法确定访问操作执行的顺序。所以首先它无法被移动,因为没法确定使用静态变量时它是否已经被移动。其次没办法对它进行安全的修改。正常情况数据仅能被绑定到一个变量上,而一个可变变量只在一个作用域内有效,就算产生了引用也有可变引用的借用限制,因此它的读写顺序是可以确定的。但是由于静态变量同时在多个作用域内出现,因此如果它是可变的就没办法保证读写不发生冲突,于是Rust就禁止了对可变静态变量的读、写。如果一定要操作,则必须在unsafe块内对可变静态变量进行操作。

从引用到切片

对于数组的访问,Rust也给出了一个内存安全的方案:切片。从存储内容上来讲,切片只是在引用的基础上多存储了一个数据长度,因此切片可以用来表示一段连续的数据。但这就是问题的根源,因为Rust没办法针对单个数据检查借用规则。不妨考虑下面的情况

let mut arr = [1, 2, 3, 4];

let first = &mut arr[0..2];
let second = &mut arr[2..4];

println!("{:?}, {:?}", first, second); // 错误!arr已经被可变借用

从逻辑上说这段代码没有问题,因为两个区间并没有相交,因此实际上并没有对同一个数据借用两个可变引用。但是对于Rust来说判断修改区间是否重叠不一定能在编译期完成,因此Rust选择以数组为单位运行借用规则检查。所以示例中因为重复借用arr的可变引用导致了编译错误。

不过由于这样会给编程带来种种不便,因此Rust还是保留了特例,比如在标准库中的split_at函数。

let mut arr = [1, 2, 3, 4];

let whole = &mut arr[..];
let (first, second) = whole.split_at(2);

println!("{:?}, {:?}", first, second); // 可以通过编译

背后的机制是Rust的unsafe,它允许我们创建C语言指针类似的裸指针,并在unsafe块内突破借用机制的限制。Rust有很多这样的例子,其根本的目的在于将不安全的代码封装成安全的函数,这样可以最大限度的利用Rust的检查机制,也有利于排查错误。

堆的管理:智能指针

C/C++中都提供了特殊的方式来分配堆内存,如C语言的malloc函数和C++的new关键字,而它们的返回值都是对应类型的指针。和字符串的例子一样,指针的使用意味着可能存在不安全的代码。而Rust的解决方案也和字符串一样:给指针引入管理对象。在C++中这种管理对象也叫智能指针,由标准库提供。Rust中的管理对象和智能指针基本可以对应,比如最简单的Box<T>就对应了std::unique_ptr<T>。而它们的实现原理也类似,之前字符串的例子中也有所提及,就是通过管理对象的生灭来间接控制堆数据的生灭。由于基本原理和之前的内容类似,所以就不占用这篇文章的篇幅说明了,或许我会专门开一篇文章来介绍。

Reference

  1. 《Rust 程序设计语言》(中译:https://kaisery.github.io/trpl-zh-cn/
分享到

KAAAsS

喜欢二次元的程序员,喜欢发发教程,或者偶尔开坑。(←然而并不打算填)

相关日志

  1. 没有图片
  2. 没有图片
  3. 没有图片
  4. 没有图片
  5. 没有图片

评论

  1. 窃时者 2021.02.06 3:53下午

    到最后我认识的所有人都会学rust(

在此评论中不能使用 HTML 标签。