改进rust代码的35种具体方法-类型(十四)-了解生命周期

上一篇文章


地球仍然在转啊 – 伽利略·伽利雷

本条目描述了 Rust 的生命周期,这是对 C 和 C++ 等先前编译语言中存在的概念的更精确表述——即使不是理论上,也是实践中的。生命周期是下一篇中描述的借用检查器的必需输入;这些功能结合在一起构成了 Rust 的核心内存安全保证。

堆栈简介

生命周期从根本上与堆栈相关,因此需要快速介绍/提醒。

当程序运行时,它使用的内存被分为不同的块,有时称为 。其中一些块是固定大小的,例如保存程序代码或程序全局数据的块,但其中两个块 –堆栈——随着程序运行而改变大小。为了实现这一点,它们通常排列在程序虚拟内存空间的两端,因此一个可以向下增长,另一个可以向上增长(至少直到你的程序耗尽内存并崩溃)。

在这两个动态大小的块中,堆栈用于保存与当前执行的函数相关的状态,特别是其参数、局部变量和临时值,保存在堆栈帧。当调用函数时f() ,一个新的堆栈帧会添加到堆栈中,超出调用函数的堆栈帧结束的位置,并且 CPU 通常会更新寄存器 –堆栈指针——指向新的堆栈帧。

当内部函数f()返回时,堆栈指针重置到调用之前的位置,这将是调用者的堆栈帧,完整且未修改。

当调用者调用不同的函数时g(),该过程会再次发生,这意味着 的堆栈帧 g()将重新使用先前使用的相同内存区域f()

fn caller() -> u64 {
    let x = 42u64;
    let y = 19u64;
    f(x) + g(y)
}

fn f(f_param: u64) -> u64 {
    let two = 2;
    f_param + two
}

fn g(g_param: u64) -> u64 {
    let arr = [2, 3];
    g_param + arr[1]
}

当然,这是实际情况的一个大大简化的版本;将东西放入堆栈和从堆栈中取出需要时间,因此对于实际处理器有很多优化。然而,简化的概念图足以理解本项目的主题。

生命周期的演变

上一节解释了参数和局部变量如何存储在堆栈上,但只是暂时的。从历史上看,这允许出现一些危险的情况:如果您持有指向这些临时堆栈值之一的指针,会发生什么?

重新开始C 中,返回指向局部变量的指针是完全可以的(尽管现代编译器会发出警告):

/* C code. */
struct File* open_bugged() {
  struct File f = { open("README.md", O_RDONLY) };
  return &f;  // return address of stack object
}

如果你运气不好并且调用代码立即使用返回值,你可能会逃脱惩罚:

  struct File* f = open_bugged();
  printf("in caller: file at %p has fd=%d\n", f, f->fd);
in caller: file at 0x7ff7b5ca9408 has fd=3

这很不幸,因为它只是看起来有效。一旦发生任何其他函数调用,堆栈区域将被重新使用,并且用于保存对象的内存将被覆盖:

  investigate_file(f);
/* C code. */
void investigate_file(struct File* f) {
  long array[4] = {1, 2, 3, 4}; // put things on the stack
  printf("in function: file at %p has fd=%d\n", f, f->fd);
}
in function: file at 0x7ff7b5ca9408 has fd=1842872565

在此示例中,丢弃对象的内容会产生额外的不良影响:与打开的文件对应的文件描述符丢失,因此程序会泄漏数据结构中保存的资源。

时间向前推进到C++,通过包含析构函数解决了失去资源访问的问题,使得RAII(参见RAII模式的Drop特征)。现在,堆栈上的东西有能力自我整理:如果对象持有某种资源,析构函数可以整理它,并且 C++ 编译器保证在整理过程中调用堆栈上对象的析构函数。向上堆栈帧。

  // C++ code.
  ~File() {
    std::cout << "~File(): close fd " << fd << "\n";
    close(fd);
    fd = -1;
  }

调用者现在获得一个(无效)指针,指向已被销毁的对象并回收了其资源:

  File* f = open_bugged();
  printf("in caller: file at %p has fd=%d\n", f, f->fd);

然而,C++ 没有采取任何措施来解决悬空指针的问题:仍然可以保留指向已消失的对象(并且其析构函数已被调用)的指针。

// C++ code.
void investigate_file(File* f) {
  long array[4] = {1, 2, 3, 4}; // put things on the stack
  std::cout << "in function: file at " << f << " has fd=" << f->fd << "\n";
}
in function: file at 0x7ff7b57ef408 has fd=1711145032

作为一名 C/C++ 程序员,您需要注意这一点,并确保您不会取消引用指向已消失内容的指针。或者,如果您是攻击者并且您发现这些悬空指针之一,您更有可能在您进行漏洞利用的过程中,疯狂地咯咯笑并高兴地取消引用指针。

输入铁锈。 Rust 的核心吸引力之一是它从根本上解决了悬空指针的问题,立即解决了很大一部分1.安全问题。

这样做需要将生命周期的概念从后台(C/C++ 程序员必须知道要注意它们,没有任何语言支持)转移到前台:每个包含 & 符号的类型&都有一个关联的生命周期 ( 'a),即使编译器允许您在很多时候省略提及它。

生命周期的范围

堆栈上项目的生命周期是指保证该项目保留在同一位置的时间段;换句话说,这正是一个时期保证对项目的引用(指针)不会变得无效。

这从创建项目的位置开始,并延伸到它所在的位置:

  • 删除(Rust 相当于 C++ 中的对象销毁)
  • 移动了

(后者的普遍性有时会让来自 C/C++ 的程序员感到惊讶:在很多情况下,Rust 将项目从堆栈上的一个位置移动到另一个位置,或者从堆栈移动到堆,或者从堆移动到堆栈。 )

物品自动掉落的确切位置取决于物品是否有名称。

局部变量和函数参数都有名称,相应项目的生命周期从创建项目并填充名称时开始:

  • 对于局部变量:在let var = ...声明处。
  • 对于函数参数:作为设置函数调用执行框架的一部分。

当命名项目被移动到其他地方,或者名称消失时,该命名项目的生命周期就结束了。 范围:

        {
            let item1 = Item { contents: 1 }; // `item1` created here
            let item2 = Item { contents: 2 }; // `item2` created here
            println!("item1 = {:?}, item2 = {:?}", item1, item2);
            consuming_fn(item2); // `item2` moved here
        } // `item1` dropped here

还可以“即时”构建一个项目,作为表达式的一部分,然后将其输入到其他内容中。这些未命名的临时项目在不再需要时将被删除。考虑这个问题的一种过于简单但有用的方法是想象表达式语法树的每个分支都扩展到自己的块,并由编译器插入临时变量。例如,这样的表达式:

    let x = f((a + b) * 2);

大致相当于:

    let x = {
        let temp1 = a + b;
        {
            let temp2 = temp1 * 2;
            f(temp2)
        } // `temp2` dropped here
    }; // `temp1` dropped here

当执行达到行尾的分号,临时的都被丢弃了。

查看编译器计算的项目生命周期的一种方法是插入一个故意错误,供借用检查器(下一篇文章叙述)检测。例如,保留超出生命周期范围的引用:

        let r: &Item;
        {
            let item = Item { contents: 42 };
            r = &item;
        }
        println!("r.contents = {}", r.contents);

item错误消息指示 的生命周期的确切终点:

error[E0597]: `item` does not live long enough
   --> lifetimes/src/main.rs:206:17
    |
206 |             r = &item;
    |                 ^^^^^ borrowed value does not live long enough
207 |         }
    |         - `item` dropped here while still borrowed
208 |         println!("r.contents = {}", r.contents);
    |                                     ---------- borrow later used here

同样,对于未命名的临时:

        let r: &Item = fn_returning_ref(&mut Item { contents: 42 });
        println!("r.contents = {}", r.contents);

错误消息显示表达式末尾的端点:

error[E0716]: temporary value dropped while borrowed
   --> lifetimes/src/main.rs:236:46
    |
236 |         let r: &Item = fn_returning_ref(&mut Item { contents: 42 });
    |                                              ^^^^^^^^^^^^^^^^^^^^^ - temporary value is freed at the end of this statement
    |                                              |
    |                                              creates a temporary which is freed while still in use
237 |         println!("r.contents = {}", r.contents);
    |                                     ---------- borrow later used here
    |
    = note: consider using a `let` binding to create a longer lived value

关于引用生命周期的最后一点:如果编译器可以向自己证明,在代码中的某个点之外没有使用引用,那么它会将引用生命周期的端点视为最后使用的位置,而不是封闭范围的末尾。此功能(称为非词法生命周期)允许借用检查器更加慷慨:

    {
        let mut s: String = "Hello, world".to_string(); // `s` owns the `String`

        let greeting = &mut s[..5]; // mutable reference to `String`
        greeting.make_ascii_uppercase();
        // .. no use of `greeting` after this point

        let r: &str = &s; // immutable reference to `String`
        println!("s = '{}'", r); // s = 'HELLO, world'
    } // where the mutable reference `greeting` would naively be dropped

Algebra of Lifetimes(生命代数)

尽管在 Rust 中处理引用时生命周期无处不在,但您无法详细指定它们 - 无法说“我正在处理从第 17 行延伸到第 32 行的生命周期ref.rs”。 (对此有一个部分例外,如下所示:'static.)

相反,您的代码引用具有任意标签的生命周期,通常为'a'b'c, ...,并且编译器有其自己的内部、不可访问的表示形式,该表示形式相当于源代码中的内容。

你不需要对这些终生标签做太多事情;可能的主要事情是将一个标签与另一个标签进行比较,重复一个标签来表明两个生命周期是“相同的”。 (稍后,我们将看到,也可以指定一个生命周期必须大于另一个生命周期,当表示为泛型的生命周期限制。)

这种生命周期代数最容易用函数签名来说明:如果函数的输入和输出处理引用,那么它们的生命周期之间有什么关系?

最常见的情况是接收单个引用作为输入并发出引用作为输出的函数。输出引用必须有生命周期,但是它可以是什么呢?只有一种可能性可供选择:输入的生命周期,这意味着它们共享相同的标签,例如'a

    pub fn first<'a>(data: &'a [Item]) -> Option<&'a Item> {

因为这种变体非常常见,而且(几乎)无法选择输出生命周期,Rust 有 生命周期省略规则意味着您不必显式编写这种情况的生命周期。相同函数签名的更惯用的版本是:

    pub fn first(data: &[Item]) -> Option<&Item> {
}

如果有不止一种输入生命周期选择可以映射到输出生命周期怎么办?在这种情况下,编译器无法弄清楚该怎么做:

error[E0106]: missing lifetime specifier
   --> lifetimes/src/main.rs:399:59
    |
399 |     pub fn find(haystack: &[u8], needle: &[u8]) -> Option<&[u8]> {
    |                           -----          -----            ^ expected named lifetime parameter
    |
    = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `haystack` or `needle`
help: consider introducing a named lifetime parameter
    |
399 |     pub fn find<'a>(haystack: &'a [u8], needle: &'a [u8]) -> Option<&'a [u8]> {
    |                ++++            ++                ++                  ++

基于函数和参数名称的精明猜测是,此处输出的预期生命周期应与输入匹配haystack

    pub fn find<'a, 'b>(
        haystack: &'a [u8],
        needle: &'b [u8],
    ) -> Option<&'a [u8]> {

有趣的是,编译器建议了一种不同的替代方案:让函数的两个输入使用相同的 生命周期'a'。例如,这种生命周期组合可能有意义的函数是:

    pub fn smaller<'a>(left: &'a Item, right: &'a Item) -> &'a Item {}

似乎暗示两个输入生命周期是“相同的”,但包含恐吓引号(此处和上方)以表明情况并非如此。

生命周期存在的理由是确保对项目的引用不会比项目本身的寿命更长;考虑到这一点,输出寿命'a与输入寿命“相同”'a只是意味着输入的寿命必须比输出长。

当两个输入生命周期“相同”时'a,这仅意味着输出生命周期必须包含在两个输入的生命周期内:

    {
        let outer = Item { contents: 7 };
        {
            let inner = Item { contents: 8 };
            {
                let min = smaller(&inner, &outer);
                println!("smaller of {:?} and {:?} is {:?}", inner, outer, min);
            } // `min` dropped
        } // `inner` dropped
    } // `outer` dropped

换句话说,输出寿命必须包含在两个输入寿命中较小的一个内。

相反,如果输出生命周期与输入之一的生命周期无关,则不需要这些生命周期嵌套:

    {
        let haystack = b"123456789"; // start of  lifetime 'a
        let found = {
            let needle = b"234"; // start of lifetime 'b
            find(haystack, needle)
        }; // end of lifetime 'b
        println!("found = {:?}", found); // `found` use within 'a, outside of 'b
    } // end of lifetime 'a

终身排除规则

除了上面描述的“一进一出”省略规则之外,还有另外两个省略规则,这意味着可以省略生命周期。

第一种情况发生在函数的输出中没有引用时;在这种情况下,每个输入引用都会自动获得自己的生命周期,这与任何其他输入参数不同。

第二种情况发生在使用对self(&self&mut self) 的引用的方法中;在这种情况下,编译器假定任何输出引用的生命周期为self,因为这(到目前为止)是最常见的情况。

总结一下函数的省略规则:

  • 一个输入,一个或多个输出:假设输出与输入具有“相同”的生命周期。
fn f(x: &Item) -> (&Item, &Item)
// ... is equivalent to ...
fn f<'a>(x: &'a Item) -> (&'a Item, &'a Item)
  • 多个输入,无输出:假设所有输入都有不同的生命周期。
fn f(x: &Item, y: &Item, z: &Item) -> i32
// ... is equivalent to ...
fn f<'a, 'b, 'c>(x: &'a Item, y: &'b Item, z: &'c Item) -> i32
  • 多个输入&self,包括一个或多个输出:假设输出生命周期与 的生命周期“相同” &self。
    
fn f(&self, y: &Item, z: &Item) -> &Thing
// ... is equivalent to ...
fn f(&'a self, y: &'b Item, z: &'c Item) -> &'a Thing

生命周期 'static

上一节描述了函数的输入和输出引用生命周期之间的各种不同可能的映射,但忽略了一种特殊情况。如果没有输入生命周期,但输出返回值仍然包含引用,会发生什么情况?

    pub fn the_answer() -> &Item {}
error[E0106]: missing lifetime specifier
   --> lifetimes/src/main.rs:411:28
    |
411 |     pub fn the_answer() -> &Item {
    |                            ^ expected named lifetime parameter
    |
    = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
    |
411 |     pub fn the_answer() -> &'static Item {
    |                            ~~~~~~~~

唯一允许的可能性是返回的引用具有保证永远不会超出范围的生命周期。这是由特殊的生命周期表明的'static,这也是唯一一个具有特定名称而不是占位符标签的生命周期。

    pub fn the_answer() -> &'static Item {}

在生命周期内获取某些东西的最简单方法'static是引用被标记为的全局变量static

    static ANSWER: Item = Item { contents: 42 };
    pub fn the_answer() -> &'static Item {
        &ANSWER
    }

Rust 编译器保证项目static在整个程序运行期间始终具有相同的地址,并且永远不会移动。从逻辑上讲,这意味着对某个项目的引用static具有生命周期。'static

请注意,一个const全局变量没有相同的保证:仅保证在各处都相同,并且无论在何处使用该变量,都允许编译器制作任意数量的副本。这些潜在的副本可能是短暂的,因此无法满足'static要求:

    const ANSWER: Item = Item { contents: 42 };
    pub fn the_answer() -> &'static Item {
        &ANSWER
    }
error[E0515]: cannot return reference to temporary value
   --> lifetimes/src/main.rs:424:9
    |
424 |         &ANSWER
    |         ^------
    |         ||
    |         |temporary value created here
    |         returns a reference to data owned by the current function

还有一种可能的方法可以获得'static 生命周期的东西。'static的关键承诺是:生命周期应该比程序中的任何其他生命周期都长寿;在堆上分配但从未释放的值也满足了这一约束。(类似Java的static修饰的常量和方法

普通的堆分配Box<T>对此不起作用,因为不能保证(如下一节所述)该项目不会在此过程中被丢弃:

    {
        let boxed = Box::new(Item { contents: 12 });
        let r: &'static Item = &boxed;
        println!("'static item is {:?}", r);
    }
error[E0597]: `boxed` does not live long enough
   --> lifetimes/src/main.rs:318:32
    |
318 |         let r: &'static Item = &boxed;
    |                -------------   ^^^^^^ borrowed value does not live long enough
    |                |
    |                type annotation requires that `boxed` is borrowed for `'static`
319 |         println!("'static item is {:?}", r);
320 |     }
    |     - `boxed` dropped here while still borrowed

但是,那Box::leak函数将拥有的引用转换Box<T>为对 的可变引用T。该值不再有所有者,因此它永远不会被删除——这满足了'static生命周期的要求:

    {
        let boxed = Box::new(Item { contents: 12 });
        // `leak()` consumes the `Box<T>` and returns `&mut T`.
        let r: &'static Item = Box::leak(boxed);
        println!("'static item is {:?}", r);
    } // `boxed` not dropped here, because it was already moved

无法删除该项目还意味着永远无法使用安全的 Rust 回收保存该项目的内存,这可能会导致永久性内存泄漏。恢复内存需要unsafe代码,这使得这是一种为特殊情况保留的技术。

生命周期和堆

到目前为止的讨论集中在堆栈上项目的生命周期上,无论是函数参数、局部变量还是临时变量。但是上面的物品呢堆?

关于堆值要认识到的关键是每个项目都有一个所有者(除了特殊情况,例如上一节中描述的故意泄漏)。例如,简单地Box<T>T值放在堆上,所有者是保存以下内容的变量Box<T>

    {
        let b: Box<Item> = Box::new(Item { contents: 42 });
    } // `b` dropped here, so `Item` dropped too.

Box<Item>当超出范围时,拥有者会删除其内容,因此Item堆上变量的生命周期与Box<Item>堆栈上变量的生命周期相同。

堆上值的所有者本身可能位于堆上而不是堆栈上,但是谁拥有该所有者呢?

    {
        let b: Box<Item> = Box::new(Item { contents: 42 });
        let bb: Box<Box<Item>> = Box::new(b); // `b` moved onto heap here
    } // `b` dropped here, so `Box<Item>` dropped too, so `Item` dropped too

所有权链必须在某个地方结束,只有两种可能性:

  • 该链以局部变量或函数参数结束——在这种情况下,链中所有内容的生命周期就是'a该堆栈变量的生命周期。当堆栈变量超出范围时,链中的所有内容也会被删除。
  • 该链以标记为的全局变量结束static- 在这种情况下,链中所有内容的生命周期都是'static。该static变量永远不会超出范围,因此链中的任何内容都不会自动删除。

因此,堆上项目的生命周期从根本上与堆栈生命周期相关。

数据结构中的生命周期

前面关于生命周期代数的部分集中于函数的输入和输出,但是当引用存储在数据结构中时也存在类似的问题。

如果我们试图在不提及相关生命周期的情况下将引用偷偷放入数据结构中,编译器会尖锐地提醒我们:

    pub struct ReferenceHolder {
        pub index: usize,
        pub item: &Item,
    }
error[E0106]: missing lifetime specifier
   --> lifetimes/src/main.rs:452:19
    |
452 |         pub item: &Item,
    |                   ^ expected named lifetime parameter
    |
help: consider introducing a named lifetime parameter
    |
450 ~     pub struct ReferenceHolder<'a> {
451 |         pub index: usize,
452 ~         pub item: &'a Item,
    |

像往常一样,编译器错误消息会告诉您要做什么。第一部分很简单:给引用类型一个显式的生命周期'a,因为在数据结构中使用引用时没有生命周期省略规则。

第二部分不太明显,但具有更深层次的影响:数据结构本身必须有一个 <'a>与其中包含的引用的生命周期相匹配的生命周期注释:

    pub struct ReferenceHolder<'a> {
        pub index: usize,
        pub item: &'a Item,
    }

数据结构的生命周期注释具有传染性:任何使用该类型的包含数据结构也必须获取生命周期注释:

    // Annotation includes lifetimes of all fields
    pub struct MultiRefHolder<'a, 'b> {
        pub left: ReferenceHolder<'a>,
        pub right: ReferenceHolder<'b>, // Could choose 'a instead here
    }

这是有道理的:任何包含引用的内容,无论嵌套多深,都仅在所引用的项目的生命周期内有效。如果该项目被移动或删除,则整个数据结构链将不再有效。

然而,这也意味着涉及引用的数据结构更难使用——数据结构的所有者必须确保生命周期全部一致。因此,尽可能选择拥有其内容的数据结构 ,特别是在代码不需要高度优化的情况下。如果这是不可能的,则可以使用各种智能指针类型(例如Rc)中描述的 可以帮助理清生命周期约束。

生命周期界限

通用的代码涉及一些未知类型T,通常受特质束缚T: SomeTrait。但是,如果围绕T引用类型构建的代码会发生什么?

引用可以通过多种不同的方式进入泛型组合中:

  • 例如,泛型可能采用<T>,但包含处理引用的代码。T&T
  • T例如,该类型本身可能是引用类型,或者是包含引用的&Thing某种更大的数据结构。MultiRefHolder<'a, 'b>

无论引用产生的途径如何,任何通用数据结构都需要传播其关联的生命周期,如上一节所示。

允许这种情况的主要方法是指定生命周期界限,它指示 type( T: 'b) 或特定生命周期'a: 'b必须比其他生命周期长'b

例如,考虑一种包装引用的类型(有点类似于Ref返回的类型 RefCell::borrow()):

pub struct Ref<'a, T: 'a>(&'a T);

这个通用数据结构包含一个显式引用&'a T,如上面的第一个项目符号所示。但该类型T本身可能包含具有一定生命周期的引用'b,如上面的第二个项目符号所示。如果T的固有生命周期'b小于外部生命周期,'a我们就会遇到潜在的灾难:Ref将会持有对数据结构的引用,而该数据结构本身的引用已损坏。

为了防止这种情况,我们需要'b大于'a;对于命名生命周期,这将被写为'b: 'a,但我们需要稍微不同地说,为T: 'a。粗略地翻译成文字,就是说“类型中的任何引用T 都必须有一个超出生命周期的生命周期'a”,这使得Ref安全:如果它自己的生命周期 ( 'a) 仍然有效,那么隐藏在 中的任何引用也是如此T

将一生界限翻译成文字也有助于'static像这样的一生界限T: 'static。这表示“类型中的任何引用都T必须有'static生命周期”,这意味着T不能有任何动态引用。任何T拥有其内容的非引用类型StringVec等等)都满足此界限,但任何具有<'a>蠕变功能的类型则不然。

出现这种情况的一个常见情况是当您尝试使用 . 在线程之间移动值时 std::thread::spawn。移动的值必须是实现的类型Send,表明它们可以安全地在线程之间移动,但它们也不需要包含任何动态引用('static生命周期界限)。当您意识到对堆栈上某些内容的引用现在会引发以下问题时,这是有道理的:哪个堆栈?每个线程的堆栈都是独立的,因此无法跟踪它们之间的生命周期。


1:例如,Chromium 项目估计70% 的安全 bug 是由于内存安全造成的

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值