Cowsay:用Rust寫腳本語言(零):目錄?

zhuanlan.zhihu.com
圖標

whoiscc/shattuck?

github.com
圖標

一個月時間裡,我已經在Shattuck這個項目上完成了近12k的增刪。

任憑我想像力再豐富,我也猜不到為了完成一個正確的運行時,我會經歷如此之多的艱難險阻。如果可以回到過去,我最想做的事情就是對著那個剛寫完《作用域與棧幀》的自己一頓毒打

畜生!你寫了甚麼!

我也曾寫了不少代碼。一次又一次地嘗試與C++和平共處,又一次又一次地被傷透了心;還有用Cython實現的大規模真·多線程數據處理業務,半Python半C++真的讓人經常懷疑自己已經神志不清了;至於在已經被時代忘記的CoffeeScript反覆嘗試基於各種框架的2048之類的(框架自然也是自己寫的)(有多少人考慮過在兩次滑動的動畫如何正確地重疊呢),用ES6,Python等等企圖實現從後到前各個層面上的靜態站生成器之類的,相比之下都「軟」到沒眼看了。但是回過頭來,Rust纔是帶給我最多感悟和好習慣的那門語言。

今天,我想在這裡簡單地總結三點,使用Rust給我帶來的獨有的體驗。我需要稍微轉變一下腦筋,比如像學Rust這樣認真地重新學一下Haskell。

當我們到達現場時,就只剩下一地的$.了……


第一,對於與生命週期相關的報錯,多考慮出錯的本質。

相信對於絕大多數的Rust初學者來說,「三段論」式的報錯都是絕對的噩夢。

首先呢,它的生命週期不能超出……但是呢,它的生命週期至少有……只有這樣才能滿足……所以,無法為它推導出一個合適的生命週期。

對於寫慣了C的人來說,這類編譯錯誤是完全陌生的。這種陌生不僅僅是來自於對生命週期概念的懵懂,而且是因為這和C語言的編譯錯誤完全是兩類不同的錯誤。

C語言的編譯錯誤,不說全部,幾乎都是「局部性」的錯誤。這個局部不是作用域意義上的局部,而是代碼組織上的。通常來說,一個報錯可以通過修改被編譯器圈出來的那部分代碼來修復,而不需要考慮程序中的任何其他部分。是的,如果被圈出來的是個函數,也許修改了它的簽名會導致調用函數的代碼不再正確。但是編譯器會在這個錯誤被修正以後繼續報新的錯誤,把受到牽連的代碼也圈起來。我們只是預測了編譯器的行為,就算不去預測,「你說什麼我改什麼」同樣可以順利完成工作。

但是Rust中,尤其是與生命週期相關的錯誤,不具有「局部性」。如果只關注被圈起來的代碼,那麼很快就會陷入死循環:可能的寫法就那麼幾種,而每一種編譯器都不滿意。再加上Rust編譯器的報錯是分批次的:所有的名字錯誤都解決了才報與可變性相關的錯誤,一直到其他類型的錯誤全都解決完了才報生命週期相關的錯誤,本來就已經筋疲力盡的用戶再面對這樣的死循環會感到加倍的絕望。

事實上,解決Rust的編譯錯誤就是需要一定的「見微知著」的能力。解決這樣的錯誤經常需要對大批報錯範圍以外的代碼進行調整,至於對哪裡進行怎樣的調整編譯器不會給予任何提示。這是一種Rust獨有的編譯錯誤,它的出現經常不是說「你的寫法和你的想法不一樣/我不能通過你的寫法讀懂你的想法」,而是「你的想法本身有問題」。目前還沒有哪種編譯器能準確地報告「你的想法哪裡有問題」,Rust比C++最大的進步就在於,它可以將想法有問題的代碼攔截下來,讓用戶在編譯期就意識到自己的設計失誤,而不是運行期。雖然攔截的手法不甚精緻,但是我在寫Safe Rust期間的確一次都沒有用到調試器。

因此,在遇到這種「三段論」形式的報錯時,切不可如往常一般把注意力完全集中在那一點點細節上,反而要像真正的調試一樣,放寬視野,問問自己,這段代碼是做什麼的?每一個實例的所有者是誰?在哪裡創建,在哪裡轉交,在哪裡被銷毀?在C語言指針的世界裡,任何一個指針所指向的實例抓過來就可以用,返回走了就不管生死了,在Rust裏這是完全行不通的。在沒有徹底轉變思維定式之前,設計出的Rust結構類型經常會有一些生命週期相關的細小瑕疵,而這些瑕疵最終會在類型的方法實現甚至是調用時才引發編譯器的不滿。這時對報錯處的代碼再怎麼調整也不能解決問題,重新設計結構體纔是解決問題的關鍵。

當然,隨著經驗和教訓的積攢,設計符合Rust思想的模型的手法日益嫻熟,這樣的報錯會逐漸減少至一個正常的水平。這時,下面一點就開始出現了。


第二,Rust提供的一切抽象,都與硬體密不可分。

在逐漸熟悉所有權和生命週期系統以後,我們會慢慢感受到Rust冷酷面孔之下的熱情。其所提供的enumtrait等概念都可以讓一個原C程序員感受到家的溫暖。更進一步的,我們會逐漸接觸trait object,為我們的類型和函數添加具有複雜約束的類型參數。總而言之,我們的編譯思路會越來越腳本語言化。這時,Rust所提供的抽象就開始越來越束手束腳起來。

如果統計一下我在Rust編程羣裏的發言的話,估計超過一半都是「xxx為什麼不可以xxx?」

雖然Rust經常被拿來和Go對標,但是Rust是一門非常「精打細算」的語言,和Go的思路並不相同,對於此前非常熟悉Swift的我來說更是難以適應。在這方面Rust更接近C++,雖然沒有恪守零開銷抽象,但是在概念的完備性,和性能以及實現的複雜度之間的平衡上,Rust還是非常偏向後者的。

舉一個具體的例子。Rust的trait object在一定程度上可以看做是C++多態的替代品。但是由於Rust對實例生命週期的嚴格管理,一個&dyn Foo比起Foo &簡直不要太菜。如果說Rust所提供的這些看起來非常「腳本語言」的抽象真的可以像腳本語言一樣靈活使用的話,那也就不用我辛辛苦苦寫Shattuck了(。稍微誇張一點說,它們只是「很像」它們在腳本語言中的兄弟而已,實質上完全不同。就像CPU中具有硬體層面上非常完善的「異常處理」系統,甚至像Java一樣區分了可以恢復的錯誤(如缺頁)和不可恢復的異常(如除0),但是這只是浮於表面低得相似而已,完全不可以把CPU提供的錯誤處理當做腳本語言的錯誤處理來用。

Rust提供了這些抽象,正確的使用方法應該是,先遵循硬體模型設計正確的數據結構和演算法,然後通過使用這些抽象來簡化和整理向外界暴露的介面。 為了說明這一點,我盡量簡略地講述一下Shattuck中對象概念的演變歷史——說起這個真的極有可能由於過於激動導致篇幅失控……

最初Object是一個trait,對象實例被送入運行時之前由用戶將其打包為Box<dyn Object>。這時的Object的概念其實就和Java或Python裏一切對象的基類沒什麼區別,只是為了確保其大小編譯期可知所以才放入Box,這一點就預示了它的悲慘結局。

接下來我意識到我需要將其動態向下轉型。在Rust中「安全」地實現這一點的唯一途徑是Any。於是要變成Box<dyn Object + Any>了嗎?這種寫法並不合法,其代表的含義也超出了Rust所能實現的語義範圍。因此我只能將Any設置為Object的super trait,並且通過冗餘的as_any方法實現我想要的功能。到這裡,Rust提供抽象的侷限性逐漸體現出來。

(跳過若干。)最後,我需要將一個不能安全跨過線程的對象實例轉換為一個可以安全共享的對象實例。這需要用戶實現一個方法,喫掉原來的對象吐出一個新對象。這就點了原來這個模型的死穴:我只能拿到&dyn Object,沒辦法把對象的所有權交給用戶!一個符合腳本語言思想的模型,最終還是沒有在Rust中活下來。

後來,我花了很長時間,強迫自己思考一個問題:

如果現在是用C,我會怎麼設計這個概念?

得出的結論似乎很幼稚:

struct Object {
void *internal;
}

那麼,現在我希望每個對象能把它自己轉變為某個線程安全的類型SyncObject的實例

struct Object {
void *internal;
struct SyncObject *(*make_sync_f)(void *);
}

struct SyncObject *object_into_sync(struct Object *object) {
return object.make_sync_f(object.internal);
}

用Rust就能不這麼寫了嗎?不可能。

type MakeSyncFn = fn(Object) -> Result<SyncObject>;

pub struct Object {
content: Box<dyn Any>,
get_holdee_f: GetObjectHoldee,
make_sync_f: MakeSyncFn,
}

impl Object {
// explicit different name with ToSync::to_sync
pub fn into_sync(self) -> Result<SyncObject> {
(self.make_sync_f)(self)
}
}

(這裡用Box<dyn Any>而不是*mut Any代替void *,因為似乎不在堆上幾乎沒有意義。)

在設計核心的設計時,Rust提供的任何抽象都不可能幫助我們減少實際存在的複雜度,該有的東西一樣都缺不得。

但是呢,我們可以用一個trait來使得用戶用起Object來更加方便

pub trait ToSync {
type Target: Any + Send + Sync;
fn to_sync(self) -> Result<Self::Target>;
}

trait MakeSync {
fn make_sync(object: Object) -> Result<SyncObject>;
}

impl<T: Any + ToSync> MakeSync for T {
fn make_sync(object: Object) -> Result<SyncObject> {
Ok(SyncObject::new(object.take::<T>()?.to_sync()?))
}
}

impl Object {
pub fn new<T: Any + GetHoldee + ToSync>(content: T) -> Self {
Self {
content: Box::new(content),
get_holdee_f: T::get_object_holdee,
make_sync_f: T::make_sync,
}
}

(其實MakeSync也可以沒有,只是為了降低我自己的心智負擔。)

最終,用戶只需要實現ToSync,這是一個和Object以及SyncObject都沒關係的trait。這樣一來,我就可以給用戶提供一個看似「魔幻」的事實:

任何一個類型,只要實現了AnyGetHoldeeToSync,就可以成為Object了!

而不需要用戶不知道從哪裡給我們找一個函數指針傳進來。但這並不會改變Object所必須包含的函數指針。C要有的,Rust都必須要有,頂多好看點。


第三,一切設計不能脫離實際。

這可真是我最感到驚奇的一點了。在Rust中,你可以輕而易舉地寫出一個類型,然而用戶沒有任何辦法好好使用它!同時,編譯器也不會對你有任何抱怨。當然了,不是說C++中不能做到這一點,但是通常都需要涉及模板元編程等等高深的技巧。然而在Rust中,這種情況變得常見了起來。這也許應當歸罪於trait相關的語法設計,希望隨著Rust的發展可以適當緩解這個問題。

作為一個例子,可以看看我某一時期對as_ref的設計。一個Object的實際類型有兩種可能:線程不安全的類型,或者由於要共享將其轉換成的線程安全的類型。當用戶向下轉型時,絕大多數情況下他對於這個Object會是兩者中的哪一個一無所知, 因此只能兩個類型都試一試,這樣看起來實在難受。於是我最初的設計是,假定兩個類型都實現了同一個trait Foo,它們就可以實現Deref<Target = dyn Foo>。於是

fn as_ref<L, S, I>(&self) -> Result<&I, ErrorT>
where
I: ?Sized
L: Any + Deref<Target = I>
S: Any + Deref<Target = I>

想法很美好。然而

  1. 一個類型只能實現一次Deref,這個寶貴的機會就這樣被佔用了太可惜了。
  2. 對於LS是同一個類型的情況及其不友好,因為T&TAny::type_id是不一樣的
  3. 對於獲取可變引用的版本as_mut,一個&mut I無法像&mut L一樣被臨時解引用,因為它的大小編譯期不知道,無法分配棧上空間

最終的結果就是這個介面的可用性極差。這不是Rust的錯,只怪我錯誤地使用了Deref和trait object的功能。

最後我把它拆成了兩個介面。as_ref<T>負責LS都是T的情況,而另一個介面是

pub fn as_dual_ref<L, S, LF, SF, R>(&self, local_fn: LF, shared_fn: SF) -> Result<R>
where
L: Any,
S: Any,
LF: FnOnce(&L) -> Result<R>,
SF: FnOnce(&S) -> Result<R>,

用起來舒服多了。所有的類型都可以推導出來,太舒服了。

所以說,設計Rust的介面時,在腦海中憑空構思用戶使用的方式都是不夠的,要真正地通過單元測試之類的方式落實到代碼上去。

也許是我的腦迴路比較奇怪才會碰到這類問題吧……


這也許是我近期最後一篇長文了。以後我還是會盡量多用英文來寫。

祝願各位讀者的Rust編程之路秀髮濃密。

Cowsay:用Rust寫腳本語言(零):目錄?

zhuanlan.zhihu.com圖標
推薦閱讀:

相關文章