編者註:上周 Armin 在自己的博客上首次發布了這個版本。如果你想再次閱讀這篇文章,或者想看看 Armin 還在做什麼,一定要去看看。

去年一直很有趣,因為我們用 Rust 建造了很多好東西,並且這是第一次在開發體驗上沒有更大的障礙。雖然過去的一年我們一直使用 Rust,但是現在感覺不同了,因為生態系統更加穩定,我們遇到的語言或者工具的問題也越來越少。

也就是說,與那些不熟悉 Rust 的人交談——甚至與同事集體討論 API——很難擺脫Rust可以成為令人心曠神怡的冒險的感覺。這就是為什麼擁有無壓力體驗的最佳方式是,提前了解你不能(或不應該嘗試)做的事情。 知道某些事情無法完成有助於讓你的思想回到正確的軌道上。

所以根據我們的經驗,這裡有一些在 Rust 中不能做的事情,以及應該做什麼,我認為應該更好地了解。

Things move

Rust 和 C++(至少對我來說)最大的區別在於 address-of操作符(&)。在 C++(類似於C)中,它只返回應用於它的地址,儘管該語言可能會對你施加一些限制(無論何時這樣做是一個好主意),但是通常沒有什麼可以阻止你獲取一個值地址並直接使用它。

但這在 Rust 中通常是沒有用的。首先,你在 Rust 取得一個引用時,借用檢查器就會檢查你的代碼,阻止你做任何愚蠢的事情。然而,更重要的是,即使安全取得一個引用,它也不像你想像的那麼有用。 原因是Rust中的對象通常會四處移動。

Rust 中通常以這種方式構造對象:

struct Point {
x: u32,
y: u32,
}
impl Point {
fn new(x: u32, y: u32) -> Point {
Point { x, y }
}
}

這裡的 new 方法(沒有獲得 self)是一個靜態方法實現,它還返回 Pont 的值。這是值通常的構造方式。因此,在函數中獲取引用並沒有做任何有用的事情,因為該值可能在調用時被移動到新的位置。這與 C++ 的工作原理是非常不同的:

struct Point {
uint32_t x;
uint32_t y;
};
Point::Point(uint32_t x, uint32_t y) {
this->x = x;
this->y = y;
}

C++ 中的構造函數在已經在分配的內存上運行。 在構造函數運行之前,this 指針就已經提供了內存(通常是堆棧中的某個位置或堆上的new運算符)。 這意味著 C++ 代碼通常可以假設實例不會移動。 作為結果,C++ 代碼使用 this 指針實際上是非常愚蠢的事情(比如將其存儲在另一個對象中)。

這種差別聽起來可能很小,但它是一個對編程人員來說會產生巨大的影響的基本因素。特別是,這是你不能使用自引用結構的原因之一。雖然一直有關於不能在Rust 中移動的類型的討論,但目前還沒有合理的解決方案 (未來方向是 the pinning system from RFC 2349)。譯者註:Pin 在 1.33 已穩定。

那麼我們目前該怎麼做的? 這取決於具體情況,但通常答案是用某種形式的句柄替換指針。 不是僅僅在結構中存儲絕對指針,而是將偏移存儲到某個參考值。 稍後如果需要指針,則按需計算。

例如,我們使用這樣的模式來處理內存映射數據:

use std::{marker, mem::{transmute, size_of}, slice, borrow::Cow};
#[repr(C)]
struct Slice<T> {
offset: u32,
len: u32,
phantom: marker::PhantomData<T>,
}
#[repr(C)]
struct Header {
targets: Slice<u32>,
}
pub struct Data<a> {
bytes: Cow<a, [u8]>,
}
impl<a> Data<a> {
pub fn new<B: Into<Cow<a, [u8]>>>(bytes: B) -> Data<a> {
Data { bytes: bytes.into() }
}
pub fn get_target(&self, idx: usize) -> u32 {
self.load_slice(&self.header().targets)[idx]
}
fn bytes(&self, start: usize, len: usize) -> *const u8 {
self.bytes[start..start + len].as_ptr()
}
fn header(&self) -> &Header {
unsafe { transmute(self.bytes(0, size_of::<Header>())) }
}
fn load_slice<T>(&self, s: &Slice<T>) -> &[T] {
let size = size_of::<T>() * s.len as usize;
let bytes = self.bytes(s.offset as usize, size);
unsafe { slice::from_raw_parts(bytes as *const T, s.len as usize) }
}
}

在這種情況下,Data<a>僅保存對後備位元組存儲(擁有的 Vec<u8> 或借用的 &[u8] 切片)的寫時複製引用。 位元組切片以 Header 中的位元組開頭,並在調用header() 時按需解析。 同樣地,通過調用load_slice() 來解析單個切片,該調用獲取存儲的切片,然後通過按需補償來查找它。

所以,總結一下: 與其存儲指向對象本身的指針,不如存儲一些信息,以便稍後計算指針。這也通常稱為使用「句柄」。

Refcounts are not dirty

另一個非常有趣的例子——非常容易遇到——也與借用檢查器有關。借用檢查器不允許你用自己不擁有的數據做愚蠢的事情,有時你會覺得自己好像撞上了一堵牆,因為你認為自己知道得更多。然而,在許多情況下,答案僅僅是一個 Rc<T>

為了使這一點不那麼神秘,讓我們來看看下面的 C++ 代碼:

thread_local struct {
bool debug_mode;
} current_config;
int main() {
current_config.debug_mode = true;
if (current_config.debug_mode) {
// do something
}
}

這看起來很簡單,但有一個問題:沒有什麼能阻止你從 curren_config 中借用一個欄位,然後將其傳遞到其他地方。這就是為什麼在 Rust 中,與之直接對應的代碼看起來要複雜得多:

#[derive(Default)]
struct Config {
pub debug_mode: bool,
}
thread_local! {
static CURRENT_CONFIG: Config = Default::default();
}
fn main() {
CURRENT_CONFIG.with(|config| {
// here we can *immutably* work with config
if config.debug_mode {
// do something
}
});
}

很明顯,這個 API 並不有趣。首先,config 是不可變的。其次,我們只能訪問傳遞給with 閉包中的 config 對象。任何試圖從這個 config 對象中借用並使其在閉包之後仍然存在的嘗試都將失敗(可能出現「無法推斷適當的生存期」之類的情況)。別無他法!

這個API在客觀上是不好的。假設我們想要查找更多的線程局部變數。讓我們分別來看這兩個問題。如上所述,引用計數通常是一個很好的解決方案,用於處理這裡的基本問題: 不清楚所有者是誰。

讓我們想像一下,這個 config 對象恰好綁定到當前線程,但實際上並不屬於它。如果 config 被傳遞給另一個線程,但是當前線程關閉了,會發生什麼?這是一個典型的例子,config 可以有多個所有者。因為我們可能想要從一個線程傳遞到另一個線程,所以我們需要一個原子引用計數的包裝器:一個Arc。這允許我們增加with 塊中的引用計數並返回它。重構後的版本是這樣的:

use std::sync::Arc;
#[derive(Default)]
struct Config {
pub debug_mode: bool,
}
impl Config {
pub fn current() -> Arc<Config> {
CURRENT_CONFIG.with(|c| c.clone())
}
}
thread_local! {
static CURRENT_CONFIG: Arc<Config> = Arc::new(Default::default());
}
fn main() {
let config = Config::current();
// here we can *immutably* work with config
if config.debug_mode {
// do something
}
}

這裡的變化是,現在線程本地持有一個引用計數的 config。因此,我們可以引入一個返回 Arc<Config> 的函數。在 TLS 的閉包中,我們使用 Arc<Config> 上的clone() 方法增加引用計數並返回它。現在,任何調用 Config::current 的調用者都可以獲得那個引用計數後的 config,並且可以根據需要一直持有它。只要有代碼保存 Arc,其中的 config 就會保持活動狀態。即使原始線程死亡。

但是我們如何讓它像 C++ 版本那樣可變呢?我們需要一些能提供內部可變性的東西。對此有兩種選擇。一種是將 config 封裝在 RwLock 之類的東西中。第二種方法是讓 config 在內部使用鎖定。

例如,可能需要這樣做:

use std::sync::{Arc, RwLock};
#[derive(Default)]
struct ConfigInner {
debug_mode: bool,
}
struct Config {
inner: RwLock<ConfigInner>,
}
impl Config {
pub fn new() -> Arc<Config> {
Arc::new(Config { inner: RwLock::new(Default::default()) })
}
pub fn current() -> Arc<Config> {
CURRENT_CONFIG.with(|c| c.clone())
}
pub fn debug_mode(&self) -> bool {
self.inner.read().unwrap().debug_mode
}
pub fn set_debug_mode(&self, value: bool) {
self.inner.write().unwrap().debug_mode = value;
}
}
thread_local! {
static CURRENT_CONFIG: Arc<Config> = Config::new();
}
fn main() {
let config = Config::current();
config.set_debug_mode(true);
if config.debug_mode() {
// do something
}
}

如果你不需要用這個類型處理多線程,你可以用 Arc 替換 Rc ,用 RwLock 替換 RefCell

總結一下:當你需要借用的數據超過了需要引用的數據的生命周期時。不要害怕使用 Arc,但要注意這將你的數據鎖定到不可變。結合內部可變性(如 RwLock)使對象可變。

Kill all setters

但是上面使用 Arc<RwLock<Config>> 模式可能會有點問題,將其替換為 RwLock<Arc<Config>> 會更好。

Rust 做得好的是一種解放的體驗,因為如果你做得好,在事後並行化你的代碼是非常容易的。Rust 鼓勵使用不可變的數據,這使得一切都變得更加容易。

然而,在前面的例子中,我們引入了內部可變性。假設我們有多個線程在運行,所有線程都引用相同的 config,但其中一個線程拋出一個標誌。如果並發運行的代碼現在不期望標誌隨機翻轉,會發生什麼情況?因此,應該謹慎使用內部可變性。理想情況下,對象一旦創建就不會以這種方式更改其狀態。我認為這種類型的 setter通常應該是反模式的。

我們不這樣做,而是回到之前的情況,config 不是可變的?如果在創建 config 之後,我們沒有修改它,而是添加一個API來將另一個 config 提升到 current,結果會怎樣呢?這意味著任何當前持有 config 的人都可以安全地知道這些值不會更改。

use std::sync::{Arc, RwLock};
#[derive(Default)]
struct Config {
pub debug_mode: bool,
}
impl Config {
pub fn current() -> Arc<Config> {
CURRENT_CONFIG.with(|c| c.read().unwrap().clone())
}
pub fn make_current(self) {
CURRENT_CONFIG.with(|c| *c.write().unwrap() = Arc::new(self))
}
}
thread_local! {
static CURRENT_CONFIG: RwLock<Arc<Config>> = RwLock::new(Default::default());
}
fn main() {
Config { debug_mode: true }.make_current();
if Config::current().debug_mode {
// do something
}
}

默認情況下,config 仍然是自動初始化的,但是可以通過構造 config 對象並調用make_current 來設置新的 config。這將把 config 移動到一個 Arc,然後將其綁定到當前線程。調用 current() 的人將會得到那個 Arc,然後可以再做他們想做的任何事情。

同樣,如果不需要在多線程中使用 Arc,也可以用 Rc 替換 Arc,用 RefCell替換 RwLock。如果只使用線程局部變數,還可以將 RefCellArc 組合在一起。

總結一下:不要使用改變對象內部狀態的內部可變性,而是考慮使用一種模式,在這種模式中,你將新狀態提升為當前狀態,並且通過將 Arc 放入 RwLock,舊狀態的當前使用者將繼續持有它。

結論

老實說,我希望我能早一點學會以上三件事。主要是因為即使你知道這些模式,你也不一定知道何時使用它們。所以我想下面的咒語,就是我想列印出來掛在某處的咒語:

  • 句柄,而不是自引用指針
  • 引用計數的方法走出生命周期/借用檢查的地獄
  • 考慮提升新的狀態而不是內部的可變性

本文翻譯自 What Not to Do in Rust


推薦閱讀:
相关文章