探索 Rust 第二個最複雜的特性
在這篇文章中,我們來探索 Rust 第二個最複雜的特性:高階 Trait 邊界。它是一個非常重要的特性,理解它有兩個原因:
-
首先,你可能會在流行的 rust 庫 (如 Tokio 或 Axum) 的源代碼中遇到它。
-
其次,它將從根本上改變你對泛型、Trait 和生命週期在 Rust 中協同工作的理解,掌握最大靈活性的高級泛型編程技術。
高階 Trait 邊界難以理解的部分原因是該特性的文檔非常稀少。所以在這篇文章中,我將解釋什麼是高階 Trait 邊界,以及如何在高級泛型代碼中使用它們來創建非常靈活的 api。
爲了理解什麼是高階 Trait 邊界,我們必須簡單地介紹一下 Trait、Trait 邊界和生命週期註釋。Trait 類似於其他語言中的接口,它允許我們以一種抽象的方式通過函數和方法定義共享行爲。另一方面,Trait 邊界允許我們將 Trait 和泛型結合起來。
這段代碼給了我們一個編譯時錯誤,說泛型 T 沒有實現 Debug Trait。問題是我們的函數接受任何泛型類型,這些泛型類型有可能可以被 debug 格式打印,也有可能不可以被打印。解決方案是使用 Trait 邊界來限制可能的具體類型。
這個泛型將只接受那些實現了 Debug trait 的類型,換句話說,這個泛型被 Debug trait 約束。
重要的是我們還可以使用 where 子句或 impl 語法指定 Trait 邊界:
在後面的高階 Trait 邊界例子中我們會看到高階 Trait 邊界在 Rust 中使用了兩種不同類型的泛型。到目前爲止,我們已經看到了類型泛型,它允許我們對具體類型進行抽象。
Rust 還提供了另一種類型的泛型:生命週期註釋,這與類型泛型不同,它允許我們編寫可以處理多個具體類型的泛型代碼。生命週期註釋主要用於表達引用的生命週期之間的關係,這有助於編譯器確保引用在使用時仍然有效。
假設我們有一個函數,它接受字符串切片作爲輸入,並返回切片中的第一個單詞:
編譯器實際上將函數簽名擴展了一個標記'a,這是一個通用的生命週期註釋:
它創建了輸入引用的生命週期和返回引用的生命週期之間的關係,這種關係表明,這個函數返回的引用必須至少在輸入引用有效的時候有效,這有助於編譯器檢查無效的引用。
如果使返回引用的生命週期長於輸入引用的生命週期,編譯器將拋出錯誤。
在這個例子中,my_string 將在內部作用域結束時被釋放,因此之後使用返回的引用將是無效的。Rust 編譯器可以防止這種內存安全錯誤,幸運的是,在大多數情況下,我們甚至不需要顯式地編寫泛型生命週期註釋,因爲 rust 編譯器足夠聰明,可以推斷出引用的生命週期。
現在我們已經理解了 Trait 邊界和泛型生命週期註釋,讓我們討論一下更高級別的 Trait 邊界。到目前爲止,我們已經分別使用了 Trait 邊界和生命週期泛型,高階 Trait 邊界把這些概念結合起來,它允許我們指定一個 Trait 邊界在所有可能的生命週期內都成立,這在表達複雜的生命週期關係時非常有用。
這裏我們有一個名爲 Formatter 的 Trait,它定義了一個函數,該函數接受任何實現 Display Trait 的類型,並返回一個格式化的字符串。
我們在 SimpleFormatter 結構體上實現這個特性,然後創建一個函數,該函數接受 formatter 並返回一個閉包,該閉包使用該 formatter 格式化給定的字符串。注意,爲了返回閉包,我們使用了特殊的 Fn trait 來定義閉包簽名。
在 main 中,我們創建這個閉包,並使用字符串切片和堆分配的字符串調用它。如果我們回頭看看 apply_format 函數,會注意到返回閉包接受一個引用。
編譯器會推斷出這個引用的生命週期,但是如果我們要顯式地編寫它會是什麼樣子呢?可以直觀地這樣寫:在函數 apply_format 上定義泛型生命週期,並將泛型生命週期分配給引用:
然而,這段代碼給了我們一個編譯時錯誤,說借用的值可能存在的時間不夠長
要理解這個錯誤,我們需要分解 apply_format 的函數簽名,'a 被聲明爲函數的通用生命週期參數,但它沒有在輸入參數中使用而是隻出現在返回類型中。那麼'a 在這裏創建了什麼關係呢?
在這種情況下,'a 在返回閉包接受的輸入引用的生命週期和閉包本身的生命週期之間建立了關係。這種關係確保了通過 apply_format 返回的閉包只能被至少與閉包本身一樣長的引用調用。
回到 main,我們可以看到閉包被存儲在 format_fn 變量中,該變量一直存在到 main 作用域結束。在 Rust 中,按定義的相反順序刪除變量,這意味着字符串 s2 將在 format_fn 中存儲的閉包之前被刪除。
一旦 s2 被刪除,所有對 s2 的引用都將失效,因爲這些引用指向的內存已釋放。所以所有指向字符串 s2 的引用的生命週期將在 s2 被刪除時結束,問題是,存儲在 format 函數中的閉包期望傳遞給它的所有引用與閉包本身一樣長。
解決此錯誤的一種方法是在創建 s2 之後定義 format_fn 變量,以便在 s2 之前刪除 format_fn 變量。
但是,這並不能解決該閉包的所有問題。例如,如果我們創建了一個內部作用域,並調用閉包時引用了該內部作用域中定義的字符串。
我們會得到一個類似的編譯時錯誤,說借用的值存在的時間不夠長。在這種情況下,s3 在內部作用域的末尾被刪除,這是不允許的,因爲對 s3 的引用在閉包被刪除之前是無效的。
現在這段代碼在技術上是內存安全的,但是它不完全滿足我們代碼最初設置的生命週期限制,因此,編譯時錯誤的核心問題是我們最初設置的生命週期約束過於嚴格。原因在於我們定義泛型生命週期註釋的方式,傳遞給閉包的引用的生命週期與閉包的生命週期綁定在一起,我們真正想要的是閉包能夠處理任何生命週期的引用。
我們可以通過高階 Trait 邊界來實現這一點,而不是在函數上定義生命週期泛型,
我們使用了 for<'a> 語法,這意味着返回的閉包可以使用任何生命週期的字符串切片,只要該生命週期至少覆蓋閉包執行的持續時間,這個簡單的修改使我們的代碼可以編譯。
就像生命週期泛型一樣,在許多情況下,沒有必要顯式地編寫高階 Trait 邊界,因爲編譯器足夠聰明,可以推斷出它們,但在某些情況下,還是需要顯式地將它們寫出來,這就是爲什麼在查看處理複雜泛型抽象的 rust crate 時可能會遇到它們的原因。
現在,當你遇到這種語法時,你就會知道它的意思,以及爲什麼要使用它。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/sTe3dG1ppmuNtVKvSc7AAQ