翻譯|Rust臨時變數的生命週期和“Super Let”

Cinea發表於2024-04-22

本文原作者:Mara Bos,原文連結:https://blog.m-ou.se/super-let/

Rust臨時變數的生命週期是一個複雜但經常被忽略的話題。在簡單情況下,Rust將臨時變數存在的時間控制得恰到好處,使我們不必過多考慮它們。然而,也有很多我們可能不會馬上完全得到我們想要結果的情況。

在這篇文章中,我們會(重新)發現Rust對臨時變數生命週期的規則,審視一些延長臨時變數生命週期的案例,並探索一個新的語言概念——super let——來讓我們更好地控制這一切。

臨時變數

這是一條沒有上下文的Rust語句,使用了一個臨時的String

f(&String::from('🦀'));

這個臨時的String會生存多久呢?如果我們正在設計Rust,我們基本上可以在這兩個選項中做選擇:

  1. 這條字串在呼叫f前馬上就被丟棄了,或者,
  2. 這條字串僅在呼叫f後才被丟棄。

如果我們選擇選項1,那麼上面的語句就會總是導致一個借用檢查錯誤,因為我們不能讓f借用已經不復存在的變數。

所以,Rust選擇了選項2:這個String首先被構造,然後一個對它的引用被傳遞給f,並且,僅僅在f返回之後,我們才丟棄這個臨時的String

在let語句中

現在看看這個稍微難一點的:

let a = f(&String::from('🦀'));
…
g(&a);

再來一次:這個臨時的String會生存多久?

  1. 這條字串在let語句結束後被丟棄:也就是f返回之後,g呼叫之前。或者,
  2. 這條字串在呼叫g之後,和a同時被丟棄。

這一次,選項1可能會正常工作,這取決於f的簽名。如果f被定義為fn f(s: &str) -> usize(就像str::len),那麼在let語句後馬上丟棄String就是完全OK的。

不過,如果f被定義為fn f(s: &str) -> &[u8](就像str::as_bytes),那麼a就會借用這個臨時的String,因此當我們繼續持有a時就會收到一個借用檢查的錯誤。

對選項2來說,它在上述兩種情況下都能正常編譯,但是我們可能會將臨時變數持有一段比實際需要更長得多的時間。這可能會浪費資源,或者產生不明顯的bug(例如,一個因為MutexGuard被丟棄得比預期更晚而導致的死鎖)。

這聽起來像是我們需要第三種選擇:讓這一切由f的簽名來決定。

不過,Rust的借用檢查器只進行檢查;它不會影響程式碼的行為。這是一個重要而有用的特性,並有很多原因。作為例子,一個從fn f(s: &str) -> &[u8](返回值借用引數)到fn f(s: &str) -> &'static [u8](返回值不借用引數)的改變在呼叫位置不會改變任何事情,例如臨時變數被丟棄的時間點。

所以,在唯二的兩個選項中,Rust選擇了選項1:在let語句結束時立即丟棄臨時變數。如果需要String存在更長時間,可以很容易地將String移到一個獨立的let語句內。

let s = String::from('🦀'); // 移動到了它自己的`let`內,以給它更長的生命週期
let a = f(&s);
…
g(&a);

在巢狀呼叫中

OK,另一個案例:

g(f(&String::from('🦀')));

還是有兩個選項:

  1. 這個字串在f呼叫後、g呼叫前被丟棄,或者,
  2. 這個字串在整個語句結束,也就是g呼叫後被丟棄。

這個程式碼片段和之前幾乎相同:一個臨時String的引用被傳遞給f,然後它的返回值被傳遞給g。不過這一次,透過巢狀呼叫表示式,一切都被放到一個語句裡了。

上面的推論依然是成立的:根據f的簽名,選項1可能會也可能不會正常工作;選項2可能會比實際需要持有臨時變數更長的時間。

不過,這一次,選項1可能會得到讓程式設計師更加意外的結果。舉個例子,哪怕是簡單如String::from('🦀').as_bytes().contains(&0x80)的程式碼也不能透過編譯,因為Stringas_bytesf)後、containsg)前就會被丟棄。

同樣可以爭論的是,讓這些臨時變數稍微存在得更久一點並沒有太大的壞處,因為在語句結束後它們還是會被丟棄。

所以,Rust選擇了選項2:不考慮f的簽名,String被保留到了語句結束,直到g被呼叫才被丟棄。

if語句中

現在讓我們移步一個簡單的if語句:

if f(&String::from('🦀')) {
    …
}

同樣的問題:String什麼時候被丟棄?

  1. if的條件被評估之後,if體被執行之前(也就是在{處)。或者,
  2. if體之後(也就是在}處。

在這個案例裡,沒有理由在if體內保留臨時變數存活。if的條件總是一個布林值(僅truefalse),根據定義它什麼都不會借用。

所以,Rust選擇了選項1。

這在使用Mutex::lock的例子裡十分有用。Mutex::lock返回一個臨時的MutexGuard變數,這個臨時變數會在其被丟棄時解鎖Mutex

fn example(m: &Mutex<String>) {
    if m.lock().unwrap().is_empty() {
        println!("the string is empty!");
    }
}

在這裡,來自m.lock().unwrap()的臨時變數MutexGuard.is_empty()後立即被丟棄,這使得Mutex不會在println期間被不必要地鎖住。

if let語句中

不過,對if let(和match)來說情況有所不同,因為此時我們的語句不需要被評估為布林值:

if let … = f(&String::from('🦀')) {
    …
}

還是有兩個選項:

  1. 這條字串在模式匹配後、if let體前(也就是在{處)被丟棄。或者,
  2. 這條字串在if let體後(也就是在}處)被丟棄。

這一次,有理由選擇選項2而不是1。對if letmatch分支裡的模式來說,發生借用再正常不過了。

所以,在這種情況下,Rust選擇了選項2。

舉個例子,如果我們有一個Mutex<Vec<T>>型別的變數vec,這段程式碼是可以正常編譯的:

if let Some(x) = vec.lock().unwrap().first() {
    // `Mutex`在這裡仍然被鎖著 :)
    // 這是有必要的,因為我們正在從`Vec`中借用`x`。(`x`是一個`&T`)
    println!("first item in vec: {x}");
}

我們從m.lock().unwrap()中獲取了一個臨時的MutexGuard,並使用first()方法來借用第一個元素。這個借用貫穿了整個if let體,因為MutexGuard直到最後的}才被丟棄。

不過,也會有非我們所願的情況出現。舉個例子,如果我們不使用返回引用的first,而是使用返回值的pop的話:

if let Some(x) = vec.lock().unwrap().pop() {
    // `Mutex`在這裡仍然被鎖著 :(
    // 這是不必要的,因為我們並沒有從`Vec`中借用任何東西。(`x`是一個`T`)
    println!("popped item from the vec: {x}");
}

這會是令人吃驚的,並引發不明顯的bug或降低效能。

也許這是Rust做了錯誤選擇的論據,又或者是未來版本的Rust做出改變的論據。關於這些規則可以被如何改變的想法,可以看看Niko關於這個主題的博文

就現在而言,變通的辦法是用一個獨立的let來將臨時變數的生命週期限制到一條語句以內:

let x = vec.lock().unwrap().pop(); // MutexGuard在這條語句後就被丟棄了
if let Some(x) = x {
    …
}

臨時變數生命週期延長

這種情況如何呢?

let a = &String::from('🦀');
…
f(&a);

兩個選項:

  1. 這條字串在let語句結束後就被丟棄。或者,
  2. 這條字串和a被同時,也就是在f呼叫後丟棄。

選項1總是會導致一個借用檢查的錯誤,所以選項2可能更正確一些。並且這就是Rust如今所做的:臨時變數的生命週期被擴充套件了,以使得上面的程式碼片段可以正常編譯。

臨時變數生存得比它出現的語句更久了,這個現象叫作臨時變數生命週期延長

臨時變數生命週期延長並不會應用到所有出現在let的語句中的臨時變數上,正如我們已經見到的:let a = f(&String::from('🦀'));中的臨時字串並不會延伸到let語句之外。

let a = &f(&String::from('🦀'));(注意多出來的&),臨時變數生命週期延長確實應用到了最外層的&上,它借用了f返回的臨時變數;但擴充套件沒有應用到內層的、借用了臨時String&上。

舉個例子,用str::len代替f

let a: &usize = &String::from('a').len();

在這裡,這個字串在let語句結束後就被丟棄了,但來自.len()&usize生存得和a一樣久。

這並不侷限於let _ = &…;語法。舉例來說:

let a = Person {
    name: &String::from('🦀'), // 擴充套件了!
    address: &String::from('🦀'), // 擴充套件了!
};

在上面的這段程式碼中,臨時字串的生命週期被擴充套件了,因為哪怕我們對Person型別一無所知,我們也能確定為了繼續使用這個物件,需要擴充套件它們的生命週期。

有關let語句中哪些臨時變數的宣告週期會被擴充套件的規則在Rust的參考文件中有說明,但實際上可以歸結為那些你從語法上就能看出來有必要延長生命週期的表示式,這與任何型別、函式簽名或特質實現無關:

let a = &temporary().field; // 擴充套件了!
let a = MyStruct { field: &temporary() }; // 擴充套件了!
let a = &MyStruct { field: &temporary() }; // 都擴充套件了!
let a = [&temporary()]; // 擴充套件了!
let a = { …; &temporary() }; // 擴充套件了!

let a = f(&temporary()); // 沒有擴充套件,因為可能沒有必要 
let a = temporary().f(); // 沒有擴充套件,因為可能沒有必要 
let a = temporary() + temporary(); // 沒有擴充套件,因為可能沒有必要 

儘管這看起來很合理,但當我們考慮到構建元組結構或元組變數也是一個函式呼叫時,還是難免讓人感到意外:Some(123)是,語法上的,一個對Some函式的呼叫。

舉例來說:

let a = Some(&temporary()); // 沒有擴充套件!(因為 `Some` 可以擁有任何函式簽名……)
let a = Some { 0: &temporary() }; // 擴充套件了!(我賭你從來沒有用過這種語法)

並且這確實非常令人困惑。😦

這也是值得考慮重新修訂規則的原因之一。


常量提升

臨時變數生命週期延長很容易和另一個稱作常量提升的東西混淆起來,它是另一種讓臨時變數比預期生存得更久的另一種方式。

在類似&123&None的表示式裡,值被識別為一個常數(不具備內部可變性,也沒有解構函式),並因此被自動提升為永遠存活。這意味著這些引用將會擁有一個'static生命週期。

舉例來說:

let x = f(&3); // 這裡的&3是 'static 的,不論對 `f()` 來說是否有必要
···

這甚至應用到了簡單的表示式上:

```rust
let x = f(&(1 + 2)); // 這裡的&3是'static的

在臨時變數生命週期延長和常量提升都可以應用的情況下,後者一般會被優先採用,因為它將生命週期擴充套件得更遠一些:

let x = &1; // 常量提升,而不是臨時變數生命週期延長

這就是說,在上面的程式碼片段中,x是一個'static的引用。數值1生存得甚至比x本身還要久。


程式碼塊中的臨時變數生命週期延長

假設我們有一種Writer型別,它持有了需要寫入的File的引用:

pub struct Writer<'a> {
    pub file: &'a File
}

並且有一些程式碼,建立了一個寫入新建立檔案的Writer

println!("opening file...");
let filename = "hello.txt";
let file = File::create(filename).unwrap();
let writer = Writer { file: &file };

現在,作用域中含有filename, filewriter。不過,後面的程式碼只應該透過Writer來進行寫入。理想情況下,filename與(特別是)file在作用域內是不可見的。

因為臨時變數生命週期延長對程式碼塊的最終表示式也是有效的,我們可以像這樣達成目標:

let writer = {
    println!("opening file...");
    let filename = "hello.txt";
    Writer { file: &File::create(filename).unwrap() }
};

現在,Writer的建立被整潔地包裝在了它自己的作用域中,除了writer之外沒有什麼可以被外部作用域看到。得益於臨時變數生命週期提升,被內部作用域建立為臨時變數的File可以和writer生存得一樣久。

臨時變數生命週期延長的侷限

現在假設我們將Writerfile欄位改為了私有

pub struct Writer<'a> {
    file: &'a File
}

impl<'a> Writer<'a> {
    pub fn new(file: &'a File) -> Self {
        Self { file }
    }
}

然後我們不需要過多修改原來的程式碼:

println!("opening file...");
let filename = "hello.txt";
let file = File::create(filename).unwrap();
let writer = Writer::new(&file); // 只有這行變動了

我們只需要呼叫Writer::new()而不是使用Writer {}語法來進行構造。

不過,使用了作用域的版本就行不通了:

let writer = {
    println!("opening file...");
    let filename = "hello.txt";
    Writer::new(&File::create(filename).unwrap()) // 錯誤:生存得不夠久!
};

writer.something(); // 錯誤:在這裡File已經沒有生存了

正如我們之前見到的,儘管臨時變數生命週期延長透過Writer {}構造語法傳播,但它不會透過Writer::new()函式呼叫語法傳播。(因為函式簽名可以是fn new(&File) -> Self<'static>,也可以是fn new(&File) -> i32,這些例子不需要延長臨時變數的生命週期。)

不幸的是,現在沒有顯式指定延長臨時變數生命週期的方式。我們不得不在最外層作用域放置一個let file。我們現在能做到的最好,就是採用延遲初始化

let file;
let writer = {
    println!("opening file...");
    let filename = "hello.txt";
    file = File::create(filename).unwrap();
    Writer::new(&file)
};

但那又把file帶回到作用域裡了,這是我們剛剛還在試圖避免的。😦

儘管把let file放在作用域外部是不是一個大問題還有待商榷,這種變通方式對大多數Rust程式設計師來說都不夠清晰。延遲初始化並不是一項常用的特性,並且編譯器目前在給出臨時變數的生命週期錯誤時也不會推薦這種變通方式。

在一定程度上,如果能修復這個問題就好了。

如果有一個既能建立檔案,又能返回它的Writer的函式的話,可能會很有用。比如:

let writer = Writer::new_file("hello.txt");

但是,因為Writer只是借用File,這將要求new_fileFile儲存在某個地方。它可以將File洩露出去或以某種方式將其儲存在static中,但是(當前)它還是不能讓File活得和返回的Writer一樣久。

所以,不如讓我們用宏來在呼叫的地方同時定義檔案和writer:

macro_rules! let_writer_to_file {
    ($writer:ident, $filename:expr) => {
        let file = std::fs::File::create($filename).unwrap();
        let $writer = Writer::new(&file);
    };
}

使用起來大概像這樣:

let_writer_to_file!(writer, "hello.txt");

writer.something();

得益於宏衛生file在這個作用域內是不可見的。

這樣做已經可行了,但如果它看起來更像一個普通的函式呼叫,就像下面一樣,不是更好嗎?

let writer = writer_to_file!("hello.txt");

writer.something();

正如我們之前見到過的,在let writer = ...;語句內部建立活得足夠久的臨時變數File的方法,是使用臨時變數生命週期延長:

macro_rules! writer_to_file {
    ($filename:expr) => {
        Writer { file: &File::create($filename).unwrap() }
    };
}

let writer = writer_to_file!("hello.txt");

這將會擴充套件為:

let writer = Writer { file: &File::create("hello.txt").unwrap() };

這段程式碼將會按需延長File臨時變數的生命週期。

如果file欄位不是公開的,我們就不能簡單地這樣做了,而是應當使用Writer::new。這個宏將會需要在呼叫它的let writer = ...插入let file;。這是不可能做到的。

format_args!()

這個問題也是(當今的)format_args!()的結果不能被儲存到一個let表示式中的原因:

let f = format_args!("{}", 1); // Error!
something.write_fmt(f);

原因是format_args!()擴充套件到了一些類似fmt::Arguments::new(&Argument::display(&arg), …)的程式碼,其中的部分引數是對臨時變數的引用。

臨時變數生命週期延長並不會對函式呼叫中的引數生效,因此fmt::Arguments物件的使用只能被限制在同一條語句中。

如果能修復這個問題那就太好了。

pin!()

另一個經常被透過宏來建立的型別是Pin。大致來說,它持有一個永遠不會被移動的值的引用。(確切的詳情很複雜,但不是非常和現在的主題相關。)

它被透過一個名為Pin::new_uncheckedunsafe函式建立,因為你需要保證哪怕Pin本身都不存在了,它引用的值也不會被移動。

使用這個函式的最好方式,是利用遮蔽機制:

let mut thing = Thing { … };
let thing = unsafe { Pin::new_unchecked(&mut thing) };

因為第二個thing遮蔽了第一個,第一個thing(仍然存在)就不能被按名訪問了。既然它不再能被按名訪問,我們就可以確認它不會被移動了(哪怕第二個thing被丟棄了也是),這正是我們向unsafe程式碼塊所保證的。

因為這是一種常見模式,這種模式通常在宏中捕獲。

舉例來說,人們可能會這樣定義一個let_pin宏:

macro_rules! let_pin {
    ($name:ident, $init:expr) => {
        let mut $name = $init;
        let $name = unsafe { Pin::new_unchecked(&mut $name) };
    };
}

使用方式看起來和之前我們的let_writer_to_file宏類似:

let_pin!(thing, Thing { … });

thing.something();

這是可以正常工作的,並且很好地壓縮和隱藏了不安全的程式碼。

但是,就像我們之前的Writer例子一樣,如果它能像下面這樣工作的話難道不會好很多嗎?

let thing = pin!(Thing { … });

我們早已知道,只有我們能利用臨時變數延長機制讓Thing生存得足夠久,我們才有可能實現這個目標。並且這也僅僅在我們能用Pin {}語法構建Pin的時候才有可能做到:Pin { pinned: &mut Thing { ... } }可以使用臨時變數生命週期延長,但Pin::new_unchecked(&mut Thing { ... })不行。

那甚至意味著要把Pin的欄位公開,而這違背了Pin的設計意圖。僅當欄位私有時,它才能提供有意義的保證。

這就意味著,很不幸地,你(如今)還不可能自己寫出這樣的pin!()宏。

但標準庫還是這樣幹了,它犯下了可怕的罪行👿:Pin的“私有欄位”其實是被定義為pub的,但也被標記成了“unstable”以使得在你嘗試使用它時編譯器能發出警告。

如果不用這樣hack的話就太好了。

super let

我們現在已經見過幾個被臨時變數生存週期延長的限制性規則約束的案例了:

  • 我們讓let writer = { ... };良好地保持作用域的失敗嘗試,
  • 我們讓let writer = writer_to_file!(…);工作的失敗嘗試,
  • 對執行let f = format_args!(…);的無能為力,以及
  • 為了讓pin!()工作所做的糟糕hack。

如果我們能顯式選擇去延長變數的生命週期的話,上面的這些問題都能各自得到很棒的解決方案。

如果我們能發明一種特殊的let語句,讓其中涉及到的變數生存得比常規的let語句更久一些,會怎麼樣呢?就像超能力一樣(或者把變數定義在“super”作用域的let)?把它叫作super let怎麼樣?

在我的想象中,它會像這樣工作:

let writer = {
    println!("opening file...");
    let filename = "hello.txt";
    super let file = File::create(filename).unwrap();
    Writer::new(&file)
};

super關鍵字將會讓file的生命週期和writer一樣長,和周圍程式碼塊產生的Writer一樣長。

super let工作的具體規則還需要繼續研究,但主要目標是它允許對臨時變數生命週期延長的“解語法糖”:

  • let a = &temporary();let a = { super let t = temporary(); &t };應該是等價的。

這個特性使得在不使用任何hack的前提下定義pin!()宏成為可能:

macro_rules! pin {
    ($init:expr) => {
        {
            super let pinned = $init;
            unsafe { Pin::new_unchecked(&pinned) }
        }
    };
}

let thing = pin!(Thing { … });

類似地,這樣的新設計也會允許format_args!()宏為其中的臨時變數使用super let,以使得宏的結果可以被作為let a = format_args!()語句的一部分被儲存。

使用者體驗和診斷資訊

同時存在letsuper let兩種僅有細微語義差異的語法,聽起來也許並不是很棒。它解決了一些問題,尤其是和宏相關的,但它真的值得在釐清letsuper let的差異時給人帶來的潛在困擾嗎?

我覺得是的,只要我們確保編譯器能夠在可能時提出建議,讓使用者新增或刪除let中的super

想象一下:有人寫了這樣的程式碼:

let output: Option<&mut dyn Write> = if verbose {
    let mut file = std::fs::File::create("log")?;
    Some(&mut file)
} else {
    None
};

在今天,它會產生這樣的錯誤:

error[E0597]: `file` does not live long enough
  --> src/main.rs:16:14
   |
14 |     let output: Option<&mut dyn Write> = if verbose {
   |         ------ borrow later stored here
15 |         let mut file = std::fs::File::create("log")?;
   |             -------- binding `file` declared here
16 |         Some(&mut file)
   |              ^^^^^^^^^ borrowed value does not live long enough
17 |     } else {
   |     - `file` dropped here while still borrowed

儘管問題相對清晰,但這並沒有實際地給出一個解決方案。我經常遇到帶著類似例子來向我求助的Rust程式設計師,結果是我為他們解釋延遲初始化的模式,並給出這樣的解決方案:

let mut file;
let output: Option<&mut dyn Write> = if verbose {
    file = std::fs::File::create("log")?;
    Some(&mut file)
} else {
    None
};

對很多Rust程式設計師來說,這個解決方案不是非常清晰,也許是因為讓file在一個分支下保持未初始化太奇怪了。

相反,如果錯誤資訊變成這樣的話,會不會感覺它變得更好了呢?

error[E0597]: `file` does not live long enough
  --> src/main.rs:16:14
   |
15 |         let mut file = std::fs::File::create("log")?;
   |             --------
   |
help: try using `super let`
   |
15 |         super let mut file = std::fs::File::create("log")?;
   |         +++++

即便對“super let”或它的語義瞭解不多,程式設計師們也能獲得一個清晰和簡單的、解決他們的問題的方案,並讓他們學到super會讓變數生存得更久一些。

類似的,當不必要地使用了super let,編譯器應該建議刪掉它:

warning: unnecessary use of `super let`
  --> src/main.rs:16:14
   |
15 |         super let mut file = std::fs::File::create("log")?;
   |         ^^^^^ help: remove this
   |
   = note: `file` would live long enough with a regular `let`

我相信這些診斷資訊會讓super let對全體Rust程式設計師有益,哪怕他們此前從未見過這個特性。

加上pinformat_args宏中人體工程學特性的增強,我認為super let在使用者(程式設計師)的體驗上取得了全勝。


潛在的擴充套件

super let可以出現在函式作用域中是一項未來潛在的擴充套件。也就是說,此時的“super”指的是函式的呼叫者。

正如@lorepozo@tech.lgbt在Mastodon上提到的,那將會允許pin!()成為一個函式,而不是一個宏。類似的,它也會使得Writer::new_file(…)在不使用宏的前提下可以被實現。

讓這得以有效工作的方式是,允許特定函式將物件放入呼叫者的棧幀中,然後稍後可以從返回值中引用這些物件。這在所有常規的舊函式中都不能執行;正常情況下,呼叫者不會給被呼叫的函式預留放入物件的空間。這需要成為函式簽名的一部分。

也許像這樣?

pub placing fn new_file(filename: &str) -> Writer {
    super let mut file = File::create(filename).unwrap(); // 放入了呼叫者的棧幀
    Writer::new(&file) // 所以我們就可以在返回值內借用它了!
}

這不是我現在提出的建議的一部分,但想象也很有趣。😃


臨時變數生命週期2024的RFC

我和Niko MatsakisDing Xiang Fei在幾個月前分享了我對super let的想法,他們對臨時變數生命週期延長的“解語法糖”感到很激動。他們已經在努力確定super let的定義和具體的規則,以及下一版Rust的臨時變數生命週期的一些新規則。

這個組合起來的“臨時變數生命週期2024”的努力正在為一項RFC作鋪墊。該RFC基本上提議在可能的情況下減少臨時變數生命週期,以防止在if letmatch中由臨時變數MutexGuard造成的死鎖,並加入super let作為選擇延長生命週期的一種方式。

相關文章