之前在第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);
}
上面的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);
的確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 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);
直接告訴我們參數要求的類別是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掉。
考慮以下情境:
// 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
會明顯比較慢,但現在應該沒有差別了(?)
如上所述,我們把a
和b
都move了,所以編譯器請我們使用clone()
複製一份再傳進fn,我們直接照著修改:
+let longer = longer_string(a.clone(), b.clone());
-let longer = longer_string(a, b);
通過了,好像也沒什麼問題啊,為什麼需要生命週期呢?我們再看以下例子:
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還是舊的
}
這裡有幾個小問題:
clone
複製一份字串其實會增加一點點開銷(就是效能比較差的意思)。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編譯器在檢查所有權和作用域的時候,就會加入剛剛的生命週期參數限制,在這裡就會報錯,所有權的檢查規則提到
在任何時候,我們要嘛只能有一個可變參考,要嘛可以有任意數量的不可變參考。
在這個例子 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