iT邦幫忙

2023 iThome 鐵人賽

DAY 15
0

生命週期變數

之前在第8篇講所有權借用Borrowing時,有提到作用域(生命週期)這個詞彙。rust透過所有權的概念,去判斷變數是否還活著。比如所有權移交出去後,該變數就死掉了(你已經死了),這就是變數生命週期的管理。而有些比較複雜的情境,rust編譯器無法判斷誰會活的比較久的時候,就要加生命週期參數(lifetime parameter),來讓rust檢查是不是能通過編譯(符合所有權借用規則)。

這個概念比較不容易理解,在這裡先知道rust的'_, 'a, 'static, 等前面加一撇開頭的變數,都是來判斷生命週期的變數,詳見rust book的說明。'_ 就是像是生命週期的 _變數,'a就是命名為a的生命週期變數,如前例如果我們要具名,就要寫成如下的格式:

pub async fn new_game<'a>(ctx: State<'a, Context>)
    -> Result<(isize, Game), ErrorResponse> {

'static是特殊的生命週期變數,代表整個程式的生命週期。而現在版本的rust有提供省略生命週期變數的幾項規則,讓我們不用再逐條撰寫生命週期變數,有時候看到比較早期版本的rust專案,還是會看到許多撇來撇去的。

用示例來講解會比較清楚:

// examples/lifetime/src/main.rs
struct Student {   // 結構體,理論上應該一起存活
    no: i32,       // 編譯時已知大小,放Stack,使用Copy trait傳遞(Call by Value)
    name: String,  // 編譯時未知大小,放Heap,使用Call by reference
}

fn move_string(s: String) {    // 這一個fn會把s的所有權拿走
    s.to_string();             // 這行執行完s就被丟棄(drop)了
}

fn main() {
    let student = Student { no: 1, name: "John".to_string() };
    println!("student no, {}!", student.no);
    println!("student name, {}!", student.name);
}

執行hello student exzmple

上面的main執行沒有問題,學生的學號和名字都正確,但是我們加入一行:

 let student = Student { no: 1, name: "John".to_string() };
+move_string(student.name);    // 把name移走
 println!("student no, {}!", student.no);
 println!("student name, {}!", student.name);

example中student.name的所有權被移走

的確name變數因為把所有權交出去(move)了,所以報錯也是很合情合理的,但是如果我們把最後列印出name註解掉呢:

let student = Student { no: 1, name: "John".to_string() };
move_string(student.name);    // 把name移走
println!("student no, {}!", student.no);
// println!("student name, {}!", student.name);

移除叫用不存在的變數

卻可以執行成功,這跑示程式跑到最下面一行的時候,這個student結構體裡有學號,但沒有名字,也就是說他的學號活的比他的名字還要久。但這在物件導向語言裡聽起來很奇怪對吧,同一個class的欄位,某些還活著某些被回收了(?),那程式一定會出問題呀。所以rust的生命週期參數就是來幫我們標註我們想要的生命週期長相,rust會透過它底層的所有權機制幫我們判斷我們是不是有違反,才好讓我們的程式品質更好一點,所以才說rust寫出來的程式相對比較安全(?)。

幫結構體Struct加上生命週期變數

幫結構體加生命週期參數,語法如下::

struct LifeStudent<'a> {    // 這裡的撇a就是生命週期參數 你也可以撇b撇c都可以
    no: &'a i32,            // 寫法是要給個&再接'a
    name: &'a String,       // 現在name我們要求要活的跟no 或跟struct一樣久
}
fn main() {
    let student = LifeStudent { no: 1, name: "John".to_string() };
    println!("student no, {}!", student.no);
    println!("student name, {}!", student.name);
}

修改生命週期後,提示需要配合修改的部分

因為我們調整後結構體的欄位類別改變了,所以我們在使用的時候,要加&,就像編譯器提示的一樣,不熟還記不起來沒關係,編譯器會教我們,都標出來要把&加在哪裡了,咦,聰明的你應該發現&不就是借用嗎,對喔,Rust透過這個方式限制我們在這情境下只能借用,確保結構體裡的元素生命週期一致:

+let student = LifeStudent { no: &1, name: &"John".to_string() };
-let student = LifeStudent { no: 1, name: "John".to_string() };

正確執行修改後的結果

我們試一下像剛剛一樣加入move會怎樣:

let student = LifeStudent { no: &1, name: &"John".to_string() };
move_string(student.name);
println!("student no, {}!", student.no);
println!("student name, {}!", student.name);

執行結果要求student.name無法借用,建議複製

直接告訴我們參數要求的類別是String,而我們傳遞的是&String不符,後面也建議我們使用.to_string()

+move_string(student.name.to_string());
-move_string(student.name);

最後執行結果

改好後又work了,所以加了生命週期參數,可以幫助我們確保相關資料存活的時間符合我們的預期。

附帶一提,如果需要2個以上的生命週期參數,可以這樣寫:

struct LifeStudent<'a, 'b> {
   no: &'a i32,
   name: &'b String,
}                            

另外有興趣的朋友可以試一下,如果整個struct被move了會怎樣:

fn move_student(s: LifeStudent) {
    s.no;
}
fn main() {
    // let student = LifeStudent { no: 1, name: "John".to_string() };
    let student = LifeStudent { no: &1, name: &"John".to_string() };
    println!("student no, {}!", student.no);
    println!("student name, {}!", student.name);
    move_student(student);                       // 這裡move student
    println!("student no, {}!", student.no);     // 報錯
    println!("student name, {}!", student.name); // 報錯
}

結果會跟我們標註的生命週期一致,student裡的兩個元素的生命週期跟著student這個物件,當student不存活時,裡面的元素也會跟著drop掉。

幫fn加上生命週期參數

考慮以下情境:

// examples/lifetime/src/main.rs
fn longer_string(s1: String, s2: String) -> String { // 取長的字串
    if s1.len() > s2.len() {
        s1        // 把 s1 所有權移交給fn呼叫者
    } else {
        s2        // 把 s2 所有權移交給fn呼叫者
    }             // s1, s2 都被 drop
}

fn main() {
    let a = String::from("Rust");
    let b = String::from("Svelte");
    let longer = longer_string(a, b);    // a 和 b 都被move了

    println!("a: len: {:2}, {:?}", a.len(), a); // :2 是佔用2格位置
    println!("b: len: {:2}, {:?}", b.len(), b); // :? 是Debug,字串會加"
    println!("   longer is >> {:?} <<", longer);
}

備註:String::From(),早期算比較正規的寫法,這裡有提到rust 1.9以前的版本用to_string會明顯比較慢,但現在應該沒有差別了(?)

編譯錯誤提醒:不能使用move後的變數

如上所述,我們把ab都move了,所以編譯器請我們使用clone()複製一份再傳進fn,我們直接照著修改:

+let longer = longer_string(a.clone(), b.clone());
-let longer = longer_string(a, b);

執行比較2字串長度,正確無誤

通過了,好像也沒什麼問題啊,為什麼需要生命週期呢?我們再看以下例子:

fn main() {
    let mut a = String::from("Rust");    // 這裡把a改為mutable
    let b = String::from("Svelte");
    let longer = longer_string(a.clone(), b.clone());

    a.push_str(" is awesome");                   // 修改 a
    println!("a: len: {:2}, {:?}", a.len(), a);  // a 修改了
    println!("b: len: {:2}, {:?}", b.len(), b);
    println!("   longer is >> {:?} <<", longer); // longer還是舊的
}

這裡有幾個小問題:

  1. 傳遞使用clone複製一份字串其實會增加一點點開銷(就是效能比較差的意思)。
  2. longer這個fn其實是跟a,b相關的,但是上面的程式是獨立處理的。

以上,就可以透過生命週期參數來幫助我們,直接寫一個生命週期版的fn如下:

fn longer_string_life<'a>(s1: &'a String, s2: &'a String) ->  String {
    if s1.len() > s2.len() { // ↑上面參數傳遞時指定與fn一樣的生命週期限制
        s1.to_string()
    } else {
        s2.to_string()
    }
}
fn main() {
    let a = String::from("Rust");
    let b = String::from("Svelte");
    let longer = longer_string_life(&a, &b); // ←這裡被強迫改成借用的方式
    println!("a: len: {:2}, {:?}", a.len(), a);
    println!("b: len: {:2}, {:?}", b.len(), b);
    println!("   longer is >> {:?} <<", longer);
}

改了生命週期的型式如上,但目前只解了第1個問題,就是效能,因為直接借用,沒有使用clone複製,所以效能變好了,可是如果我們對a變數進行修改:

let mut a = String::from("Rust");         // 改為mutable
let b = String::from("Svelte");
let longer = longer_string_life(&a, &b);
a.push_str(" is awesome");                // 修改

正確編譯,但執行結果與預期不符

還是一樣是錯的啊,那我們再調整一下,我們把回傳值也加上生命週期參數的限制:

+fn longer_string_life<'a>(s1: &'a String, s2: &'a String) -> &'a String {
-fn longer_string_life<'a>(s1: &'a String, s2: &'a String) ->  String {
     if s1.len() > s2.len() {
+        &s1
-        s1.to_string()
     } else {
+        &s2
-        s2.to_string()
     }

rust編譯器提示不可同時含可變引用與不可變引用

如此,rust編譯器在檢查所有權和作用域的時候,就會加入剛剛的生命週期參數限制,在這裡就會報錯,所有權的檢查規則提到

在任何時候,我們要嘛只能有一個可變參考,要嘛可以有任意數量的不可變參考。

在這個例子 longer 使用到 a, b的 「不可變參考」,而a.push_str執行時需要使用到a的「可變參考」,而longer活到了93行,所以在89行的時候等於是同時存在a的「可變參考」與a的「不可變參考」,違反了所有權借用規則。

Rust透過所有權的可變/不可變參考檢核,確保資料的同步一致性。

這時候我們被迫再執行一次longer_string_life

fn main() {
    let mut a = String::from("Rust");
    let b = String::from("Svelte");
    let longer = longer_string_life(&a, &b);
    a.push_str(" is awesome");
    let longer = longer_string_life(&a, &b);     // 再執行一次
    println!("a: len: {:2}, {:?}", a.len(), a);
    println!("b: len: {:2}, {:?}", b.len(), b);
    println!("   longer is >> {:?} <<", longer);
}

最終執行正確的結果

這就是fn加生命週期參數的使用。

最後的例子大家可能會覺得奇怪,可以指派(let)同一個變數2次,因為rust是用所有權的概念去指派變數,所以同名變數第二次指派的時候,就會自動把前一個同名變數給drop掉,有時候我們會利用這個特性來做類似動態語言的事,比如:

fn main() {
    let age = 30;                             // i32
    let age = format!("{} years old", age);   // String
    // 以下不會再用到 30 這個i32的資料類別
}

以上範例如果要套用在一般靜態語言下,會因為在靜態語言中變數宣告後,類別就再也不能變更了,所以要就要再給另外一個變數,時常會看到有人在程式裡寫一堆tmp, tmp1, tmpStr等暫存變數,或是str_age, int_age不好的匈牙利命名法,在這可以用let蓋掉前面的,就不用再寫tmp了。

另外雖然這裡rust雖然用起來很像動態語言,但其實運作上是在指派另一個變數(String)時,一併把之前的變數(i32)drop掉。除了寫法感覺像動態般,另一個好處是效能的提高,比如時常我們會需要接取一些參數或資料,基本上宣告一個變數就會存活整個fn,而rust這種寫法可以讓我們提早釋放用不到的資源,所以rust會比較快不是沒有原因的。

參考資料:

本系列專案源始碼放置於 https://github.com/kenstt/demo-app


上一篇
14 幫 tauri 整理一下儀容
下一篇
16 幫 rust 加上TLS,及builder演示
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言