[Rust] lifetime,以及 Borrow checker是如何检查lifetime的

Borrow checker是rust语言中保证引用(reference)安全性的重要机制。通过borrow checker机制,rust能防止虚悬引用(dangling reference)的出现。所谓虚悬引用是指,当引用对象的值已经从内存中被清除时,指向原来内存位置的引用却仍然存在。因此虚悬引用会导致数据泄露的风险,或造成其他软件错误。

Borrow checker要求引用的寿命(lifetime)要小于被引用的对象的寿命。换一种角度说,当对某一值的引用还未过期时,该值也不得过期。大多数高阶语言是通过garbage collection机制来实现这一点的。rust则使用了更刚烈的机制,即所有的值的寿命都会随着其所在的scope的结束而结束。这就反过来要求对该值的引用也在这一scope中结束,而不是如在garbage collection中那样当所有引用失去时被引用的值才会被自动收回(在rust的语境中,这意味着此时“引用”对被引用的值拥有所有权(ownership))。在对rust程序的编译过程中,borrow checker会比对引用和被引用的值的寿命,当发现前者大于后者时,即会编译失败。因此在编译阶段就杜绝了虚悬引用的问题,也用这种刚强的方式实现了对内存的回收。对比borrow checker和garbage collection的内存管理风格,可以说前者是让引用迁就被引用的值,后者是让被引用的值牵就引用。

寿命(lifetimes)

因为引用和被引用的值的寿命都交由borrow checker进行判定,因此我们初步设想二者的定义都应具备某种机器所可把握的简单直接性。

引用(Reference)的寿命指该引用被声明起到其最后被使用为止的代码区域。

被引用的值(Referent)的寿命指该值被声明起到其所在scope结束为止的代码区域。

因此,引用的寿命小于被引用的值的寿命是指前者对应的代码区域应完全包含在后者对应的代码区域之内。

(当然当你真正理解了lifetimes之后,你会发现上述定义的不确切之处。lifetimes定义的重点是一个值死亡的时点而不是其lifespan。一个活了99岁的人只要死在活了9岁人之前,就可以说前者的lifetime小于后者。但为了(自我)教学的方便,我们暂时保持上述定义的僵化教条。)

borrow checker如何避免虚悬引用

上述定义看似绕口,在具体的代码例子中则能一目了然。如下述存在虚悬引用的代码无法通过编译,因为其无法通过borrow checker的审查:

    // the following codes cannot be compiled
    let r;                 // ----------- 'b
    {
        let x = 5;  // ---- 'a
        r = &x;     
    }               // ---- 'a
    println!("r: {}", r);   // ----------- 'b

由上述代码标定的区域可知,被引用的值x的寿命为’a对应的区域,即从其被声明起到其所在的scope结束为止。引用r的寿命为’b对应的区域,即从其被声明起到其最后被使用为止。显然'a < 'b,不满足borrow checker被引用的寿命应大于引用的寿命的要求。对应的编译错误会吿诉你borrowed value does not live long enough。本例中borrowed value即为x

函数中的寿命

函数的功能是,接受某些值作为输入,并返回一些值作为输出。因此自然,函数可以接受一些引用作为输入,并返回一些引用作为输出。但是,输出的引用所引用的值只能来自于输入的引用,而不会产生自函数内部,因为函数无法返回一个对其内部值的引用,(这是因为这个引用所指向的pointer在函数返回时就被回收了)。因此下面定义的函数是无法通过编译的:

// the following function cannot be compiled
fn ret_ref_of_local() -> (i32, &i32) {
    let x = 5;
    (x, &x)    
}

因此函数输出的引用必然和其输入的引用共享被引用值,也因此函数输出的引用的寿命不应大于该被引用值的寿命。如在下面代码所设定的最简单情形:一元函数中,输入和输出引用同一值。 因此为了通过borrow checker的审查,输出引用的寿命不应高于输入所引用的值的寿命。当如同下面代码所示,输出引用result在被引用值x的scope之外被使用时,程序不能编译。

// The following codes cannot be compiled.
fn deliver_ref(r: &i32) -> &i32 {
    r    
}

fn main() {
    let result;
    {
        let x = 3;
        let r = &x;
        result = deliver_ref(r);
    }
    println!("{}", result);
}

注意这里borrow checker所比对的lifetimes是输出引用result和被输入引用的值x的,而不是输出引用result和输入引用r的。理解这一点是掌握borrow checker如何审查函数lifetimes的关键所在。如在下面修改后的代码中,即使输入引用r的寿命小于输出引用result的寿命,但只要result的寿命不超过r所引用的值x的寿命,代码仍能编译成功。

fn deliver_ref(r: &i32) -> &i32 {
    r    
}

fn main() {
    let x = 3;
    let result;
    {
        let r = &x;
        result = deliver_ref(r);
    }
    println!("{}", result);
}

一个小细节,在同一scope内,xresult哪一个首先被声明并不影响二者有相同的lifetime,重点在于resultx的scope结束之前被使用。下面列出的代码仍然可以编译成功,尽管

fn deliver_ref(r: &i32) -> &i32 {
    r    
}

fn main() {
    let result;
    let x = 3;
    {
        let r = &x;
        result = deliver_ref(r);
    }
    println!("{}", result);
}

generic lifetimes

fn deliver_ref(r: &i32) -> &i32 {
    r    
}

因此,在前面的例子中,borrow checker可以从上述函数中推断出输出引用所要保持的lifetime。但在更复杂的函数中,borrow checker无法明确推断出输入和输出引用的寿命的关系,而需要我们在代码中提供。generic lifetimes是rust中提供这一信息的系统。使用generic lifetimes,上述函数中隐藏的输入和输出的寿命的关系可以明确地表达出来。

fn deliver_ref<'a>(r: &'a i32) -> &'a i32 {
    r    
}

在上述函数中,输入和输出引用有相同的寿命'a,但正如我们之前分析的,我们不能将之按字面理解为输入引用和输出引用的寿命相同,而应解读为,输出引用的寿命不应超过被输入引用的值的寿命。这一解读规则对于理解更复杂的函数中设置的lifetimes关系非常关键。

// the following codes cannot be compiled
fn greater<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
    if x > y {
        x    
    } else {
        y    
    }
}

fn main() {
    let result;
    let y = 5;    
    {
        let x = 3;
        result = greater(&x, &y);
    }   
    println!("{}", result);
}

在上述代码中,函数greater接受xy两引用作为输入,并返回其中所引用的值较大的一个作为输出。在编译时borrow checker无法推断出函数会返回哪一个引用,所以我们设定输出的引用的寿命不得超过任一输入所引用的值的寿命。这样就完全杜绝了任何虚悬引用通过编译的可能性,从而保证了代码的安全。

我们只需对必要的函数变量设置lifetime关系。如下面列出的函数first返回的引用仅与第一个变量x有关。因此只需为第一个变量设定lifetime关系。使输出引用的寿命不大于值x的寿命,我们已经可以完全杜绝引用虚悬,没有必要再将其寿命限定在无关的y值的寿命之内了。因此下面的代码完全可以编译成功。

fn first<'a>(x: &'a i32, _y: &i32) -> &'a i32 {
    x    
}
fn main() {
    let result;
    let x = 5;
    {
        let y = 4;
        result = first(&x, &y);
    }
    println!("{}", result);
}

如果我们把_y也拉入lifetimes的限定关系之中,则相关代码就无法编译成功了。

// the codes below cannot be compiled
fn first<'a>(x: &'a i32, _y: &'a i32) -> &'a i32 {
    x    
}

fn main() {
    let result;
    let x = 5;
    {
        let y = 4;
        result = first(&x, &y);
    }
    println!("{}", result);
}

当然如果遇到必须要给出lifetimes限定而未限定的情形时,编译器会报错。 如在下面的代码中我们试图骗过borrow checker,以制造出对y值的虚悬引用。为此我们在函数greater的定义中未对引用y与输出引用的lifetimes关系做足够的限定。编译器可以轻松地识别出我们的狡计。

// the following function cannot be compiled
fn greater<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
    if x > y {
        x    
    } else {
        y    
    }
}

fn main() {
    let x = 3;
    let result;
    {
        let y = 5;
        result = greater(&x, &y);
    }
    println!("{}", result);
}

上述审查是发生在编译阶段的。即使在事后表明函数返回引用的寿命是正确的,不存在引用虚悬的情况,编译仍然无法通过。这是因为在事前编译器并不知道xy的哪一个的引用会被返回,在此无知之幕之下,编译器只能执行更保守的规则,即输出引用同时不得超过xy任一值。如下面代码一样无法通过编译,即使如果代码被运行并不会真的造成虚悬引用:

// the following function cannot be compiled
fn greater<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
    if x > y {
        x    
    } else {
        y    
    }
}

fn main() {
    let x = 5;
    let result;
    {
        let y = 3;
        result = greater(&x, &y);
    }
    println!("{}", result);
}

Struct中的寿命

当struct中的field为一引用时,我们需要指定该struct的generic lifetime。如下面所定义的struct:

#[derive(Debug)]
struct ImportantExcerpt<'a> {
    part: &'a str,    
}

该代码应解读为,该struct的寿命不得超过part字段所引用的值的寿命。注意和前面讨论的逻辑一致,重要的是将struct的寿命与被引用的值的寿命做比较,而不是与part字段的引用本身做比较。因而在下面的代码中,尽管struct i的寿命大于引用first_sentence的寿命,但是因为struct i的寿命没有超过first_sentence所引用的值novel的寿命,因此仍然是可以成功编译的:

fn main() {
    
    let i;
    let novel = String::from("Call me Aulee. Some years ago...");
    {
        let first_sentence = novel.split(".").next().expect("could not find a .");
        i = ImportantExcerpt {
            part: first_sentence,    
        };
    }
    println!("{:?}", i);
}

作为对比,在下面的代码中由于struct i的寿命超过了值novel的寿命,因此被borrow checker拒绝了。(String是heap上的数据的指针,拥有对数据的所有权。struct i 向String暂时借入所有权,需要在String被回收前“归还”所有权。)

fn main() {
    
    let i;
    {
        let novel = String::from("Call me Aulee. Some years ago...");
        let first_sentence = novel.split(".").next().expect("could not find a .");
        i = ImportantExcerpt {
            part: first_sentence,    
        };
    }
    println!("{:?}", i);
}

抹去lifetimes(lifetime elision)

为了增加代码的可读性,rust允许在一些情况下不标注generic lifetimes,而由编译器推断出lifetimes。推断的三条规则是:

  1. 编译器为每一个输入引用分配各自的寿命参数。

  2. 如果仅有一个输入引用,则该输入引用的寿命参数分配给所有的输出引用。

  3. 如果有多个输入引用, 但其中一个是&self&mut self,则self的寿命分配给所有的输出引用。

如果通过应用上述规则后仍然存在着含混之处,则需在代码中由人工设定generic lifetimes。

下面我们通过给前面提到的struct ImportantExcerpt定义方法作为例子,来说明三条推断规则。

首先为ImportantExcerpt定义一个最简单的方法level,该方法以&self为唯一的输入引用,但不返回任何输出引用,仅返回数字3。

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3    
    }
}

我们引用第1条规则,为唯一的输入引用&self分配lifetime:

impl<'a> ImportantExcerpt<'a> {
    fn level(&'a self) -> i32 {
        3    
    }
}

我们看到所有的输入引用(仅唯一一个)都有了lifetime标注,且没有输出引用。所以我们的自动规则可以推断出所有的lifetime,说明我们不用人为设定level方法中的generic lifetimes。

下面的方法ret_part也不用设置lifetime,因为我们可以引用规则1和规则2 推断出全部的lifetimes。

impl<'a> ImportantExcerpt<'a> {
    fn ret_part(&self) -> &str {
        self.part    
    }   
}

引用规则1,可以为&self分配lifetime。因为仅有一个输入引用,所以可以将它的lifetime分配给所有的输出引用。即,

impl<'a> ImportantExcerpt<'a> {
    fn ret_part(&'a self) -> &'a str {
        self.part    
    }   
}

既然推断出了全部输入输出引用的lifetime,我们在定义ret_part方法时可以抹去对lifetime的设定。

在下面的announce_and_return_part方法中,除了&self这个输入引用之外,还有另外一个输入引用announcement

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part    
    }
} 

首先引用规则1,为两个输入引用分别指定lifetime。再根据规则3,为输出引用分配&self的lifetime。因此使用规则也可推断出所有的输入和输出lfietimes,我们在定义announce_and_return_part时也可以抹去对lifetime的设定。

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'a str {
        println!("Attention please: {}", announcement);
        self.part    
    }
}

下面的get_same_length_str是一个比较有趣的例子。该方法所返回的引用不是来自&self,而是来自另一输入引用my_sentence。我们将看到三条规则无法实现对lifetimes的自动设定。

// the following codes cannot be compiled
impl<'a> ImportantExcerpt<'a> {
    fn get_same_length_str(&self, my_sentence: &str) -> &str {
        let len = self.part.len();
        &my_sentence[0..len]    
    }
}

引用规则1和规则3,我们得到如下设定:

// the following codes cannot be compiled
impl<'a> ImportantExcerpt<'a> {
    fn get_same_length_str<'b>(&'a self, my_sentence: &'b str) -> &'a str {
        let len = self.part.len();
        &my_sentence[0..len]    
    }
}

上述代码似乎使用规则完成了对lifetimes的设定。但是其实制造出了逻辑上的冲突。虽然get_same_length_str方法返回的引用应来自my_sentence,故为'b。但是我们却将之指定为'a。为化解上述矛盾,我们可以让返回的引用同时遵循&selfmy_sentence所引用的值的lifetimes。故可将lifetimes重新设置为:

impl<'a> ImportantExcerpt<'a> {
    fn get_same_length_str(&'a self, my_sentence: &'a str) -> &'a str {
        let len = self.part.len();
        &my_sentence[0..len]    
    }
}

因为方法get_same_length_str被默认采用struct ImportantExcerpt 的lifetime,我们可进一步将上面代码简化为:

impl<'a> ImportantExcerpt<'a> {
    fn get_same_length_str(&self, my_sentence: &'a str) -> &str {
        let len = self.part.len();
        &my_sentence[0..len]    
    }
}

'static 和 string literals

前面曾提到过,函数无法从其内部产出一个引用,所有的输出引用应当来自函数的输入引用。下面代码中定义的from_string_lieteral函数似乎是一个反例:

    let my_string_literal;
    {
        fn from_string_lieteral<'a>() -> &'a str {
            let x = "My name is Aulee";
            x    
        }    
        my_string_literal = from_string_lieteral();
    }
    println!("{}", my_string_literal);

这个函数不接受任何输入引用,但是却返回了一个输出引用。这是因为返回的a是一个string literal(字符串字面量)的引用。由于string literal直接储存于程序binary中,其寿命不受其声明时所在的scope的局限,而是存活于程序的整个期间(因此它不为任何一个寿命有限的对象所拥有)。像string literal这样的被引用值所拥有的这个最长的寿命记为'static

但是string literal的寿命也有可能因为lifetime自动推理的规则而受到限制。如下面的代码无法通过编译,因为根据规则1,函数input_i32_output_str的输出引用的寿命应在输入所引用的值的寿命的范围之内:

// the codes below cannot be compiled
    let the_string_literal;
    {
        let i = 6;
        fn input_i32_output_str(x: &i32) -> &str {
            println!("The input &i32 is: {}.", x);
            "This is the output &str."  
        }
        the_string_literal = input_i32_output_str(&i);    
    }
    println!("{}", the_string_literal); 

此时,如果能明确地告知编译器该输出的string literal引用的寿命为'static,相关代码就可编译成功了。

    let the_string_literal;
    {
        let i = 6;
        fn input_i32_output_str(x: &i32) -> &'static str {
            println!("The input &i32 is: {}.", x);
            "This is the output &str."  
        }
        the_string_literal = input_i32_output_str(&i);    
    }
    println!("{}", the_string_literal);     

Variance 和 Subtype

编译器使用形式化的类型推理来实现对寿命相容性的推断。在这一形式化的推理中,寿命也被视为一种特殊的type,服从与常规type相同的规则:即subtype可以替换type;subtype比type“更好”——我们可以用一个功能更好的子类型来替换功能受限制的母类型。这种类型的替换称作coercion。对于寿命这种特殊类型而言,类型的替换指更长的寿命可以替换更短的寿命。

简单的类型可以包裹成更复杂的组合类型,而类型替换规则由部分到组合的转换中存在三种类型:协变(covariance)、不变(invariance)、与反变(contravariance)。

利用variance和subtype的概念,我们可以对前面分析中所建立的直觉,做出纯粹形式的推导。

下面使用variance和subtype的概念,展示编译器是如何机械地分析前面例子中寿命是否相容的。

fn ret_ref_of_local() -> (i32, &i32) {
    let x = 5;
    (x, &x)    
}

上述代码中,抹去的generic lifetime为ret_ref_of_local<'a>() -> (i32, &'a i32)

上述函数没有输入的引用,输出的引用为&'a i32&'a i32是一个类型包裹。其中&_对于'a是协变的。

在上述函数内部, 设x的寿命为'short。则函数返回的引用为&'short i32。类型cocercion要求&'short i32&'a i32的subtype。由于&_对于'a是协变的,即要求'short是'a的subtype,即'short:'a,即'short > 'a。而'a必须要足够长以满足函数调用的需要,因此'short > 'a是不可能的。因此上述函数无法编译。

// The following codes cannot be compiled.
fn deliver_ref(r: &i32) -> &i32 {
    r    
}

fn main() {
    let result;
    {
        let x = 3;
        let r = &x;
        result = deliver_ref(r);
    }
    println!("{}", result);
}

上述代码中,抹去的generic lifetime为deliver_ref<'a>(r: &'a i32) -> &'a i32。设result的寿命为'outerr的寿命为'inner

因此在函数的输入一侧,要求&'inner i32 &'a i32的subtype。在&'inner i32&'a i32两个类型包裹(wrapper)中, &_对于寿命是协变的。因此这就意味着'inner'a的subtype。也就是'inner:'a,即作为寿命前者必须比后者长'inner > 'a。因此我们按最紧的情况取'a为为'inner

在函数的输出一侧,deliver_ref(r) -> result,因此要求&'a i32&'outer i32的subtype。由于&_对于寿命是协变的,这就要求'a是'outer的subtype,即'a:'outer,即前者的寿命要大于后者'a > 'outer

而我们知道'inner < 'outer。因此同时符合上述两个条件的'a是不存在的。故上述代码不能编译。