Rust的内存安全三原则:所有权、借用及生命周期

2023-10-29

我们接下来要探讨的概念是Rust的内存安全及其零成本抽象原则的核心。它们让Rust能够在编译期检测程序中内存安全违规,在离开作用域时自动释放相关资源等情况。我们将这些概念称作所有权、借用和生命周期。

所有权有点类似核心原则,而借用和生命周期是对语言类型系统的扩展。在代码的不同上下文中加强或有时放松所有权原则,可确保编译期内存管理正常运作。接下来让我们详细说明这些原则。

5.7.1 所有权

程序中资源的真正所有者的概念因语言而异。这里的含义是通过资源,我们共同引用在堆或堆栈上保存值的任何变量,或者是包含打开文件描述符、数据库连接套接字、网络套接字及类似内容的变量。从它们存在到完成程序调用及其之后的时间,都会占用一些内存。资源所有者的一个重要职责就是明智地释放它们使用的内存,因为如果无法在适当的位置和时间执行取消内存分配,就可能导致内存泄漏。

在使用Python等动态语言编程时,可以将多个所有者或别名添加到list对象中,从而使用执行该对象的众多变量之一添加或删除list中的项目。变量不需要关心如何释放对象使用过的内存,因为GC会处理这些事情,并且一旦指向对象的所有引用都消失,GC就会释放相关的内存。

对于C/C++之类的编译语言,在智能指针出现之前,程序库对代码使用完毕的相关资源API的调用方或者被调用方是否负责释放内存有明确的规定。存在这些规则是因为编译器不会在这些语言中强制限定所有权。在C++中不使用智能指针仍然有可能出现问题。在C++中,存在多个变量指向堆上的某个值是完全没问题的(尽管我们不建议这么做),这就是所谓的别名。由于具有指向资源的多个指针或别名的灵活性,程序员会遇到各种各样的问题,其中之一就是C++中的迭代器失效问题,我们在前面已经解释过它。

具体而言,当给定作用域中资源的其他不可变别名相对存在至少一个可变别名时,就会出现问题。

另一方面,Rust试图为程序中值的所有权设定适当的语义。Rust的所有权规则遵循以下原则。

  • 使用let语句创建值或资源,并将其分配给变量时,该变量将成为资源的所有者。
  • 当值从一个变量重新分配给另一个变量时,值的所有权将转移至另一个变量,原来的变量将失效以便另作他用。
  • 值和变量在其作用域的末尾会被清理、释放。

需要注意的是,Rust中的值只有一个所有者,即创建它们的变量。其理念很简单,但是它的含义让熟练使用其他语言的程序员感到惊讶。考虑以下代码,它以最基本的形式演示所有权原则:

// ownership_basics.rs

#[derive(Debug)]
struct Foo(u32);

fn main() {
    let foo = Foo(2048);
    let bar = foo;
    println!("Foo is {:?}", foo);
    println!("Bar is {:?}", bar);
}

我们创建了变量foo和bar,它们指向Foo实例。对某些熟悉允许多个所有者指向一个值的主流命令式语言的人来说,我们希望这个程序能够顺利编译。但是在Rust中,编译代码时可能遇到以下错误提示:

 

这里,我们创建了一个Foo的实例并将其分配给变量foo。根据所有权规则,foo是Foo实例的所有者。在代码中,我们将foo分配给bar。在main中执行第二行代码时,bar成为Foo实例的新所有者,而旧的foo是一个废弃变量,经过此变动之后不能在其他任何地方使用。这在main函数第三行的println!调用中表现非常明显。每当我们将变量分配给某个其他变量或从变量读取数据时,Rust会默认移动变量指向的值。所有权规则可以防止你通过多个访问点来修改值,这可能导致访问已被释放的变量,即使在单线程上下文中,使用允许多个值的可变别名的语言也是如此。比较典型的例子是C++中的迭代器失效问题。现在,为了分析某个值何时超出作用域,所有权规则还会考虑变量的作用域。接下来让我们探讨一下作用域。

作用域简介

在我们进一步了解所有权之前,需要简要了解一下作用域。如果你熟悉C语言,那么可能已经对作用域的概念很熟悉了,但我们将在Rust的背景下回顾它,因为所有权与作用域协同工作。因此,作用域只不过是变量和值存在的环境。你声明的每个变量都与作用域有关。代码中的作用域是由一对花括号表示的。无论何时使用块表达式都会创建一个作用域,即任何以花括号开头和结尾的表达式。此外,作用域支持互相嵌套,并且可以在子作用域中访问父作用域的元素,但反过来不行。

这里是一些演示多个作用域和值的代码:

// scopes.rs

fn main() {
    let level_0_str = String::from("foo");
    {
        let level_1_number = 9;
        {
            let mut level_2_vector = vec![1, 2, 3];
            level_2_vector.push(level_1_number); //可以访问
        } // level_2_vector离开作用域

        level_2_vector.push(4); //不再有效
    } // level_1_number离开作用域
} // level_0_str离开作用域

为了解释这个问题,假定我们的作用域从0开始编号。通过这个假设,我们创建了名称中包含level_x前缀的变量。让我们逐行解释前面的代码。由于函数可以创建新的作用域,因此main函数引入了根级别作用域0,在上述代码中定义为level_0_str。在0级作用域中,我们创建了一个新的作用域,即作用域1,并且带有一个花括号,其中包含变量level_1_number。在1级作用域中,我们创建了一个块表达式,它成为2级作用域。在其中,我们声明了另一个变量level_2_vector,以便我们可以将level_1_number添加到其中,而level_1_number来自其父级作用域1。最后,当代码到达}末尾时,其中的所有值都会被销毁,相应作用域的生命周期也随之结束。作用域结束之后,我们就不能使用其中定义的任何值。

请注意,在推断所有权规则时,作用域是一个非常重要的属性。它也会被用来推断后续介绍的借用和生命周期。当作用域结束时,拥有值的任何变量都会运行相关代码以取消分配该值,并且其自身在作用域之外是无效的。特别是对在堆上分配的值,drop方法会被放在作用域结束标记}之前调用。这类似于在C语言中调用free函数,但这里是隐式的,并且可以避免程序员忘记释放值。drop方法来自Drop特征,它是为Rust中大部分堆分配类型实现的,可以轻松地自动释放资源。

在了解了作用域之后,让我们看看类似之前在ownership_basics.rs中看到的示例,但这一次,我们将会使用原始值:

// ownership_primitives.rs

fn main() {
    let foo = 4623;
    let bar = foo;
    println!("{:?} {:?}", foo, bar);
}

尝试编译并运行此程序,你可能会感到惊讶,因为这个程序能够通过编译并正常工作。到底发生了什么?在该程序中,4623的所有权不会从foo转移到bar,但bar会获得4623的单独副本。看起来基元类型在Rust中会被特殊对待,它们会被移动而不是复制。这意味着根据我们在Rust中使用的类型,存在不同的所有权语义,这将引入移动和复制语义的概念。

移动和复制语义

在Rust中,变量绑定默认具有移动语义。但这究竟意味着什么?要理解这一点,我们需要考虑如何在程序中使用变量。我们创建值或资源并将它们分配给变量,以便在程序中可以方便地引用它们。这些变量是指向值所在内存地址的名称。现在,诸如读取、赋值、添加及将它们传递给函数等对变量的操作,在访问变量指向值的方式上可能具有不同的语义或含义。在静态类型语言中,这些语义大致分为移动语义和复制语义。接下来让我们对它们进行定义。

移动语义:通过变量访问或重新分配给变量时移动到接收项的值表示移动语义。由于Rust的仿射类型系统,它默认会采用移动语义。仿射类型系统的一个突出特点是值或资源只能使用一次,而Rust通过所有权规则展示此属性。

复制语义:默认情况下,通过变量分配或访问,以及从函数返回时复制的值(例如按位复制)具有复制语义。这意味着该值可以使用任意次数,每个值都是全新的。

这些语义对C++社区的人来说非常熟悉。默认情况下,C++具有复制语义。后来的C++ 11版本提供了对移动语义的支持。

Rust中的移动语义有时会受到限制。幸运的是,通过实现Copy特征可以更改类型的行为以遵循复制语义。基元和其他仅适用于堆栈的数据类型在默认情况下实现了上述特征,这也是前面的基元代码能够正常工作的原因。考虑下列尝试显式创建类型的代码片段:

// making_copy_types.rs

#[derive(Copy, Debug)]
struct Dummy;

fn main() {
    let a = Dummy;
    let b = a;
    println!("{}", a);
    println!("{}", b);
}

在编译代码时,我们得到以下错误提示信息:

 

有趣的是,Copy特征似乎依赖于Clone特征。这是因为Copy特征在标准库的定义如下:

pub trait Copy: Clone { }

Clone是Copy的父级特征,任何实现Copy特征的类型必须实现Clone。我们可以在派生注释中的Copy旁边添加Clone特征来让该示例通过编译:

// making_copy_types_fixed.rs

#[derive(Copy, Clone, Debug)]
struct Dummy;

fn main() {
    let a = Dummy;
    let b = a;
    println!("{}", a);
    println!("{}", b);
}

现在程序能够正常运行。但是Clone和Copy之间的差异并不是很明显。接下来让我们对它们进行区分。

5.7.2 通过特征复制类型

Copy和Clone特征传达了在代码中使用类型时如何进行复制的原理。

Copy

Copy特征通常用于可以在堆栈上完全表示的类型,也就是说它们自身没有任何部分位于堆上。如果出现了这种情况,那么Copy将是开销很大的操作,因为它必须从堆中复制值。这直接影响到赋值运算符的工作方式。如果类型实现了Copy,则从一个变量到另一个变量的赋值操作将隐式复制数据。

Copy是一种自动化特征,大多数堆栈上的数据类型都自动实现了它,例如基元类型和不可变引用,即&T。Copy特征复制类型的方式与C语言中的memcpy函数类似,后者用于按位复制值。默认情况下不会为自定义类型实现Copy特征,因为Rust希望显式指定复制操作,并且要求开发人员必须选择实现该特征。当任何人都想在自定义类型上实现Copy特征时,Copy还取决于Clone特征。

没有实现Copy特征的类型包括Vec<T>、String和可变引用。为了获得这些值的复制,我们需要使用目的性更明确的Clone特征。

Clone

Clone特征用于显式复制,并附带clone方法,类型可以实现该方法以获取自身的副本。Clone特征的定义如下:

pub trait Clone {
    fn clone(&self) -> Self;
}

Clone有一个名为clone的方法,用于获取接收者的不可变引用,即&self,并返回相同类型的新值。用户自定义类型或任何需要提供能够复制自身的包装器类型,应通过实现clone方法来实现Clone特征。

但是Clone与Copy特征的不同之处在于,其中的赋值操作是隐式复制值,要复制Clone值,我们必须显式调用clone方法。clone方法是一种更通用的复制机制,Copy是它的一个特例,即总是按位复制。

String和Vec这类元素很难进行复制,只实现了Clone特征。智能指针类型也实现了Clone特征,它只是在指向堆上相同数据的同时复制指针和额外的元数据(例如引用计数)。

这是能够帮助我们确定如何复制类型,以及为Clone特征提供灵活性的示例之一。

下面是一个通过Clone特征复制类型的示例:

// explicit_copy.rs

#[derive(Clone, Debug)]
struct Dummy {
    items: u32
}

fn main() {
    let a = Dummy { items: 54 };
    let b = a.clone();
    println!("a: {:?}, b: {:?}", a, b);
}

我们在derive属性中添加了一个Clone特征。有了它,我们就可以在a上调用clone方法来获得它的新副本。

现在,你可能想知道何时应该实现这些类型中的某一种。以下是一些指导原则。

何时在类型上实现Copy。

可以在堆栈上单独表示的小型值如下所示。

  • 如果类型仅依赖于在其上实现了Copy特征的其他类型,则Copy特征是为其隐式实现的。
  • Copy特征隐式影响赋值运算符的工作方式。使用Copy特征构建自定义外部可见类型需要考虑它是否会对赋值运算符产生影响。如果在开发的早期阶段,你的类型是Copy,后续将它移除之后则会影响使用该类型进行赋值的所有环节。你可以通过这种方式轻松地破坏API。

何时在类型上实现Clone。

  • Clone特征只是声明一个clone方法,需要被显式调用。
  • 如果你的类型在堆上还包含一个值作为其表示的一部分,那么可选择实现Clone特征,这也需要向复制堆数据的用户明确表示。
  • 如果要实现智能指针类型(例如引用计数类型),那么应该在类型上实现Clone特征,以便仅复制堆栈上的指针。

现在我们已经学习了Copy和Clone的基础知识,接下来我们看看所有权对代码产生的一些影响。

所有权的应用

除了let绑定示例之外,还可以在其他地方找到所有权的用武之地,重要的是我们能够识别它和编译器给出的错误提示信息。

如果将参数传递给函数,那么相同的所有权规则也同样有效:

// ownership_functions.rs

fn take_the_n(n: u8) { }

fn take_the_s(s: String) { }

fn main() {
    let n = 5;
    let s = String::from("string");

    take_the_n(n);
    take_the_s(s);

    println!("n is {}", n);
    println!("s is {}", s);
}

编译过程以类似方式失败:

 

String并没有实现Copy特征,因此值的所有权在take_the_s函数中会发生移动。当函数返回时,相关值的作用域也随之结束,并且会在s上调用drop方法,这会释放s所使用的堆内存。因此,在函数调用结束后s将失效。但是,由于String实现了Clone特征,我们可以通过在函数调用时添加一个.clone()调用来让代码正常工作:

take_the_s(s.clone());

我们的take_the_n函数能够正常工作,是因为u8(基元类型)实现了Copy特征。

也就是说,将移动语义类型传递给函数之后,我们后续将不能再使用该值。如果要使用该值,那么必须复制该类型并将副本发送到该函数。现在,如果我们只需要变量s的读取访问权限,那么可以让该代码正常工作的另一种方法是将字符串s传递回main函数,如以下代码所示:

// ownership_functions_back.rs

fn take_the_n(n: u8) { }

fn take_the_s(s: String) -> String {
    println!("inside function {}", s);
    s
}

fn main() {
    let n = 5;
    let s = String::from("string");

    take_the_n(n);
    let s = take_the_s(s);

    println!("n is {}", n);
    println!("s is {}", s);
}

我们在take_the_s函数中添加了一个返回类型,并将传递的字符串返回给调用者。在main函数中,我们在s中接收它。因此,main函数中的最后一行代码能够正常运行。

在match表达式中,移动类型默认也会被移动,如以下代码所示:

// ownership_match.rs

#[derive(Debug)]
enum Food {
    Cake,
    Pizza,
    Salad
}

#[derive(Debug)]
struct Bag {
    food: Food
}

fn main() {
    let bag = Bag { food: Food::Cake };
    match bag.food {
        Food::Cake => println!("I got cake"),
        a => println!("I got {:?}", a)
    }
    println!("{:?}", bag);
}

在上述代码中,我们创建了一个Bag实例并将其分配给bag。接下来,我们将匹配它的food字段,并输出一些文本。之后,我们用println!输出bag中的内容,编译时出现以下错误提示信息:

 

如你所见,错误提示信息提示bag已被match表达式中的变量移动和使用。这使得变量bag失效并无法再使用。当我们介绍借用这一概念时,将会了解到如何让上述代码正常工作。

方法:在impl代码块中,任何以self作为第一个参数的方法都将获取调用该方法的值的所有权。这意味着对值调用方法后,你无法再次使用该值。如以下代码所示:

// ownership_methods.rs

struct Item(u32);
impl Item {
    fn new() -> Self {
        Item(1024)
    }

    fn take_item(self) {
        // 什么也不做
    }
}

fn main() {
    let it = Item::new();
    it.take_item();
    println!("{}", it.0);
}

编译时,我们得到以下错误提示信息:

 

take_item是一个以self作为第1个参数的实例方法。在调用之后,它将在方法内移动,并在函数作用域结束时被释放。后续我们将不能再使用它。当介绍借用这一概念时,我们将会解释如何让上述代码正常运行。

闭包中的所有权

闭包也会出现类似的情况。请考虑如下代码段:

// ownership_closures.rs

#[derive(Debug)]
struct Foo;

fn main() {
    let a = Foo;

    let closure = || {
        let b = a;
    };
    println!("{:?}", a);
}

如你所见,Foo的所有权在闭包中已经默认移动到了b,用户将无法再次访问a。编译上述代码时,我们得到以下输出结果:

 

要获得a的副本,我们可以在闭包内调用a.clone()并将它分配给b,或者在闭包前面放置一个关键字move,如下所示:

let closure = move || {
    let b = a;
};

这将使我们的程序通过编译。

注意

闭包接收不同的值取决于在其内部使用变量的方式。

通过这些观察,我们已经发现所有权规则非常严格,因为它只允许我们使用类型一次。如果函数只需要对值的读取访问权限,那么我们需要再次从函数返回值,或者在它传递给函数之前复制它。如果类型没有实现Clone特征,那么后者可能无法实现其目的。

复制类型看起来似乎很容易绕过所有权规则,但是由于Clone总是复制类型,可能会调用内存分配器API,这是一种涉及系统调用,并且开销高昂的操作,因此它无法满足零成本抽象承诺的所有要点。

随着移动语义和所有权规则的实施,在Rust中编写程序很快就会变得困难重重。幸运的是,我们引入了借用和引用类型的概念,它们放宽了规则所施加的限制,但仍然能够在编译期确保兼容所有权规则。

5.7.3 借用

借用的概念是规避所有权规则的限制。进行借用时,你不会获取值的所有权,而是根据需要提供数据。这是通过借用值,即获取值的引用来实现的。为了借用值,我们需要将运算符&放在变量之前,&表示指向变量的地址。在Rust中,我们可以通过两种方式借用值。

不可变借用:当我们在类型之前使用运算符&时,就会创建一个不可变借用。之前的部分所有权示例可以使用借用进行重构:

// borrowing_basics.rs

#[derive(Debug)]
struct Foo(u32);

fn main() {
    let foo = Foo;
    let bar = &foo;
    println!("Foo is {:?}", foo);
    println!("Bar is {:?}", bar);
}

这一次,程序通过编译,因为main函数中的第二行已经修改为如下代码:

let bar = &foo;

注意变量foo之前的&。我们借用foo并将借用结果分配给bar。bar的类型为&Foo,这是一种引用类型。作为一个不可变借用,我们不能通过bar改变Foo中的值。

可变借用:可以使用&mut运算符对某个值进行可变借用。通过可变借用,你可以改变该值。请考虑如下代码:

// mutable_borrow.rs

fn main() {
    let a = String::from("Owned string");
    let a_ref = &mut a;
    a_ref.push('!');
}

在这里,我们有一个声明为a的String实例,但我们还是用&mut a创建了一个该值的可变借用。这并没有将a移动到b ——只是可变地对它借用。然后我们将一个字符“!”推送给该字符串。对该程序进行编译:

 

我们有一个错误。编译器提示我们不能进行相互借用。这是因为可变借用需要原有的变量自身使用关键字mut进行修饰声明。这应该是显而易见的,因为我们不能改动不可变绑定背后的东西。因此,我们将a的声明改为如下内容:

let mut a = String::from("Owned string");

上述修改使代码通过编译。这里a是一个执行堆分配值的堆栈变量,a_ref是a所拥有的值的可变借用。a_ref可以改变String值,但是不能销毁该值,因为它不是所有者。如果a在借用它的代码行之前被销毁,则借用失效。

现在,我们在上述程序的末尾添加一个printlin!来输出修改后的a:

// exclusive_borrow.rs

fn main() {
    let mut a = String::from("Owned string");
    let a_ref = &mut a;
    a_ref.push('!');
    println!("{}", a);
}

编译后给出如下错误提示信息:

 

Rust禁止这样做,因为通过a_ref将值不变地借用为可变借用已经出现在作用域中。这凸显了借用的另一个重要规则。一旦值被可变借用,我们就不能再对它进行其他借用,即使是进行不可变借用。在介绍了借用这一概念之后,让我们重点介绍一下借用在Rust中的实施细则。

借用规则

和所有权规则类似,我们也有借用规则,通过引用来维护单一的所有权语义。这些规则如下所示。

  • 一个引用的生命周期可能不会超过其被引用的时间。这是显而易见的,因为如果它的生命周期超过其被借用的时间,那么它将指向一个垃圾值(被销毁的值)。
  • 如果存在一个值的可变借用,那么不允许其他引用(可变借用或不可变借用)在该作用域下指向相同的值。可变借用是一种独占性借用。
  • 如果不存在指向某些东西的可变借用,那么在该作用域下允许出现对同一值的任意数量的不可变借用。

注意

Rust中的借用规则由编译器中被称为借用检查器的组件进行分析。Rust社区把处理借用错误戏称为和借用检查器搏斗。

现在我们已经熟悉了这些规则,让我们看看如果违反上述某些规则后,借用检查器会做出什么反应。

借用实践

当我们测试借用检查器时,Rust通过借用规则获得的异常诊断信息将会非常有用。在下面的一个示例中,我们将看到它们在多种情况下的表现。

函数中的借用:如前所述,如果只是读取值,那么在进行函数调用时移动所有权没有太大的意义,并且会受到诸多限制。调用函数后,你无法再使用该变量。除了通过值获取参数,也可以通过借用来获取它们。我们可以修复之前介绍所有权时提到的代码示例,以便在不进行复制的情况下通过编译器的校验,相关代码如下所示:

// borrowing_functions.rs

fn take_the_n(n: &mut u8) {
    *n += 2;
}

fn take_the_s(s: &mut String) {
    s.push_str("ing");
}

fn main() {
    let mut n = 5;
    let mut s = String::from("Borrow");

    take_the_n(&mut n);
    take_the_s(&mut s);

    println!("n changed to {}", n);
    println!("s changed to {}", s);
}

在上述代码中,函数take_the_s和take_the_n将接收可变借用作为参数。有了这个,我们需要对代码进行3处改动。首先,变量绑定必须是可变的:

let mut s = String::from("Borrow");

其次,我们的函数将更改为以下内容:

fn take_the_s(n: &mut String) {
    s.push_str("ing");
}

最后,函数调用时也需要修改为以下形式 :

take_the_s(&mut s);

此外,我们可以看到Rust中的所有内容都是明确的。众所周知,可变性在Rust代码中是非常明显的,尤其是当多个线程一起发挥作用时。

匹配中的借用:在match表达式中,默认情况下会对匹配臂中的值进行移动,除非它是Copy类型。下列代码在5.7.2小节介绍所有权时被提及,我们可以通过在匹配臂中使用借用来进行编译:

// borrowing_match.rs

#[derive(Debug)]
enum Food {
    Cake,
    Pizza,
    Salad
}

#[derive(Debug)]
struct Bag {
    food: Food
}

fn main() {
    let bag = Bag { food: Food::Cake };
    match bag.food {
        Food::Cake => println!("I got cake"),
        ref a => println!("I got {:?}", a)
    }
    println!("{:?}", bag);
}

我们对之前的代码稍作修改,你可能在阅读所有权相关内容时已经非常熟悉它们。对于第二个匹配臂,我们以ref作为前缀。关键字ref可以通过引用来匹配元素,而不是根据值来捕获它们。通过此修改,我们的代码得以顺利编译。

从函数返回引用: 在下面的代码示例中,我们有一个函数试图返回在函数内部声明的值的引用:

// return_func_ref.rs

fn get_a_borrowed_value() -> &u8 {
    let x = 1;
    &x
}

fn main() {
    let value = get_a_borrowed_value();
}

上述代码无法通过借用检查器的校验,我们得到以下错误提示信息:

 

错误提示信息告知我们缺少生命周期声明符。这对了解我们的代码存在什么问题没有多大帮助。我们需要熟悉生命周期这一概念,5.7.5小节将会详细介绍它。在此之前,让我们了解一些基于借用规则能够使用的方法类型。

5.7.4 基于借用规则的方法类型

借用规则还规定了如何定义类型的固有方法和特征的实例方法。以下是它们接收实例的方式,并且是根据限制由少到多排列的。

  • &self方法:这些方法只对其成员具有不可变的访问权限。
  • &mut self方法:这些方法能够可变地借用self实例。
  • self方法:这些方法拥有调用它的实例的所有权,并且类型在后续调用时将失效。

对于自定义类型,相同的借用规则也适用于其作用域成员。

注意

除非你有意编写一个应该在结束时移动或删除self的方法,否则总是应该使用不可变的借用方法,即将&self作为第1个参数。

5.7.5 生命周期

Rust编译期内存安全难题的第三部分是生命周期的概念和用于在代码中指定生命周期的相关语法注释。在本小节中,我们将简要地介绍一下生命周期的概念。

当我们声明某个变量时,会使用一个值对它进行初始化,该变量具有一个生命周期,超过该生命周期后它就会失效从而无法使用。在一般的编程术语中,变量的生命周期是指代码中的变量指向的有效内存区域。如果你曾经使用过C语言,那么应该会敏锐地意识到变量的生命周期:每次调用malloc分配一个变量时,它应该有一个所有者,并且该所有者应该可靠地确定该变量的生命何时结束,以及何时释放相关的内存。但最糟糕的地方在于,它不是由编译器强制执行的,相反,这是程序员需要担负的责任。

对于在堆栈上分配的数据,我们可以通过查看代码来轻松地判定变量是否存续。但是,对于在堆上分配的值,这一点就不是那么明确了。

Rust中的生命周期是一个具体的构造,而非C语言中概念性的认知。它们执行程序员手动执行的类似分析,即检查值的作用域和引用它的任何变量。

在讨论Rust中的生命周期时,你只需要在有引用时处理它们。Rust中的所有引用都附加了生命周期信息。生命周期定义了引用相对值的原始所有者的生存周期,以及引用作用域的范围。

大多数情况下它是隐式的,编译器通过分析代码来确定变量的生命周期。在某些情况下,编译器却不能确定变量的生命周期,它需要我们的帮助,换句话说,它要求用户明确自己的意图。

到目前为止,我们在之前的示例中一直在讨论如何使用引用和借用规则,接下来让我们尝试编译下列代码后会发生什么:

// lifetime_basics.rs

struct SomeRef<T> {
    part: &T
}

fn main() {
    let a = SomeRef { part: &43 };
}

这段代码非常简单,我们有一个SomeRef结构体,它存储了一个指向泛型T的引用。在main函数中,我们创建了一个该结构体的实例,并使用指向i32类型的引用对它的part字段进行初始化,即&43。

它在编译时给出了如下错误提示信息:

 

在这种情况下,编译器要求我们输入一个名为生命周期的参数。生命周期参数与泛型参数非常相似。泛型T可以修饰任何类型,生命周期参数表示引用能够有效使用的区域或范围。当借用规则检查器检查、分析代码时,编译器可以稍后填写实际的区域信息。

生命周期纯粹是一个编译期构造,它可以帮助编译器确定某个引用有效的作用域,并确保它遵循借用规则。它可以跟踪诸如引用的来源,以及它们是否比借用值生命周期更长这类事情。Rust中的生命周期能够确保引用的存续时间不超过它指向的值。生命周期并不是你作为开发人员将要用到的,而是编译器使用和推断引用的有效性时会用到的。

生命周期参数

对于编译器无法通过分析代码来确定值的生命周期的情况,我们需要通过在代码中添加一些注释来帮助编译器达到上述目的。为了与标识符区分,生命周期注释带有“'”前缀。因此,为了让我们之前的带参数的代码示例能够通过编译,我们需要在SomeRef之上添加生命周期注释,如下所示:

// using_lifetimes.rs

struct SomeRef<'a, T> {
    part: &'a T
}

fn main() {
    let _a = SomeRef { part: &43 };
}

生命周期由一个“'”进行修饰,后跟任何有效的标识符序列。但是按照惯例,Rust中的大多数生命周期都采用'a、'b、'c这样的名称作为生命周期参数。如果类型上有多个生命周期,则可以使用更长的描述性生命周期名称,例如'ctx、'reader、'writer等。它与泛型参数声明的位置和方式相同。

我们稍后会看到一些通过将生命周期用作泛型参数来解决无效引用的示例,但是其中有一个包含具体值的生命周期,如下列代码所示:

// static_lifetime.rs

fn main() {
    let _a: &'static str = "I live forever";
}

关键字 static 修饰的生命周期意味着这些引用在程序运行期间都是有效的。Rust 中的所有文本字符都具有'static的生命周期,并且它们会被转到已编译对象代码的数据片段中。

生命周期省略规则

只要在函数或类型定义中存在引用,就会涉及生命周期。大多数情况下,你不需要显式使用生命周期注释代码,编译器能够很聪明地推断它,因为很多信息在编译期就可以用于处理引用。

换句话说,以下两个函数签名的效果是相同的:

fn func_one(x: &u8) → &u8 { .. }

fn func_two<'a>(x: &'a u8) → &'a u8 { .. }

通常情况下,编译器会省略func_one中的生命周期参数,我们不需要将其写为func_two的形式。

不过编译器只能在受限制的位置省略生命周期符号,并且存在省略规则。在讨论这些规则之前,我们需要先介绍输入/输出型生命周期,并且仅在函数需要接收引用参数时讨论它们。

  • 输入型生命周期:函数参数上的生命周期注释当作引用时被称为输入型生命周期。
  • 输出型生命周期:函数返回值上的生命周期参数当作引用时被称为输出型生命周期。

值得注意的是,任何输出型生命周期都源自输入型生命周期,我们不能拥有独立于输入型生命周期的输出型生命周期。它只能是一个小于或等于输出型生命周期的生命周期。

以下是省略生命周期时需要遵守的一些规则。

  • 如果输入型生命周期仅包含单个引用,那么假定输出型生命周期也仅包含单个引用。
  • 对于涉及self和&mut self的方法,输入型生命周期是针对&self进行推断的。

但是有时在存在歧义的情况下,编译器不会尝试进行假设。请考虑如下代码:

// explicit_lifetimes.rs

fn foo(a: &str, b: &str) -> &str {
    b
}

fn main() {
    let a = "Hello";
    let b = "World";
    let c = foo(a, b);
}

在上述代码中,c中存储了一个表示任意类型(T)的引用。在这种情况下,返回值的生命周期并不明显,因为涉及两个输入引用。但某些情况下,编译器无法计算引用的生命周期,它需要我们的帮助来指定生命周期参数。请考虑如下不能通过编译的代码:

 

上述程序没有通过编译,因为Rust无法确定返回值的生命周期,它需要我们的帮助。

现在,当Rust无法为我们代劳时,有很多地方需要用户指定生命周期。

  • 函数签名。
  • 结构体和结构体字段。
  • impl代码块。

自定义类型中的生命周期

如果结构体中包含引用任何类型的字段,我们需要明确指定这些引用的生命周期。该语法和函数签名中的语法类似:我们首先在结构体代码行上声明生命周期名称,然后在字段中使用它们。

以下是最简单形式的语法:

// lifetime_struct.rs

struct Number<'a> {
    num: &'a u8
}

fn main() {
    let _n = Number {num: &545};
}

Number定义的存续时间与num的引用时间一样长。

impl代码块中的生命周期

当为包含引用的结构体创建impl代码块时,我们需要再次重复指定生命周期的声明和定义。例如,如果我们为之前定义的结构体Foo构造了一个实现,那么类似的语法将如下所示:

// lifetime_impls.rs

#[derive(Debug)]
struct Number<'a> {
    num: &'a u8
}

impl<'a> Number<'a> {
    fn get_num(&self) -> &'a u8 {
        self.num
    }
    fn set_num(&mut self, new_number: &'a u8) {
        self.num = new_number
    }
}

fn main() {
    let a = 10;
    let mut num = Number { num: &a };
    num.set_num(&23);
    println!("{:?}", num.get_num());
}

在大多数情况下,这是从类型自身进行推断的,然后我们可以使用“<'_>”语法省略签名。

多个生命周期

和泛型参数类似,如果我们有多个具有不同生命周期的引用,那么可以指定多个生命周期。但是,如果必须在代码中使用多个生命周期,那么它很快就会变得杂乱无章。大多数情况下,我们在结构体或函数中只需处理一个生命周期,但是在某些情况下我们需要用到多个生命周期注释。例如,假定我们正在构建一个解码器程序库,它可以根据模式和给定的已编码字节流来解析二进制文件。我们有一个Decoder对象,它包含一个schema对象的引用和一个reader类型的引用。我们的Decoder定义将如下所示:

// multiple_lifetimes.rs

struct Decoder<'a, 'b, S, R> {
    schema: &'a S,
    reader: &'b R
}

fn main() {}

在上述定义中,我们很可能遇到通过网络获取reader,而schema是本地的情况,因此它们在代码中的生命周期可能是不同的。当我们为Decoder提供实现时,可以通过生命周期子类型指定它们的关系,该概念稍后会进行介绍。

生命周期子类型

我们可以指定生命周期之间的关系,以确定是否可以在同一位置使用两个引用。继续我们的Decoder结构体示例,我们可以在impl代码块中声明生命周期之间的关系,如下所示:

// lifetime_subtyping.rs

struct Decoder<'a, 'b, S, R> {
    schema: &'a S,
    reader: &'b R
}

impl<'a, 'b, S, R> Decoder<'a, 'b, S, R>
where 'a: 'b {
}

fn main() {
    let a: Vec<u8> = vec![];
    let b: Vec<u8> = vec![];
    let decoder = Decoder {schema: &a, reader: &b};
}

我们使用where语句在impl代码块中指定了关系:'a:'b。这表示'a的生命周期比'b长,换句话说,'b永远不会比'a存续的时间更长。

在泛型上声明生命周期区间

除了使用特征来限制泛型函数能够接收的类型之外,我们还可以使用生命周期注释来限制泛型参数。例如,考虑我们有一个logger程序库,其中Logger对象的定义如下所示:

// lifetime_bounds.rs

enum Level {
    Error
}

struct Logger<'a>(&'a str, Level);

fn configure_logger<T>(_t: T) where T: Send + 'static {
    // 此处配置logger
}

fn main() {
    let name = "Global";
    let log1 = Logger(name, Level::Error);
    configure_logger(log1);
}

在上述代码中,我们有一个Logger结构体,其中包含其名称和一个Level枚举。我们还有一个名为configure_logger的泛型函数,它接收一个受Send + 'static约束的类型T作为参数。在 main 函数中,我们创建了一个带有'static声明的字符串"Global",并将它传递给configure_logger函数执行相关调用。

除了Send端点之外(表示可以将此线程发送到其他线程),我们还声明该类型的生命周期必须与'static生命周期一样长。假定我们将Logger引用指向了某个包含较短生命周期的字符串,代码如下所示:

// lifetime_bounds_short.rs

enum Level {
    Error
}

struct Logger<'a>(&'a str, Level);

fn configure_logger<T>(_t: T) where T: Send + 'static {
    // 这里配置logger
}

fn main() {
    let other = String::from("Local");
    let log2 = Logger(&other, Level::Error);
    configure_logger(&log2);
}

这将无法执行,并得到以下错误提示信息:

 

错误提示信息清楚地表明,借用的值必须对static生命周期有效,但我们已经传递了一个字符串,其生命周期在main函数中被称为'a,它比'static生命周期更短。

本文摘自《精通Rust 第2版》

 

本书内容共17章,由浅入深地讲解Rust相关的知识,涉及基础语法、软件包管理器、测试工具、类型系统、内存管理、异常处理、高级类型、并发模型、宏、外部函数接口、网络编程、HTTP、数据库、WebAssembly、GTK+框架和GDB调试等重要知识点。

本书适合想学习Rust编程的读者阅读,希望读者能够对C、C++或者Python有一些了解。书中丰富的代码示例和详细的讲解能够帮助读者快速上手,高效率掌握Rust编程。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Rust的内存安全三原则:所有权、借用及生命周期 的相关文章

随机推荐

  • 深度学习与计算机视觉系列(8)_神经网络训练与注意点

    作者 寒小阳 时间 2016年1月 出处 http blog csdn net han xiaoyang article details 50521064 声明 版权所有 转载请联系作者并注明出处 1 训练 在前一节当中我们讨论了神经网络静
  • Memcache查看列出所有key方法

    Memcached查看列出所有key方法 测试的过程中 发现Memcached没有一个比较简单的方法可以直接象redis那样keys 列出所有的Session key 并根据key get对应的session内容 具体操作如下 登录MemC
  • bugkuctf-Simple_SSTI_2

    方法一 tplmap 直接扫 python2 tplmap py u http 114 67 175 224 10589 flag 可以注入 使用 os shell提权 python2 tplmap py u http 114 67 175
  • 7.recurrent_neural_network

    device torch device cuda if torch cuda is available else cpu sequence length 28 input size 28 hidden size 128 num layers
  • windows环境与Linux环境下调用c++程序

    在此之前 需要在java编译软件IDEL中配置本地的Maven仓库等 可自行网上搜索配置 一 在Windows系统下调用c 软件生成的dll文件 1 在IDEL中创建Maven工程 配置下载jna包等 在pom文件中写入如下配置即可
  • 软件测试2019:第三次作业

    一 单元测试的任务有哪些 1 模块接口测试 2 模块局部数据结构测试 3 模块边界条件测试 4 模块中所有独立执行通路测试 5 模块的各条错误处理通路测试 二 代码评审方法有哪些 你认为哪一种比较有效 理由是什么 代码评审方法分为代码走查和
  • 什么时候开始使用Redis

    思考这个问题的本质就是要学会取舍和选型 技术选型非常重要 大多人为了技术而技术 这是不可取的 就想小彬认为微服务必须解决分布式事务一样 但他却不知道为什么要用分布式事务 从而不知道什么时候要用分布式事务 就想Redis一样 什么时候要用Re
  • jmap 文件解析_干货分享丨jvm系列:dump文件深度分析

    摘要 java内存dump是jvm运行时内存的一份快照 利用它可以分析是否存在内存浪费 可以检查内存管理是否合理 当发生OOM的时候 可以找出问题的原因 那么dump文件的内容是什么样的呢 JVM dump java内存dump是jvm运行
  • 【springboot】如何在自己的springboot项目中引用别的springboot项目jar

    正好今天碰到了 就在这里总结下 习惯了将公用的项目打包成jar 然后当做工具类引入到自己项目中 直接调用 感觉甚是方便 但有没有发现 平时我们引用的大部分情况下是一个maven项目 然后打包好的jar也是maven项目的结构 所以我们可以正
  • VS使用技巧汇总

    总目录 文章目录 总目录 前言 一 快捷技巧 1 代码片段快捷方式 2 选择性粘贴 3 快速停靠窗口 4 多行同步快速编辑 5 引用命名空间 6 整行上下移动 7 快捷键 二 VS功能 1 打开VS自带反编译 2 VS扩展插件 三 其他 总
  • win10远程登录Ubuntu14.04图形化界面

    一 使用场景 因工作原因 需要在window与Linux系统同时操作 由于虚拟机卡顿 十分影响工作效率 于是找领导又申请一台电脑 Ubuntu主机主要日常代码编译与git操作 window主机主要用于日常沟通 资料查询 测试研发 windo
  • go语言重大bug,make缓存读取数据漏洞,4096漏洞

    做一个小程序 需要对文件内容分片读取 但是读取过程中发现数据读取不全 经测试多个make缓存读取文件时发现问题 以下为漏洞测试部分 一 生成测试文件 AAA txt 创建一个AAA txt文件 写入1万个A wFile os OpenFil
  • KMP算法原理

    所有下标从0开始 子串的定位操作通常称为串的模式匹配 它求的是子串 或称模式串 在主串中的位置 前缀 除最后一个字符外 字符串的所有头部子串 后缀 除第一个字符外 字符串的所有尾部子串 部分匹配值 字符串的前缀和后缀的最长相等前后缀长度 字
  • Linux网络编程:多进程 多线程_并发服务器

    文章目录 一 wrap常用函数封装 wrap h wrap c server c封装实现 client c封装实现 二 多进程process并发服务器 server c服务器 实现思路 代码逻辑 client c客户端 三 多线程threa
  • JS 面试题集合(二)

    一 延迟加载 JS 有哪些方式 延迟加载 async defer 例如 defer 等html全部解析完成 才会执行js代码 顺次执行js 脚本 async asyc 是和 html 解析同步的 一起的 不是顺次执行 js 脚本 谁先加载完
  • 破解极验(geetest)验证码

    最近在搞爬虫的时候在好几个网站都碰到了一种叫做geetest的滑动条验证码 一直没有太好的办法只能在触发这个验证码后发个报警去手动处理一下 http www geetest com exp embed是他们官网的样例 后来研究了下觉得要破解
  • FindBugs Bug Descriptions

    FindBugs Bug Descriptions This document lists the standard bug patterns reported byFindBugs version 1 3 9 Summary Descri
  • 【力扣】三数之和

    给你一个包含 n 个整数的数组 nums 判断 nums 中是否存在三个元素 a b c 使得 a b c 0 请你找出所有和为 0 且不重复的三元组 注意 答案中不可以包含重复的三元组 示例 1 输入 nums 1 0 1 2 1 4 输
  • KEIL 生成bin文件

    1 首先对于keil5其编译生成的HEX文件 一般通过勾选如下 在进行ISP烧写时 就可以通过传送HEX文件进行烧写 2 对于烧写而言不仅仅可以通过HEX文件进行烧写 还可以通过BIN文件进行烧写 且BIN文件比HEX文件更小 设置BIN文
  • Rust的内存安全三原则:所有权、借用及生命周期

    我们接下来要探讨的概念是Rust的内存安全及其零成本抽象原则的核心 它们让Rust能够在编译期检测程序中内存安全违规 在离开作用域时自动释放相关资源等情况 我们将这些概念称作所有权 借用和生命周期 所有权有点类似核心原则 而借用和生命周期是