Rust RFCs - Active RFC List

注意:這是官方 rust rfcs 的 fork。如果你有興趣協助翻譯的話,歡迎前往 CONTRIBUTING.md 瞭解更多細節。

Note: This is a fork of official rust rfcs. If you are interested in help translating, please have a look at CONTRIBUTING.md.

Many changes, including bug fixes and documentation improvements can be implemented and reviewed via the normal GitHub pull request workflow.

Some changes though are "substantial", and we ask that these be put through a bit of a design process and produce a consensus among the Rust community and the sub-teams.

The "RFC" (request for comments) process is intended to provide a consistent and controlled path for new features to enter the language and standard libraries, so that all stakeholders can be confident about the direction the language is evolving in.

Table of Contents

When you need to follow this process

You need to follow this process if you intend to make "substantial" changes to Rust, Cargo, Crates.io, or the RFC process itself. What constitutes a "substantial" change is evolving based on community norms and varies depending on what part of the ecosystem you are proposing to change, but may include the following.

  • Any semantic or syntactic change to the language that is not a bugfix.
  • Removing language features, including those that are feature-gated.
  • Changes to the interface between the compiler and libraries, including lang items and intrinsics.
  • Additions to std.

Some changes do not require an RFC:

  • Rephrasing, reorganizing, refactoring, or otherwise "changing shape does not change meaning".
  • Additions that strictly improve objective, numerical quality criteria (warning removal, speedup, better platform coverage, more parallelism, trap more errors, etc.)
  • Additions only likely to be noticed by other developers-of-rust, invisible to users-of-rust.

If you submit a pull request to implement a new feature without going through the RFC process, it may be closed with a polite request to submit an RFC first.

Sub-team specific guidelines

For more details on when an RFC is required for the following areas, please see the Rust community's sub-team specific guidelines for:

Before creating an RFC

A hastily-proposed RFC can hurt its chances of acceptance. Low quality proposals, proposals for previously-rejected features, or those that don't fit into the near-term roadmap, may be quickly rejected, which can be demotivating for the unprepared contributor. Laying some groundwork ahead of the RFC can make the process smoother.

Although there is no single way to prepare for submitting an RFC, it is generally a good idea to pursue feedback from other project developers beforehand, to ascertain that the RFC may be desirable; having a consistent impact on the project requires concerted effort toward consensus-building.

The most common preparations for writing and submitting an RFC include talking the idea over on our official Discord server, discussing the topic on our developer discussion forum, and occasionally posting "pre-RFCs" on the developer forum. You may file issues on this repo for discussion, but these are not actively looked at by the teams.

As a rule of thumb, receiving encouraging feedback from long-standing project developers, and particularly members of the relevant sub-team is a good indication that the RFC is worth pursuing.

What the process is

In short, to get a major feature added to Rust, one must first get the RFC merged into the RFC repository as a markdown file. At that point the RFC is "active" and may be implemented with the goal of eventual inclusion into Rust.

  • Fork the RFC repo RFC repository
  • Copy 0000-template.md to text/0000-my-feature.md (where "my-feature" is descriptive). Don't assign an RFC number yet; This is going to be the PR number and we'll rename the file accordingly if the RFC is accepted.
  • Fill in the RFC. Put care into the details: RFCs that do not present convincing motivation, demonstrate lack of understanding of the design's impact, or are disingenuous about the drawbacks or alternatives tend to be poorly-received.
  • Submit a pull request. As a pull request the RFC will receive design feedback from the larger community, and the author should be prepared to revise it in response.
  • Each pull request will be labeled with the most relevant sub-team, which will lead to its being triaged by that team in a future meeting and assigned to a member of the subteam.
  • Build consensus and integrate feedback. RFCs that have broad support are much more likely to make progress than those that don't receive any comments. Feel free to reach out to the RFC assignee in particular to get help identifying stakeholders and obstacles.
  • The sub-team will discuss the RFC pull request, as much as possible in the comment thread of the pull request itself. Offline discussion will be summarized on the pull request comment thread.
  • RFCs rarely go through this process unchanged, especially as alternatives and drawbacks are shown. You can make edits, big and small, to the RFC to clarify or change the design, but make changes as new commits to the pull request, and leave a comment on the pull request explaining your changes. Specifically, do not squash or rebase commits after they are visible on the pull request.
  • At some point, a member of the subteam will propose a "motion for final comment period" (FCP), along with a disposition for the RFC (merge, close, or postpone).
    • This step is taken when enough of the tradeoffs have been discussed that the subteam is in a position to make a decision. That does not require consensus amongst all participants in the RFC thread (which is usually impossible). However, the argument supporting the disposition on the RFC needs to have already been clearly articulated, and there should not be a strong consensus against that position outside of the subteam. Subteam members use their best judgment in taking this step, and the FCP itself ensures there is ample time and notification for stakeholders to push back if it is made prematurely.
    • For RFCs with lengthy discussion, the motion to FCP is usually preceded by a summary comment trying to lay out the current state of the discussion and major tradeoffs/points of disagreement.
    • Before actually entering FCP, all members of the subteam must sign off; this is often the point at which many subteam members first review the RFC in full depth.
  • The FCP lasts ten calendar days, so that it is open for at least 5 business days. It is also advertised widely, e.g. in This Week in Rust. This way all stakeholders have a chance to lodge any final objections before a decision is reached.
  • In most cases, the FCP period is quiet, and the RFC is either merged or closed. However, sometimes substantial new arguments or ideas are raised, the FCP is canceled, and the RFC goes back into development mode.

The RFC life-cycle

Once an RFC becomes "active" then authors may implement it and submit the feature as a pull request to the Rust repo. Being "active" is not a rubber stamp, and in particular still does not mean the feature will ultimately be merged; it does mean that in principle all the major stakeholders have agreed to the feature and are amenable to merging it.

Furthermore, the fact that a given RFC has been accepted and is "active" implies nothing about what priority is assigned to its implementation, nor does it imply anything about whether a Rust developer has been assigned the task of implementing the feature. While it is not necessary that the author of the RFC also write the implementation, it is by far the most effective way to see an RFC through to completion: authors should not expect that other project developers will take on responsibility for implementing their accepted feature.

Modifications to "active" RFCs can be done in follow-up pull requests. We strive to write each RFC in a manner that it will reflect the final design of the feature; but the nature of the process means that we cannot expect every merged RFC to actually reflect what the end result will be at the time of the next major release.

In general, once accepted, RFCs should not be substantially changed. Only very minor changes should be submitted as amendments. More substantial changes should be new RFCs, with a note added to the original RFC. Exactly what counts as a "very minor change" is up to the sub-team to decide; check Sub-team specific guidelines for more details.

Reviewing RFCs

While the RFC pull request is up, the sub-team may schedule meetings with the author and/or relevant stakeholders to discuss the issues in greater detail, and in some cases the topic may be discussed at a sub-team meeting. In either case a summary from the meeting will be posted back to the RFC pull request.

A sub-team makes final decisions about RFCs after the benefits and drawbacks are well understood. These decisions can be made at any time, but the sub-team will regularly issue decisions. When a decision is made, the RFC pull request will either be merged or closed. In either case, if the reasoning is not clear from the discussion in thread, the sub-team will add a comment describing the rationale for the decision.

Implementing an RFC

Some accepted RFCs represent vital features that need to be implemented right away. Other accepted RFCs can represent features that can wait until some arbitrary developer feels like doing the work. Every accepted RFC has an associated issue tracking its implementation in the Rust repository; thus that associated issue can be assigned a priority via the triage process that the team uses for all issues in the Rust repository.

The author of an RFC is not obligated to implement it. Of course, the RFC author (like any other developer) is welcome to post an implementation for review after the RFC has been accepted.

If you are interested in working on the implementation for an "active" RFC, but cannot determine if someone else is already working on it, feel free to ask (e.g. by leaving a comment on the associated issue).

RFC Postponement

Some RFC pull requests are tagged with the "postponed" label when they are closed (as part of the rejection process). An RFC closed with "postponed" is marked as such because we want neither to think about evaluating the proposal nor about implementing the described feature until some time in the future, and we believe that we can afford to wait until then to do so. Historically, "postponed" was used to postpone features until after 1.0. Postponed pull requests may be re-opened when the time is right. We don't have any formal process for that, you should ask members of the relevant sub-team.

Usually an RFC pull request marked as "postponed" has already passed an informal first round of evaluation, namely the round of "do we think we would ever possibly consider making this change, as outlined in the RFC pull request, or some semi-obvious variation of it." (When the answer to the latter question is "no", then the appropriate response is to close the RFC, not postpone it.)

Help this is all too informal!

The process is intended to be as lightweight as reasonable for the present circumstances. As usual, we are trying to let the process be driven by consensus and community norms, not impose more structure than necessary.

License

This repository is currently in the process of being licensed under either of:

  • Apache License, Version 2.0, (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
  • MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)

at your option. Some parts of the repository are already licensed according to those terms. For more see RFC 2044 and its tracking issue.

Contributions

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

總結

允許所有型別(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)的情況下,此型別才能視爲正確格式。這對於溢出和超出範圍的陣列存取來說很棘手。然而,我們實際上只能確保在函式的簽章中表達式常數的格式進行正確性約束。目前尚不清楚有關在函式中抽象常數表達式之格式正確性的處理方式,也因此使實作推遲。
  • 排序與預設參數︰所有常數參數是否將排在最後,或者將它們與型別混合嗎?具有預設值的參數是否須在沒有預設值之後?這些決定推遲到實作語法的討論中。

總結

透過此功能,可以在常數求值(const evaluation)中使用 ifmatch 並使他們被延遲求值。簡單來說,這項功能允許我們寫 if x < y { y - x } else { x - y };即使當使用非負型別時在 x < yelse 分支會報溢位錯誤 (overflow error)。

動機

在常數宣告中使用條件式對於建立像是 NonZero::newconst fn 及 直譯判定(interpreting assertions)來說很重要。

教學式解說

如果你寫


#![allow(unused)]
fn main() {
let x: u32 = ...;
let y: u32 = ...;
let a = x - y;
let b = y - x;
if x > y {
    // do something with a
} else {
    // do something with b
}
}

這支程式永遠都會 panic(除非 xy 同時是 0)因為不管是 x - y 或是 y - x 都會造成溢位。為了解決此問題,我們必須把 let alet b 個別搬進 ifelse 中。


#![allow(unused)]
fn main() {
let x: u32 = ...;
let y: u32 = ...;
if x > y {
    let a = x - y;
    // do something with a
} else {
    let b = y - x;
    // do something with b
}
}

當改用常數時,上面的寫法就會出現新問題:


#![allow(unused)]
fn main() {
const X: u32 = ...;
const Y: u32 = ...;
const FOO: SomeType = if X > Y {
    const A: u32 = X - Y;
    ...
} else {
    const B: u32 = Y - X;
    ...
};
}

AB 會比 FOO 先被求值,因為常數在定義上就是「常數」,所以不應被求值順序影響。這項假設在有錯誤的情況下並不成立,因為錯誤屬於副作用(side effects),因此不純(pure)。

為了解決此問題,我們必須把中介常數消掉並改為直接對 X - YY - X 求值。


#![allow(unused)]
fn main() {
const X: u32 = ...;
const Y: u32 = ...;
const FOO: SomeType = if X > Y {
    let a = X - Y;
    ...
} else {
    let b = Y - X;
    ...
};
}

技術文件式解說

if 或是在 variant 沒有欄位的 enums 上做 match 會在 HIR -> MIR 階段時,被轉譯成 switchInt 終止器(terminator)。Mir 直譯器現在將會針對那些終止器求值(之前就可以了)。

在 variant 沒有欄位的 enums 上做 match 會被轉譯成 switch,表示他會被檢查 discriminant 或是在 packed enums(例如 Option<&T>)的情況會運算 discriminant(這個情況 discriminant 沒有特別的記憶體位址,但他會把所有的零視為 None,並把其他的值都當作 Some)。當進入 match 的分支時,匹配上的值基本上會被 transmute 成 enum 的 variant 型別,如此一來可以允許其他程式碼來存取該 enum 的欄位。

缺點

這項功能容易造成任意「常數」值(如:size_of::<T>() 或是特定的平台常數)編譯失敗。

原理及替代方案

利用中介 const fns 來破壞立即常數求值(eager const evaluation)

如果寫成


#![allow(unused)]
fn main() {
const X: u32 = ...;
const Y: u32 = ...;
const AB: u32 = if X > Y {
    X - Y
} else {
    Y - X
};
}

X - Y 或是 Y - X 其中一方有可能會報錯,這時必須加入中介 const fn


#![allow(unused)]
fn main() {
const X: u32 = ...;
const Y: u32 = ...;
const fn foo(x: u32, y: u32) -> u32 {
    if x > y {
        x - y
    } else {
        y - x
    }
}
const AB: u32 = foo(X, Y);
}

const fn 的 xy 參數未知,無法做常數求值(const evaluate)。當提供此 const fn 參數並求值時,只會對相應的分支求值。

未解決問題

總結

Cargo.toml 的 package 區塊加入 rust 欄位,用於指定 crate 的最低支援 Rust 版本(Minimum Supported Rust Version,MSRV)。

[package]
name = "foo"
version = "0.1.0"
rust = "1.30"

動機

當前 crate 無任何正式方法指定 MSRV,導致使用者無法在不建構該 crate 的情況下得知 crate 是否可透過他們的工具鏈(toolchain)建構。這也帶來有關如何在提升 MSRV 時管理 crate 版本的爭論,保守的作法是將之視為破壞性改動,這種作法會阻礙整個生態採納新功能,或使得版本號通膨式提升,進而讓下游的 crate 很難跟上。另一方面,若作法稍微寬鬆些,則導致採用較舊編譯器版本的使用者無法成功編譯它們的 crate。

教學式解說

若你想指定一個特定 MSRV 版本,請在 Cargo.toml[package] 區塊中的 rust 欄位設定指定的 Rust 版本。如果你建構一個 crate 時有任一依賴要求的 MSRV 比你當前工具鏈更高,會導致編譯錯誤,指明該依賴與它的 MSRV。這個行為可以透過 --no-msrv-check 選項來停用。

技術文件式解說

在建構過程(包含 runtestbenchmarkverifypublish 子指令),cargo 會以依賴樹(dependency tree)的形式,檢查所有將要建構或檢查的所有 crate 之 MSRV 要求。在依賴樹內但不會被構建的 crate 不會執行此項檢查(例如特定目標平台或可選的 crate)。

rust 欄位應至少遵循下列要求:

  • 其值需遵守語意化版號且不能有範圍運算子。注意,「1.50」是合法的值,代表「1.50.0」。
  • 版本不能比當前 stable 工具鏈高(上傳 crate 時 crates.io 會檢查)。
  • 版本不能低於 1.27(此版本將 package.rust 欄位從錯誤改為警告)。
  • 版本不能比選用的 edition 釋出的版本低,舉例來說,同時出現 rust = "1.27"edition = 2018 是非法的。

未來展望與延伸

對版本解析之影響

rust 欄位之值(手動設定或 cargo 自動選擇)會用於選擇合適的依賴版本。

舉例來說,想像你的 crate 相依 crate foo,一個發佈了從 0.1.00.1.9 十個版本的 crate,其中 0.1.00.1.5 版在 crates.io 上 Cargo.tomlrust 欄位為「1.30」,其他版本則為「1.40」。現在,你建構一個專案用了例如 Rust 1.33 版,cargo 會選用 foo v0.1.5foo v0.1.9 只會在你用 Rust 1.40 或更高版本建構專案時選用。倘若你嘗試使用 Rust 1.29 建構專案,cargo 會回報錯誤。

rust 欄位值也會被檢核。在 crate 建構過程,cargo 將檢查所有上游相依是否可以在指定 MSRV 下建構。(例如,檢查給定的 crate 與 Rust 版本限制條件下是否存在解)已除去(yank)的 crate 會略過這個檢查步驟。

期望實作這項功能得以替長久以來對 MSRV 提升是否為破壞性改動的爭論劃下休止符,並讓 crate 作者提升 crate 的 MSRV 不再如此綁手綁腳。(儘管對於已 1.0 的 crate 來說,透過提升修訂號(patch version)來提升 MSRV 次版號,以接納修復嚴重問題的 backport,可能是個有用的慣例)

注意,上述 MSRV 限制與依賴版本解析檢查,可以透過 --no-msrv-check 選項停用。

發佈時檢查 MSRV

cargo publish 將檢查上傳是以 rust 欄位指定的工具鏈版本完成,若工具鏈版本有異,cargo 會拒絕上傳該 crate。此確保機制避免因為未預期的 MSRV 提升導致錯誤的 rust 欄位值。這項檢查可透過既有的 --no-verify 選項停用。

rust 欄位設為必填

未來(可能是下一個 edition),我們可以設定新上傳的 crate 的 rust 欄位為必填。既有 crate 的 MSRV 會透過 edition 決定。換句話說 edition = 2018 之 MSRV 必然是 rust = "1.31",而 edition = "2015" 則是 rust = "1.0"

cargo init 將會使用當前工具鏈使用的版本。

基於 cfg 的 MSRV

部分 crate 會根據不同目標架構平台或啟用的功能而有不同的 MSRV。可透過以下方式有效指定 MSRV 如何依賴這些配置:

[package]
rust = "1.30"

[target.x86_64-pc-windows-gnu.package]
rust = "1.35"

[target.'cfg(feature = "foo")'.package]
rust = "1.33"

target 區塊中所有 rust 值應等於或高於在 package 區塊的 rust 值。

target 的條件為真,cargo 會取用該區塊的 rust 值。若多個 target 區塊的條件為真,則取用最大值。

Nightly 與 stable 版本

部分 crate 可能偏好在最新 stable 或 nighly 工具鏈,除了指定版本之外,我們可允許宣告 stablenightly 值,讓維護者不需追蹤該 crate 的 MSRV 。

對於某些超前沿的 crate(例如: rocket)常常因為 Nightly 更新就壞,將可指定特定可成功建構的 Nightly 版本。透過下列語法來達成:

  • 自動選擇:nightly 此寫法與寫 stable 的行為一致,將使用等於當前或更高的 nightly 版本。
  • 單一版本:nightly: 2018-01-01 (主要寫法)
  • 列舉:nightly: 2018-01-01, 2018-01-15
  • 類語意化版本條件:nightly: >=2018-01-01nightly: >=2018-01-01, <=2018-01-15nightly: >=2018-01-01, <=2018-01-15, 2018-01-20。(後者會解讀為 「(version >= 2018-01-01 && version <= 2018-01-20) || version == 2018-01-20」)

這些條件或許很嚴苛,盼使用這功能的 crate 一隻手數得出來。

缺點

  • 即使宣告了 MSRV 且檢查過,並無法保持 crate 能夠正確在指定 MSRV 下正確執行,只有合理配置的 CI 能做到此事。
  • 更複雜的依賴版本解析演算法。
  • 使用 cargo publish 配合 MSRV rust = "stable" 恐過於保守。

替代方案

  • 自動計算 MSRV。
  • 不做任何事,依靠 LTS 發行 的 crate MSRV 提升。
  • 允許在 RFC 2523 中提出基於版本與路徑的 cfg 屬性(attribute)

先驅技術

早先的提案:

未解決問題

  • 瑣碎的命名問題:rustrustc 還是 min-rust-version
  • 額外的檢查?
  • 更優質地說明版本解析演算法
  • nightly 版本如何與「基於 cfg 的 MSRV」運作?

總結

透過引進輸入輸出安全性(I/O safety)之概念與一系列新型別與特徵,保障 AsRaw 及相關特徵的使用者對原始資源 handle (raw resource handle)之使用,以彌補 Rust 封裝邊界的漏洞。

動機

Rust 標準函式庫幾乎算是已經提供了輸入輸出安全性,保證程式的一部分若私自持有一個原始 handle (raw handle),其他部分就無法存取。例如 FromRawFd::from_raw_fd 標示為不安全,它不允許使用者在 safe Rust 之下執行如 File::from_raw_fd(7) 等操作,或是在程式各處私自持有檔案描述子(file descriptor)來執行輸入輸出。

不過仍有漏網之魚。許多函式庫的 API 透過接受 AsRawFd/IntoRawFd 來執行輸入輸出操作:


#![allow(unused)]
fn main() {
pub fn do_some_io<FD: AsRawFd>(input: &FD) -> io::Result<()> {
    some_syscall(input.as_raw_fd())
}
}

AsRawFd 並無限制 as_raw_fd 的回傳值,所以 do_some_io 最終會對任意 RawRd 的值執行輸入輸出。由於 RawFd 本身實作了 AsRawFd,甚至可以寫出 do_some_io(&7)

這會使得程式存取錯誤的資源,更甚者為其他地方私有的原始 handle 建立多個別名(alias),從而打破封裝的邊界,造成遠處來的詭異行為

而在特殊情況下,違反輸入輸出安全性恐導致違反記憶體安全性。舉例來說,理論上替透過 Linux memfd_create 系統呼叫建立出來的檔案描述子打造 mmap 的安全封裝,並將之傳給 safe Rust 是可行的,畢竟它就是匿名的被開啟的檔案(anonymous open file),表示其他行程(process)無法存取之。然而,在沒有輸入輸出安全性且沒有永久封閉該檔案的情況下,該程式中其他程式碼恐意外地對該檔案描述子呼叫 writeftruncate,進而打破記憶體安全性 &[u8] 不變的規則。

這個 RFC 透過以下幾點,開闢一條逐步關閉此漏洞的道路:

  • 一個新概念:輸入輸出安全性。其概念會撰寫在標準函式庫文件中。
  • 一系列全新的型別與特徵。
  • from_raw_fd/from_raw_handle/from_raw_socket 撰寫新文件,解釋就輸入輸出安全性而言,它們為何不安全,順便解決出現好的相同問題。

教學式解說

輸入輸出安全性概念

Rust 標準函式庫提供了低階型別,以表示原始的作業系統資源 handle:類 Unix 平台的 RawFd 和 Windows 上的 RawHandleRawSocket。然而,它們並沒有提供任何自身的行為,而是僅作為一個標識符(identifier),並在低階的作業系統 API 間傳遞。

這些原始 handle 可以視為原始指標(raw pointer)且具有相同的危險性。雖然取得一個原始指標是安全的,但當該原始指標是非法指標,或是比其指向記憶體之處活得更久時,對原始指標取值(dereference)都可能引發未定義行為(undefined behavior)。無獨有偶,透過 AsRawFd::as_raw_fd 或類似方法取得一個原始 handle 是安全的,但當它並非合法 handle 或在關閉之後才拿來用時,用該 handle 來執行輸入輸出恐導致「損毀的輸出結果」、「遺失或洩漏輸入資料」或「違反封裝的邊界」。在這兩個案例中,影響不僅限於本地,也會影響程式的其他部分。保護原始指標免於危險稱作記憶體安全性,所以保護原始 handle 免於危險我們叫它輸入輸出安全性

Rust 標準函式庫也有高階型別如 FileTcpStream 來提供高階的作業系統 API 界面,這些型別包裝了原始 handle。

這些高階型別同時實作了在類 Unix 平台的 FromRawFd 特徵,以及 Windows 上的 FromRawHandle/FromRawSocket 。這些特徵提供許多函式將底層的值封裝產生出高階的值。由於這些函式無法確保輸入輸出的安全性,因此標示為不安全。型別系統並不會限制這些型別傳入:


#![allow(unused)]
fn main() {
    use std::fs::File;
    use std::os::unix::io::FromRawFd;

    // 建立一個檔案。
    let file = File::open("data.txt")?;

    // 從任意整數值構建一個 `File`,這個型別能通過檢查,但 7 可能在執行期間無法
    // 被識別為任何活生生的資源,或它可能不慎指向程式其他地方封裝好的原始 handle
    // 一個 `unsafe` 區塊告知呼叫者對此有責任,需使其免於這些風險。
    let forged = unsafe { File::from_raw_fd(7) };

    // 取得一個 `file` 內部的原始 handle 的副本。
    let raw_fd = file.as_raw_fd();

    // 關閉 `file`。
    drop(file);

    // 開啟一些無關的檔案。
    let another = File::open("another.txt")?;

    // 其他對 `raw_fd`(也就是 `file` 內部的原始 handle)的使用有可能使其生命週
    // 期長於與作業系統的關聯。這恐導致意外建立別名指向其他封裝起來的 `File`
    // 實例,例如 `another`。因此,一個 `unsafe` 區塊告知呼叫者對此有責任,需使
    // 其免於這些風險。
    let dangling = unsafe { File::from_raw_fd(raw_fd) };
}

呼叫端必須確保傳入 from_raw_fd 的值一定是從作業系統回傳所得,並且 from_raw_fd 的回傳值的生命週期不會長於和作業系統關聯的 handle。

雖然將輸入輸出安全性作為明確的概念是個新作法,但其實它也反映出許多常見的實踐。除了引入一些新型別與特徵及其實作外,Rust std 並不需改變已經穩定的界面。在推行之初,並不需讓整個 Rust 生態系馬上支援輸入輸出安全性,而是可以漸進式地採用之。

OwnedFdBorrowedFd<'fd>

這兩個型別概念上將取代 RawFd,並分別表示擁有和借用的 handle 值。OwnedFd 擁有一個檔案描述子,當 OwnedFd 釋放時就會關閉其檔案描述子。BorrowedFd 的生命週期標示該檔案描述子被借用多久。這些型別皆會自動實施其輸入輸出安全性不變的規則。

至於在 Windows 上,會以 HandleSocket 形式呈現相應的型別。

這些型別在輸入輸出扮演的角色可類比 Rust 既有的記憶體管理相關型別:

型別類似於
OwnedFdBox<_>
BorrowedFd<'a>&'a _
RawFd*const _

不過兩者還是有差,輸入輸出安全性並不區分可不可變。在 Rust 的掌控之外,作業系統資源能以各種形式共享,所以輸入輸出可以視為使用了內部可變性

AsFdInto<OwnedFd>From<OwnedFd>

這三個型別概念上,在大多數用例中分別取代 AsRawFd::as_raw_fdIntoRawFd::into_raw_fd,以及 FromRawFd::from_raw_fd。它們依據 OwnedFdBorrowedFd 來運作,所以也會自動實施其輸入輸出安全性不變的規則。

使用這些型別後,就能避免在動機一節的 do_some_io 範例中提及的問題。由於只有合理擁有或借用檔案描述子的型別能實作 AsFd,所以這個版本的 do_some_io 不需要擔心偽造或迷途(dangling)的檔案描述子傳入。


#![allow(unused)]
fn main() {
pub fn do_some_io<FD: AsFd>(input: &FD) -> io::Result<()> {
    some_syscall(input.as_fd())
}
}

至於在 Windows 上,會以 HandleSocket 形式呈現相應的型別。

漸進式採用

輸入輸出安全性及其新型別與新特徵並不需要一次性全面導入,而是能夠分階段漸進採用:

  • 首先,在 std 新增這些新型別與新特徵,並替相關的型別實作之。這是向下相容的改變。
  • 在此之後,crate 可以開始用這些新型別,並替 crate 自己的型別實作這些新特徵。這些改變相對小,而且符合語意化版號相容性,不需其他特殊處理。
  • 當標準函式庫和夠多的熱門 crate 實作這些新特徵後,其他 crate 可按照它們的開發步調,將這些新特徵作為泛型引數的限定條件(bound)。雖然這些改變不符合語意化版號的相容性,不過多數 API 使用者改用新特徵時並不需改變程式碼。

技術文件式解說

輸入輸出安全性概念

Rust 語言除了有記憶體安全性之外,Rust 標準函式庫同時提供了對輸入輸出安全性的保證。一個合法的輸入輸出操作,其所操作的原始 handle(RawFdRawHandleRawSocket)必為明確從作業系統回傳之值,且這些操作僅發生在與作業系統關聯之生命週期內。當一段 Rust 程式碼宣稱輸入輸出安全性,代表該程式碼不可能導致非法的輸入輸出操作。

雖然有些作業系統的文件中說明其檔案描述子的配置演算法,但從這些演算法旁敲側擊出來的 handle 值並不會視為「明確從作業系統回傳之值」。

對接受任意原始輸入輸出 handle 值(RawFdRawHandleRawSocket)的函式,若安全的 API 會藉由這些 handle 執行輸入輸出,應標示為 unsafe

OwnedFdBorrowedFd<'fd>

OwnedFdBorrowedFd 皆為 repr(transparent),並帶有一個 RawFd 值,且兩者皆可應用區位最佳化(niche optimizations),所以和 Option<OwnedFd>Option<BorrowedFd<'_>> 的大小相同,而且可在 FFI 宣告的函式中使用,例如 open, read, write, close 等。若以上述方法使用之,它們將確保 FFI 邊界的輸入輸出安全性。

這些型別同時會實作既有的 AsRawFdIntoRawFd,以及 FromRawFd 特徵,所以它們可和既有程式碼的 RawFd 型別交互使用。

AsFdInto<OwnedFd>From<OwnedFd>

這些型別提供 as_fdintofrom 函式,類似於 AsRawFd::as_raw_fdIntoRawFd::into_raw_fdFromRawFd::from_raw_fd

原型實作

上述所有原型放在:

https://github.com/sunfishcode/io-lifetimes

README.md 有文件鏈結、範例、和當前提供類似功能的 crate 之調查研究。

缺點

Crate 若用到檔案描述子,如 nixmio,將需要遷移到有實作 AsFd 的型別,或將這類函式標示為不安全。

crates 若用 AsRawFdIntoRawFd 來接收任何「類檔案」或「類 socket」型別,如 socket2SockRef::from,將需換成 AsFdInto<OwnedFd>,或將這類函式標示為不安全。

原理及替代方案

有關「unsafe 僅為了記憶體安全性」

Rust 有個慣例:unsafe 只適用於標示記憶體安全性。舉個有名的案例, std::mem::forget 曾標示為不安全,但後來被改為安全,且其結論指出 unsafe 僅該用作標示記憶體安全性,而不該用來標示可能作繭自縛的情況或恫嚇應避免使用的 API 上。

記憶體安全性造成的危害比其他程式設計面向更甚,它不僅要避免意外行為發生,且仍需避免無法限制一段程式碼能做什麼。

輸入輸出安全性也落在這個範疇,有兩個因素:

  • (若作業系統存在 mmap 相關 API)在安全封裝的 mmap 中,輸入輸出安全性的錯誤仍會導致記憶體安全性的錯誤。
  • 輸入輸出安全性之錯誤也意味著一段程式碼可以在沒有通知或被授予任何引用的情形下,讀寫或刪除程式其他部分正在使用的資料。這使得在無法通曉一個 crate 鏈結到的其他 crate 的所有實作細節下,非常難以限制這個 crate 可以做什麼。

原始 handle 更像指向單獨的位址空間(address space)的原始指標,它們可能迷途(dangle)或造假。輸入輸出安全性近似於記憶體安全性,兩者皆竭力杜絕遠處來的詭異行為(spooky-action-at-a-distance),且對兩者來說,所有權都可作為建立穩健抽象化的主要根基,所以自然而然共用了相似的安全性概念。

將輸入輸出 Handle 當作純資料

主要的替代方案的說法是原始 handle 為純資料(plain data),並沒有輸入輸出安全性,和作業系統資源的生命週期也無任何與生俱來的關係。至少在類 Unix 平台,這些永遠不會導致記憶體不安全或是未定義行為。

不過,大部分的 Rust 程式碼不直接與原始 handle 互動。撇開本 RFC 不談,不與原始 handle 互動是件好事。所謂資源,一定會有生命週期,若大部分的 Rust 程式碼能使用各方面都更易上手又能自動管理生命週期的更高階的型別,這樣鐵定更棒。不過,純資料的方案對於相對不常見的案例,最多只能讓原始 handle 的操作容易撰寫些。這可能只是蠅頭小利,甚至可能是個缺點,有可能最後變相鼓勵大家在不需要時去用了原始 handle 。

純資料的方案亦不需要變更任何 crate 的程式碼。而輸入輸出安全性的方案則需改動如 socket2nixmio 這些用到 AsRawFdRawFd 的 crate,不過這改動可以漸進推廣到整個生態系,而不必一次性完成。

IoSafe 特徵(與它的前身 OwnsRaw

這個 RFC 在早先幾個版本提議過一個 IoSafe 特徵,這個特徵會帶來小程度但具侵入性的修復。來自該 RFC 的回饋促使一系列新型別與特徵的開發。這個開發牽扯更廣的 API 範圍,也意味著需要更多設計和審核。並且隨著時間推移,整個 crate 生態系需要更大規模的改動。然而,早期跡象指出,本 RFC 引入的新型別與特徵更易理解,使用上更順手且安全,所以長期來說有更穩健的基礎。

IoSafe 早期叫做 OwnsRaw。我們很難替這個特徵找到恰到好處的名字,這也許是個訊號,表示它並非良好設計的特徵。

先驅技術

大部分記憶體安全的程式語言都對原始 handle 做了安全的抽象層。多數情況下,它們僅是簡單地避免暴露原始 handle,例如 C#Java 等。若將透過原始 handle 執行輸入輸出標示為不安全,可讓 safe Rust 與這些程式語言達到相同程度的安全保證。

在 crates.io 上有好幾個 crate 封裝了擁有或借用的檔案描述子。io-lifetimes 的 README.md 中 Prior Art 一節詳細描述了其與其他既有 crate 的同異。從高層次角度來看,既有的 crate 都與 io-lifetimes 共享相同的基本概念。這些 crate 都圍繞著 Rust 生命週期與所有權概念打造,而這恰恰說明這些概念非常適合這個問題。

Android 有特殊的 API 會偵測不恰當的 close,詳見 rust-lang/rust#74860。這些 API 的動機就是一種輸入輸出安全性的應用。Android 的特殊 API 用了動態檢查,讓它們可以在跨來源語言的邊界實施這些規則。本 RFC 提議的輸入輸出安全性的型別和特徵只專注在 Rust 程式碼本身實施這些規則,所以它們可在編譯期間利用 Rust 的型別系統實施這些規則,而非延遲到執行期間。

未解決問題

形式化所有權

此 RFC 並沒有為原始 handle 的所有權和生命週期定義一個形式化模型(formal model)。這 RFC 對原始 handle 規範之定位尚不明朗。當 handle 只是整數型別時,與其關聯資源之生命週期意義為何?所有具有相同值的整數型別會共享該關連嗎?

Rust 參考手冊根據 LLVM 的指標別名規則定義了記憶體的未定義行為;輸入輸出可能需要類似的 handle 別名規則。這對目前的實際需求而言似非必要,但未來可對此進行探索。

未來展望

以下包含一些從此 RFC 延伸的可能想法:

  • Clippy 警吿常見的輸入輸出不安全狀況。

  • 一個原始 handle 所有權形式化模型。可想像為延伸 Miri 使其揪出「關閉後使用」和「使用偽造的 handle」這類錯誤。

  • 一個屬於 Rust,細緻且基於能力的安全模型(capabability-based security model)。藉由此模型提供的保證,在 safe Rust 語境下就不可能偽造出假的原始 handle 高階封裝。

  • 還可替有實作 AsFdInto<OwnedFd>From<OwnedFd> 的型別添加一些方便的功能:

    • from_into_fd 函式:取得 Into<OwnedFd> 並將之轉為 From<OwnedFd>,讓使用者一步執行這些常見的轉換步驟。
    • as_filelike_view::<T>() 函式:回傳一個 View,其中包含內部檔案描述子構建出來的暫時實例 T,讓使用者能以 FileTcpStream 等方式查看原始的檔案描述子。
  • 簡單使用情景的可攜性。由於 Windows 有兩種不同的 handle 型別,但 Unix 只有一種,因此在這領域中達成可攜性並非易事。然而,在部分案例中,可將 AsFdAsHandle 一視同仁,而另外一些情況則可以把 AsFdAsSocket 當作相同的。在這兩類情形,普通的 FileLikeSocketLike 抽象化能讓程式碼泛用在 Unix 和 Windows 上。

    類似的可攜性也能推廣到 From<OwnedFd>Into<OwnedFd>

致謝

感謝 Ralf Jung (@RalfJung) 引導我理解這個主題至此,鼓勵我和審核這個 RFC 的草案,並耐心回答我諸多問題!