總結

允許所有型別(type)以作為常數值(constant values)的形式成為泛型(generic),這能讓使用者用 impl 寫出所有陣列型別(array types)。

動機

現在的 Rust 有一種將常數參數化(parametric)型別︰內建的陣列型別 [T; LEN]。然而,常數泛型(const generics)並非第一級別的功能,使用者無法自己定義將常數值作爲泛型的型別,且不能對所有的陣列實作特徵(traits)。

在此限制下,標準庫僅包含長度最多到 32 的陣列實作,而陣列也因此被視爲語言的第二級別的功能。即使在長度可靜態已知的情形下,也大多調配於堆(heap)上成為向量(vector)而非使用陣列型別,因而造成一定程度上的效能取捨。

常數參數也能讓使用者更自然地指定一個泛型變體(variant), 且相比於型別更能確切地反應其值。舉例來說,基於某些因素有個型別用一個名稱來作為參數的話,使用 &'static str 來提供其名稱會比(藉由相關的常數或是函式提供的)單元型別(unit type)的形式還來的合理,這能簡化 API。

總結來說,常數可作爲參數使用,讓編譯器在型別檢查時期能確定這些數值。藉由限制哪些數值是否有實作過特定的特徵,孤兒規則(orphan rules)可以確保一個 crate 僅使用部份安全的數值。(例如有關密碼學的相關函式庫)

設計細節

現今 Rust 中的型別有兩種參數化的形式︰型別與生命周期。如同在編譯時能推導出這些數值,我們將額外的允許型別將數值參數化。一個常數參數必為單一、特定型別,且可以有效的替換為編譯時期計算出的任意值且該型別將符合本 RFC 以下列出的要求。

(為明確在此 RFC 闡明哪些表達式可在編譯時評估,在之後的範例中,我們假設整數及其基本算數操作可以在編譯時計算。)

詞彙表

  • 常數(constant, const value)︰一個在編譯時期可以保證完全評估的 Rust 數值。不同於靜態數值(static),常數將在其使用的位置 inline 而非存在於編譯好的二進制檔案的資料區段。

  • 常數參數、泛型常數(const parameter, generic const)︰一個由型別或函式抽象而得到的常數。此常數是具體型別的輸入值,例如一個靜態陣列的長度。

  • 關聯常數(associated const)︰一個由特徵關聯而得到的常數,其相似於關聯型別。不同於常數參數,關聯常數是由型別所決定的。

  • 常數變數(const variable)︰相比於具體常數,常數參數或關聯常數皆為常數變數。在單態化(monomorphization)之前,一個常數在上下文中是未確定的。

  • 具體常數(concrete const)︰相比於常數變數,一個在上下文中已知且單一值的常數。

  • 常數表達式(const expression)︰一個用於評估一個常數的表達式。此可為一個身份表達式或一個在 Rust 常數系統中可以評估出的更複雜的表達式。

  • 抽象常數表達式(abstract const expression)︰一個包含常數變數的表達式。(因此在單態化結束前其值是無法評估的)

  • 常數投影(const projection)︰抽象常數表達式的數值(其在泛型上下文中因缺乏所依賴的常數變數而無法被定義)

  • 身份表達式(identity expression)︰一個在不以其範圍內的名稱進行置換則無法評估的表達式。此包括了所有的文字及身份(ident),例如,3"Hello, world"foo_bar

宣告常數參數

在型別參數宣告的任何序列中都可以宣告常數參數(例如在一個型別的定義中或是在 impl 的標頭或區塊(block)中)。常數參數的格式爲 const $ident: $ty


#![allow(unused)]
fn main() {
struct RectangularArray<T, const WIDTH: usize, const HEIGHT: usize> {
    array: [[T; WIDTH]; HEIGHT],
}
}

這些宣告的身份(ident)就是常數參數(在本 RFC 內文中可替稱作「常數變數」)的名稱,而且所有數值必定其推導的型別。本 RFC 後文將說明能被推導的型別有哪些限制。

宣告的常數變數範圍在該項目(type、impl、function、method、…等等)的整體範圍中。

套用常數作為參數

任何可推導出該常數參數型別的常數表達式都可作爲參數。除了陣列外,當套用一個表達式作為常數參數且該表達式並非身份表達式時,該表達式必須包含在區塊內 。此語法上的限制是必要的,以避免在型別中解析表達式需要無限地向前展望(lookahead)。


#![allow(unused)]
fn main() {
const X: usize = 7;

let x: RectangularArray<i32, 2, 4>;
let y: RectangularArray<i32, X, {2 * 2}>;
}

陣列

陣列語法中有一個特別的構造語法:[T; CONST]。在陣列中大括號不需要存在於任何的常數表達式中,[i32; N * 2] 是一個合理的型別。

何時可使用常數變數

常數變數可使用於下列上下文之任一項中︰

  1. 用於任一型別中的常數,該型別是待定項目之簽章(signature)的一部份:fn foo<const N: usize>(arr: [i32; N])
  2. 用於定義相關常數的常數表達式或相關型別之參數的一部份
  3. 在項目中任何函式的內部之任何運行時表達式中的數值
  4. 在項目中任何函式內部用於任何型別的參數,例如在 let x: [i32; N]<[i32; N] as Foo>::bar()
  5. 在項目中任何欄位之型別的一部分(如 struct Foo<const N: usize>([i32; N]);

在一般情況下,常數變數可以用做常數。但有一個明顯的例外是,常數變數不可用於常數、靜態型別、函式、或函式內的型別的建構子,意即下面的例子是不合規的。


#![allow(unused)]
fn main() {
fn foo<const X: usize>() {
    const Y: usize = X * 2;
    static Z: (usize, usize)= (X, X);

    struct Foo([i32; X]);
}
}

這樣的限制就如同型別型別不能用在函式本體中建造的型別一樣。所有的宣告雖然專用於該項目,但也必需獨立於它,且不能有任何在其範圍內的參數。

兩個常數的型別相等性之相等原則

在統一且重疊的檢查期間,何時兩個型別是否相等是關重要的。因為型別現在可依賴於常數了,所以我們必須定義我們如何比較兩個常數表達式的相等性。

在大多數情況下,兩個常數的相等性會如同你的預期,如果兩個常數彼此相等,則它們相等。但是仍會有一些特殊的狀況。

結構相等性

常數相等性的定義是根據 RFC 1445 解釋的結構相等性所定義。只有在型別有「結構相符」的特性下,該結構才可用做常數參數。舉例來說,浮點數就會被排除在外。

在最終解決方案出來前,結構相符這樣的性質應作為權宜之計。無論變數參數採用什麼解決方案,確保相等是具有反射性(reflexive)對於型別相等而言是重要的,這樣才能使得型別始終相同。(浮點數相等的標准定義是不具反射性)

這可能會與未來進行配對(match)的定義而有所不同,但是配對和常數參數不一定得使用相同的相等性定義,現今所使用的相等性定義已經足以滿足我們的目的。

因為常數必須具有結構相符的性質,並且此性質無法強制轉成一個型別變數,因此我們無法定義一個常數參數是從其他型別變數推導來的。(意即 Foo <T,const N:T>不是合規的)

兩個抽像表達式的相等性

在比較兩個抽象常數表達式(即依賴於變數的表達式)的相等性時,我們無法比較其值的相等性,因其值是由常數變數所決定的,而在在單態化之前是未知的。

基於這個原因,我們至少會將常數表達式的返回值當作投影(projections)。雖然其值仍保持未知,我們仍藉由輸入值來決定其值,這是作法相同於現在我們處理關聯型別(associated types)的作法。此作法我們將稱為常數投影(const projection),對另一個同型別的常數而言,我們永遠無法確定其相等性。

每個常數表達式都會生成一個新的投影,該投影本質上是匿名的。不可能使兩個匿名投影一致(想像兩個關聯型別的泛型 T :: AssocT :: Item,你無法證明或否定它們是否為同一型別)。因此,除非它們從字面上使用完完全全相同的文字,否則常數表達式在 AST 節點中是彼此不一樣的。這意味著 N + 1 的一個實例不會與另一個 N + 1 的實例在型別相同。

更清楚來說,以下是無法通過型別檢查的,因為 N + 1 會是兩種不同的型別:


#![allow(unused)]
fn main() {
fn foo<const N: usize>() -> [i32; N + 1] {
    let x: [i32; N + 1] = [0; N + 1];
    x
}
}

但如果這樣寫的話,它將只有一個型別:


#![allow(unused)]
fn main() {
type Foo<const N: usize> = [i32; N + 1];

fn foo<const N: usize>() -> Foo<N> {
    let x: Foo<N> = Default::default();
    x
}
}

未來的擴展性

未來的某一天我們可以利用一些操作的基本性質(例如加法和乘法的可交換性),而對常數投影的相等性做出更聰明的判斷。但是,本 RFC 並不打算在此提案中建造這些可能性,而是打算留給未來的 RFC 決定。

常數變數的特化(Specialization)

定義常數參數特化的順序也是很重要的。為此,將字面上的定義必需比其他表達式更具體,否則表達式定義的順序上也會產生不確定性。

正如我們有朝一日可以在常數投影上支援更進階的相等性一樣,我們可以支援更進階的特化定義。例如,給定型別為 (i32, i32),我們可以確定 (0, PARAM2)(PARAM1, PARAM2) 更為具體;(i32, U)(T, U) 更為具體。在未來的某天我們將也有可能在常數特化上支援多元交互(intersectional)和其他更進階的定義。

我們如何教導這個

常數泛型是一個很龐大的功能,將需要大量的教育資源,這將會需要寫在在書本跟參考文件中,且可能會在書中有獨立的章節。常數泛型的文件化過程本質上將會是一個大工程。

然而,常數泛型應該被視為進階功能,並且在使用 Rust 的初期,我們應該不會向新手介紹這些內容。

缺點

此功能由於允許型別由常數決定,將為型別系統增加了大量的複雜性。它需要抽象變數相等性的確定規則,這出現了很多令人意外的的特殊情況。它增加了 Rust 的很多語法。如果我們不要採用此功能,Rust 肯定會更簡單。

然而,我們已經引入了一種由常數確定的型別(數組型別)。泛型化的功能似乎是必然的,甚至是不可避免的,鑑於此,我們應該盡早決定。

替代方案

除了暫緩執行或不執行外,並沒有真的替代方案。

我們可以限制常數泛型為 usize 型別,但這不會讓實作更為簡單。

我們可以對常數相對性的複雜概念更積極地發展,但這會使實作比上述說明得更加複雜。

我們可以選擇稍微不同的語法,例如將在常數跟型別間加上分號。

未解決問題

  • 一致的抽象常數表達式︰本 RFC 盡可能最大限度地減少抽象常數表達式的統合性上的處理,從本質上來說,並沒有使其一致。這可能造成無法接受的使用者體驗的不穩定,而我們想要實作一些更進些的統合性之前穩定此功能。
  • 常數表達式之正確格式︰只有在單態化過程中,程式不會恐慌(panic)的情況下,此型別才能視爲正確格式。這對於溢出和超出範圍的陣列存取來說很棘手。然而,我們實際上只能確保在函式的簽章中表達式常數的格式進行正確性約束。目前尚不清楚有關在函式中抽象常數表達式之格式正確性的處理方式,也因此使實作推遲。
  • 排序與預設參數︰所有常數參數是否將排在最後,或者將它們與型別混合嗎?具有預設值的參數是否須在沒有預設值之後?這些決定推遲到實作語法的討論中。