這是一個玩具語言的示範,具備動態類型、內聯值、堆疊分配、內部指標、單一所有權及有限形式的借用——表達能力不及Rust,但遠勝於次級引用(例如可表達外部迭代器)。

由於沒有靜態類型,借用必須動態檢查。示範的有趣之處在於,我們能以相當低成本且提供有用錯誤訊息的方式完成這項檢查。

我正在探索一種由Julia和Zig所示範的類型系統風格。兩者皆從動態類型系統開始,透過動態類型檢查強制執行,然後疊加靜態類型系統,能證明動態檢查是不必要的。動態類型系統提供彈性與易於元程式設計,靜態類型系統則在大部分程式碼中移除額外負擔。

Julia與Zig在無法靜態檢查的程式碼處理上略有不同:Zig會拒絕編譯該程式碼,而Julia會保留部分動態檢查,並在有更多類型資訊時再次執行靜態檢查。

我為zest語言探索第三種選擇——程式碼可動態類型(解譯)或靜態類型(編譯),但兩者切換需明確註記。目標是大部分程式碼享有靜態類型保證,同時可選擇動態類型膠水碼以支援REPL、即時重載、編譯時元程式設計、執行時程式碼生成及彈性軟體等。

棘手之處在於我也想強制可變值語意。迄今有兩種主要策略:

因此我必須創新,這是我提出的方案:

額外好處是,我至少有60%信心此方案是安全的。

先說明:若下方有綠色勾勾,代表所有範例均可互動。你可編輯程式碼並點擊執行按鈕查看結果。若是紅色叉叉,可能是你關閉了JavaScript或我未測試你的瀏覽器,你只能參考程式碼框底部的離線結果。

我們的玩具語言相當簡單,包含整數、元組、函式及基本控制流程。

每個變數都是獨立值。修改一個變數的值不會影響其他變數的值。

每個區塊({})結束時,該區塊定義的每個變數都會釋放其關聯值,釋放該值使用的記憶體。

這種結合值語意與可變性的方式簡單且易於實作。

但這也沒什麼用。每次使用都複製整個值在處理大型資料時不可行。我們需要表達值之間共享的方式,且不破壞值語意。

引用讓我們表達值儲存在不同位置的概念。

一種建立引用的方式是box函式,將內容存於堆積中。下例中,值[2, 3]存於堆積,值[1, box(...)]存於堆疊。

解引用運算子*用來存取引用內的內容。

我們可以複製box的內容,但這不適用於任意大型資料結構,可能複製數GB記憶體!

或者我們可以複製指標本身,讓a與b共享堆積分配。但b*[0]的賦值會影響a,破壞值語意幻象。

除非加上明確註記,否則我們實際上拒絕複製box。

使用引用時,我們必須更明確定義意義,有幾種選擇。

第一種是用^移動值。這會複製引用,但摧毀原始引用!

圖中留有XXX表示原始引用已被摧毀,尚未被替代。XXX具體意義稍後討論。(可用某種零值代表所有類型,但感覺不妥。)

被摧毀的值可被新值覆蓋。

這提供一種(笨拙的)方式將引用傳入函式而不複製。可移動原始值,在函式內修改,回傳修改後值,再賦回原變數。

為減少繁瑣,我們提供第二種選擇——用!建立借用引用。這類似將值移入新box(...),但當新引用被釋放時,值會回到原位置。(借用即應歸還!)

第三種也是最後一種選擇是用&建立共享引用。行為類似借用引用,但原擁有者保有其副本,未被摧毀。

為維持獨立值幻象,不允許修改任一副本。

支援閉包,但不會隱式捕捉作用域變數。所以下例無法運作。

(可支援Rust風格隱式捕捉,但明確捕捉更易解釋借用檢查互動。)

先思考如何不用閉包寫此範例。我們可回傳包含所有狀態的元組,再對該元組呼叫獨立的next函式。

閉包只是此模式的語法糖。我們指定要捕捉的狀態(index, tuple_ref)及存取方式(!),編譯器會轉換成上述範例。

底層,借用與共享引用實作為指向原始值的指標。那些XXX在實作中不存在。

借用檢查目標是兩全其美——提供值語意的簡單性,同時保有引用語意的效能,且不讓你察覺差異。

確保移動、借用與共享不破壞幻象,靜態類型系統易實現,動態類型系統則難且昂貴。這是我目前最佳方案,仍較Rust限制多。

或許最簡單說明方式是先列出禁止行為,再談實作。

最大限制是擁有引用(box)不得指向借用/共享引用。確保借用/共享引用只存在堆疊,方便動態執行時強制其他規則。

當借用引用指向值時,不可再建立該值的其他借用/共享引用。

執行時會追蹤借用引用何時被釋放。

可透過解構借用引用,建立多個指向值不同部分的借用引用。

當共享引用指向值時,只能建立該值的共享引用。

當存在借用/共享引用時,不可移動變數中的值,即使移動與借用/共享部分不重疊。

值一旦部分或全部被移動,直到整個值被替換前不可使用。

變數只能持有指向壽命較長變數的借用/共享引用。

區塊回傳值不得包含指向該區塊定義變數的借用/共享引用。

雖然語言是動態類型,變數一旦賦值後類型不可變(因配置大小不可變)。

連引用類型也不可變,雖大小相同。避免每個配置存放類型標籤,可由引用類型推斷值類型。

不過如Julia,我們可選擇在需要動態性的地方存放類型標籤。any函式接受引用,回傳動態類型版本。

但仍不可改變配置本身類型。

說完不能做的,來看看能做且次級引用無法做到的事。

引用可放入元組。非玩具語言中,可用類型如Option<&mut T>。

引用可從函式回傳。非玩具語言中,可用函式類型如fn(&[T], usize) -> Option<&T>。

借用引用亦可從函式回傳,但有細節。下例無法運作。

原因是tuple*[index]!借用自tuple,生命週期結束時會回傳給tuple,故無法超過tuple壽命。若要產生超過tuple壽命的引用,必須明確移動tuple。

Alex在評論中舉例次級引用無法處理的情況——在迴圈中遍歷鏈結串列。我們也能處理,雖然玩具語言無和類型,稍顯笨拙,但非借用檢查限制。

先前閉包章節見過複製迭代器,我們也能做回傳共享或借用引用的迭代器。

甚至正確指派回傳引用的生命週期——共享版本共享底層元組,借用版本借用迭代器本身,故不允許同時回傳多個借用引用。

為強制這些規則,我們需存額外資料。

每個變數存有一個可處於4種狀態的引用計數。

巧妙之處在於每個安全檢查只需一次整數比較。

每個借用/共享引用存有:

用20位元即可以8字節對齊定位8MB堆疊,以上資訊每引用佔42位元。每借用/共享引用總共16字節,但靜態類型碼不必付此開銷。

貸方與擁有者不同時,是從現有借用再借用的情況:

b與d皆被借用,且在e存在時不可存取。建立b!時增加b的借用計數,建立d*!時增加d的借用計數。

結果是b在d存在時不可存取,d在e存在時不可存取。形成底層值的監管鏈,任一時刻最多一個引用可修改值。

但底層值仍由b擁有,故不安全寫入e* = c&,因c會先於b被釋放。安全檢查賦值時須看位置擁有者,不是貸方。故每引用追蹤擁有者與貸方。

此方案缺點是無法回傳超過貸方生命週期的引用。

我們想從區塊回傳c,但c貸方是b,b會在區塊結束時釋放。

解法是移動b。評估左值b^*時,發現b已被消耗且不可存取,故不須記錄為貸方。

回顧前段iter_borrowed範例,tuple_ref^**[index*]!中有此模式。無^時,迭代器無法回傳借用引用,因貸方是tuple_ref。

大部分錯誤訊息可由追蹤資料輕易產生。例如:

a&產生記錄a為貸方的引用,明確知道責任歸屬。

有一種錯誤需更多工作。

我們知道不安全再次借用a,因借用計數已是1,但不知借用引用位置。

知道所有借用/共享引用必在堆疊,且借用a的引用必在a堆疊下方。且即將恐慌,可花CPU時間掃描堆疊找出借用a的引用,產生更好錯誤訊息。

關鍵函式getRefIndexes,給定類型ID,回傳該類型所有引用位置的預計算清單。避免遞迴深入類型,類似某些垃圾回收語言的GC位圖。

新執行緒啟動時,需確保不與其他執行緒共享引用計數。動態碼呼叫靜態碼(反之亦然)時,需確保靜態碼不以破壞動態碼引用計數方式使用引用。兩者安全需求相似。

我尚未實作執行緒或靜態類型,但已將安全需求封裝於with_new_stack函式,該函式將閉包複製到新堆疊,呼叫後再複製結果回來。

捕捉可能包含借用/共享引用。複製到新堆疊後,將來源改指向新堆疊的虛擬貸方,函式呼叫不須觸及舊堆疊引用計數。

僅函式回傳時才減少原貸方引用計數(執行緒意味結構化併發)。

無法遞迴再借用,故借用/共享引用目標不得含借用/共享引用。

函式回傳結果不得含借用/共享引用。

執行緒情況此限制過嚴,理論上可允許回傳借用/共享引用,只要壽命足夠。但靜態碼可能無法精確知道擁有者/貸方,無法正確更新引用計數。

函式必須以移動呼叫,非借用/共享引用呼叫。

這些限制允許安全跨堆疊或單堆疊分區呼叫,防止執行緒共享引用計數,且靜態碼永不見引用計數或來源。

我嘗試過多種方案,在成本、表達力與易解釋性間取捨。

最有趣但放棄的系統是用invisicap風格陰影配置追蹤借用/共享元素。

允許對單一值建立多重不重疊借用。

也免除借用子引用時需固定值,因借用部分被釋放時只需修改該部分。

最大問題是無法安全過渡到靜態碼。若可移動正被借用的值,唯有掃描整個值確認無移動部分,才能確保不傳入靜態碼。

錯誤訊息也較差,違規時常無法合理追蹤肇事者,需全堆積掃描。

範例中標點符號多,若加入動態版Rust的deref coercion,可減少許多。例如a**[0]可簡化為a[0],若[]運算子會解引用左側直到找到元組。類似地a** = b可簡化為a = b。

若你玩過REPL,可能遇過此錯誤:

問題在於我們有嚴格LIFO堆疊,每個表達式預期消耗參數並只留結果值。但f()!中值[2, 3]需先分配於堆疊才能借用。靜態語言可預先檢查大小並分配堆疊空間,動態語言無此選項。

唯一易處理情況是表達式回傳引用且立即解引用。

這可將引用從堆疊彈出,產生指向內容的左值。

可能解法是用兩個堆疊——一個放表達式結果值,一個放當前區塊結束前需存活的值。借用前先從第一堆疊彈出,推入第二堆疊。

但現行設計有優點——只能從具名值借用/共享,易產生可讀錯誤訊息。若允許f()!,錯誤訊息會變成:

限制借用/共享引用只在堆疊,主要是為了在賦值a = b時,可掃描b的堆疊值判斷壽命,而非整個堆積值。

額外好處是指向堆疊的指標只能存在堆疊,且可精確識別,方便堆疊擴展或縮減。

我們有足夠空間在來源中存放32位元堆疊索引,堆疊最大可達4GB。

我很介意移動值會得到同類型值,但借用/共享值會得到引用。很難避免,我嘗試過:

兩者在實作範例時都不太符合人體工學。

問題與內部指標存在密切相關。回顧歷史,我曾做過一種無需左值且以空指標表示被釋放值的通用布局語言。該語言人體工學佳且易解釋,但通用布局限制效能潛力。

與Rust相比,我們必須明確釋放值以結束生命週期,感覺受限。

不只是最後直接使用後釋放變數,因引用仍可能存在。

可標記變數於最後直接使用後可釋放,實際釋放則待引用計數歸零。這會讓語言更像Rust。

我未採用此法,因動態版本函式可能比靜態版本更早釋放值,靜態分析只能大致追蹤引用計數,動態版本則精確。

我堅持靜態類型只排除錯誤,不改變語意。較晚釋放感覺像改變語意,雖目前無直接觀察到。

我同時解決兩個問題:

許多相關工作只解決其中一個,少數系統同時解決兩者。

Rust兩者皆解決,但付出複雜類型系統代價。Hylo、Mojo及近期Swift也嘗試同時解決,但以限制引用使用換取簡化類型系統。

Hylo特別有趣,因其引用完全是次級,簡化心智模型,但透過交錯協程重獲部分一級引用表達力。

我仍有興趣,但Hylo較限制(如無Option<&T>),程式碼生成複雜,且協程副作用時機難以理解。現系統只要不允許借用/共享引用出現在其他值中,也能達到類似簡單心智模型。

Rust也有動態模型——樹狀借用,用於Miri測試使用unsafe逃逸型態系統的程式碼。但樹狀借用成本過高,不適合實際程式模型,每次讀寫引用都須檢查所有別名引用是否需失效。

C# refs與OCaml模式只解決問題2,但易擴展至問題1。兩者需靜態類型,我的引用計數系統大致是C# refs的動態版本,表達力相似。

Cheriot與fil-c解決問題2無靜態類型(需GC)。我曾構思fil-c啟發系統同時解決問題1,但無法合理過渡動態與靜態碼。

R與(部分)Swift用引用計數與寫時複製解決問題1。引用計數有不小開銷(如某些基準測試顯示由原子改非原子引用計數可使Swift程式效能翻倍),寫時複製可能無意中複製大型值。引用計數與內部指標結合困難,且允許內部指標逃逸是Go等語言記憶體洩漏常見原因。堆疊分配仍只能靠盡力逃逸分析。這些都與我追求可預測效能目標不符。

Gel/inko是引用計數的有趣變體。非零引用計數時不釋放值,而是在作用域結束釋放,若引用計數非零則報錯。此法未解決我兩問題,但啟發我實作。

我不確定下一步。現有方案可用,但使用時感覺繁瑣。Rust一級借用成功,或許因其類型系統讓編譯器補足許多細節(如自動借用、deref coercion)並捕捉錯誤。

一選擇是更接近Hylo,採用次級引用與協程,若能在動態解譯環境實現。

另一選擇是全靜態類型(免動態借用檢查),但專注靜態碼中未知類型值的使用人體工學。我看得出Zig風格comptime仍可行,即使comptime語言也靜態類型,但不知對元程式設計人體工學影響多大。

總之,若想看更多此語言,可按贊助按鈕。就像你的稅金支持某人博士獎學金,只是更直接些。