在其歷史中,.NET 一直努力維持版本之間的高度相容性,並在不同的 .NET 實現之間保持相容性。 雖然 .NET 5(及 .NET Core)及後續版本可視為相較於 .NET 框架的新技術,但有兩個主要因素限制了此 .NET 實作與 .NET 框架的分歧:
- 大量開發者最初或持續開發 .NET Framework 應用程式。 他們期望在各種 .NET 實作中行為一致。
- .NET 標準函式庫專案允許開發者建立針對 .NET Framework 與 .NET 5(及 .NET Core)及後續版本共用的通用 API 的函式庫。 開發者期望 .NET 應用程式中使用的函式庫應與 .NET Framework 應用程式中使用的函式庫行為相同。
除了 .NET 實作間的相容性外,開發者期望同一版本的 .NET 版本間能有高度的相容性。 特別是為早期版本 .NET Core 撰寫的程式碼,應能在 .NET 5 或更新版本上無縫執行。 事實上,許多開發者期望新釋出版本 .NET 中的新 API 也應該與先前推出這些 API 的版本相容。
本文概述影響相容性的變更,以及 .NET 團隊評估每種變更的方式。 了解 .NET 團隊如何處理可能的破壞性變更,對於開啟修改現有 .NET API 行為的拉取請求的開發者尤其有幫助。
以下章節將說明對 .NET API 所做的變更類別及其對應用程式相容性的影響。 變更是允許的()、不允許的✔️(❌),或者需要判斷和評估先前的行為是否可預測、明顯和一致(❓)。
備註
- 除了作為評估 .NET 函式庫變更的指南外,函式庫開發者也可以利用這些標準評估針對多個 .NET 實作與版本的函式庫變更。
- 如需相容性類別的相關信息,例如向前和回溯相容性,請參閱 程式代碼變更如何影響相容性。
修改公共契約
此類別中的變更會修改類型的公用界面範圍。 不允許此類別中的大部分變更,因為它們違反 回溯相容性 (使用舊版 API 開發的應用程式能夠在更新版本上不需重新編譯即可執行)。
型別
✔️ ALLOWED:可以從類型中移除介面實作,當該介面已由基底類型實作時
❓ 需要判定:將新的介面實現新增至類型
這是可接受的變更,因為它不會對現有的用戶端造成負面影響。 類型的任何變更都必須在此處定義的可接受變更界限內運作,才能讓新實作保持可接受。 當新增會直接影響設計者或序列化工具產生無法被下層使用的程式代碼或數據的能力時,必須特別小心。 例如,ISerializable 介面。
❓ 需要判斷:引入新的基類
如果類型未導入任何新的 抽象 成員,或變更現有類型的語意或行為,則可以將類型引入兩個現有型別之間的階層。 例如,在 .NET Framework 2.0 中,DbConnection 類別成為 SqlConnection 的新基底類別,而 c1 / 先前直接衍生自 Component。
✔️ 允許:將類型從某個元件移到另一個元件
舊組件必須標示為指向TypeForwardedToAttribute新組件的標記。
✔️ 允許:將 結構 類型變更為
readonly struct類型不允許將
readonly struct類型變更為struct類型。✔️ 允許:展開類型的可見度
❌ 不允許:變更類型的命名空間或名稱
❌ 不允許:重新命名或移除公用類型
這會中斷所有使用已重新命名或移除類型的程序代碼。
備註
在極少數情況下,.NET 可能會移除公開 API。 更多資訊請參閱 .NET 中的 API 移除。 關於.NET的支援政策資訊,請參見 .NET 支援政策。
❌ 不允許:變更列舉的基礎類型
這是編譯時間和行為中斷性變更,以及可讓屬性自變數無法剖析的二進位中斷性變更。
❌ 不允許:對先前已解封的類型進行密封
❌ 不允許:將介面新增至介面的基底類型集合
如果一個介面實作另一個先前未實作的介面,那麼所有實作原始版本介面的類型的功能都會受到影響。
❓ 需要判斷:移除基類集合中的一個類別或已實現的介面集合中的一個介面
移除介面的規則有一個例外:您可以新增來自已移除介面的衍生介面實作。 例如,如果型別或介面現在實作 IDisposable,則您可以移除 IComponent ,這會實作 IDisposable。
❌ 禁止:將
readonly struct類型更改為 結構 類型然而,可以將
struct類型變更為readonly struct。❌ 禁止:將 struct 類型變更為
ref struct類型,及反向操作❌ 不允許:減少類型的可見度
不過,允許增加類型的可見度。
成員
✔️ 允許:展開非虛擬成員的可見度
✔️ ALLOWED:將抽象成員新增至沒有 可存取 (公用或受保護)建構函式的公用類型,或 類型已密封
不過,將抽象成員新增至具有可存取(公用或受保護)建構函式的型別時是不被允許的,且該型別不能是
sealed。✔️ 允許:將成員移至階層中比移除的類型更高的類別
✔️ 允許:新增或移除覆寫
引入覆寫可能導致先前的使用者在呼叫 基底時忽略覆寫。
✔️ ALLOWED:將建構函式新增至類別,並且如果該類別先前沒有建構函式,則新增一個無參數建構函式
不過,不允許在未加入無參數建構函式 的情況下 ,將建構函式新增至先前沒有建構函式的類別。
✔️ 允許:從
ref readonly變更為ref傳回值(虛擬方法或介面除外)✔️ ALLOWED:將唯讀屬性從欄位中移除,除非該欄位的靜態類型是可變值類型
✔️ 允許:呼叫先前未定義的新事件
❓ 需要謹慎判斷:將新的實例欄位新增至類別
這項變更會影響串行化。
❌ 不允許:重新命名或移除公用成員或參數
這會中斷所有使用已重新命名或移除成員或參數的程序代碼。
這包括從屬性移除或重新命名 getter 或 setter,以及重新命名或移除列舉成員。
❓ 需要判斷:將成員新增至介面
雖然這是個突破性的改變,因為它將最低.NET版本提升到 .NET Core 3.0(C# 8.0),也就是在引入預設介面成員(DIM)時,但允許在介面中新增靜態、非抽象、非虛擬成員。
如果您 提供實作,將新成員新增至現有介面不一定會導致下游元件編譯失敗。 不過,並非所有語言都支援 DIM。 此外,在某些情況下,運行時間無法決定要叫用的預設介面成員。 從 C# 13 開始,
ref struct型別可以實作介面,但無法被框定或轉換成介面型別。 因此,型別ref struct必須為介面的每個實例成員提供顯式實作—不能使用介面提供的預設實作。 要將預設實例成員加入到ref struct所實作的介面中,必須在ref struct中加入相應的實作,這會導致原始碼相容性破壞的變更。 基於這些原因,請在將成員新增至現有介面時使用判斷。備註
如果介面是由
ref struct型別實作的(在 C# 13 及以後版本中可能),將任何預設實例成員加入介面,對於呼叫者來說,這是一個會破壞原始碼的變更。 必須ref struct提供新成員的明確實作;不能退回到預設實作。❌ 不允許:變更公用常數或列舉成員的值
❌ DISALLOWED:變更屬性、欄位、參數或傳回值的類型
❌ 不允許:新增、移除或變更參數的順序
✔️ 允許:將參數更改
ref為ref readonly將參數從
ref改為ref readonly,對於傳遞帶有ref修飾符的現有呼叫站點,是原始碼相容的——這些呼叫無需任何變更即可持續編譯。 與改變ref為in不同,ref readonly參數不會默默允許呼叫者傳遞 r值(非變數);如果參數不是變數,編譯器會發出警告。 現有ref的通話地點仍然有效。❌ 禁止:將參數更改
in為ref readonly呼叫位置若傳遞未加上
inin修飾符(編譯器允許in參數),當參數變更為ref readonly時會收到警告,因為ref readonly要求參數必須以參考方式傳遞。 將警告視為錯誤的來電者將經歷來源破壞性變更。❌ 不允許:重新命名參數(包括變更其大小寫)
這被認為是中斷的原因有兩個:
❌ 不允許:從
ref傳回值變更為ref readonly傳回值❌️ 不允許:在虛擬方法或介面中將傳回值從
ref readonly變更為ref❌ 不允許:對成員新增或移除abstract
❌ 禁止:從成員中移除虛擬關鍵詞
❌ 不允許:將 虛擬 關鍵詞新增至成員
雖然這通常不是重大變更,因為 C# 編譯器傾向於發出呼叫非虛擬方法的 callvirt 中介語言(IL)指令(
callvirt執行 Null 檢查,但正常呼叫不會),但由於多個原因,這種行為並非總是不變:- C# 並不是 .NET 唯一針對的語言。
- 每當目標方法為非虛擬且可能不是 Null 時,C# 編譯程式就會嘗試優化
callvirt為一般呼叫(例如透過 ?. null 傳播運算符存取的方法)。
使方法成為虛擬意味著使用者程式碼通常會以非虛擬方式呼叫它。
❌ 不允許:將虛擬成員抽象化
❌ 不允許:將 sealed 關鍵詞新增至介面成員
新增
sealed至預設介面成員會使它成為非虛擬的,以防止呼叫該成員的衍生型別實作。❌ 不允許:將抽象成員新增至具有可存取(公用或受保護)建構函式且未密封的公用類型
❌ 不允許:從成員新增或移除 靜態 關鍵詞
❌ 不允許:新增多載,排除現有多載並定義不同行為的多載
這會破壞已綁定到先前過載的現有客戶端。 例如,如果類別具有接受 UInt32的單一方法版本,則現有的取用者會在傳遞 Int32 值時成功系結至該多載。 不過,如果您新增接受 Int32的多載,在重新編譯或使用晚期系結時,編譯程式現在會系結至新的多載。 如果出現不同的行為,這可能會造成突破性改變。
❓ 需要判斷:增加 OverloadResolutionPriorityAttribute 現有超載或更改其優先權值
對來源層級的過載解析有影響的 OverloadResolutionPriorityAttribute:重新編譯的呼叫者可能會解析至與先前不同的過載。 目的是將該屬性加入到一個新的、更好的重載方法,使編譯器偏好它而非現有的方法。 將它加入現有的過載或更改已賦予屬性的過載的優先權值,可能會造成原始碼變更,因為重新編譯的呼叫者可能會改變行為。
✔️ 允許:將反約束加入
allows ref struct一般型態參數新增
allows ref struct參數後,允許類型ref struct參數,擴展了可用作為型別參數的類型。 使用非ref struct型別參數的現有呼叫者不會受到影響。 通用方法或類型必須對所有該類型參數的實例遵循參考安全規則。❌ DISALLOWED:移除
allows ref struct一般型別參數中的反約束移除
allows ref struct會限制呼叫者能用作型別引數的型別。 任何將ref struct傳遞為型別參數的呼叫器將不再編譯。❌ DISALLOWED:將建構函式新增至先前沒有建構函式的類別,而不需新增無參數建構函式
❌️ 不允許:將 只讀 新增至欄位
❌ 不允許:減少成員的可見度
這包括在有可存取的建構函式(或)而類型未密封的情況下,減少受保護成員的可見度。 如果情況並非如此,則允許減少受保護成員的可見度。
允許增加成員的可見度。
❌ 不允許:變更成員的類型
無法修改方法的傳回值或屬性或欄位的類型。 例如,傳回 Object 之方法的簽章無法變更為傳回 String,反之亦然。
❌ 不允許:將實例欄位新增到沒有任何非公用欄位的結構中
如果結構只有公用欄位或完全沒有欄位,則呼叫方可以宣告該結構類型的本地變數,而不需要呼叫結構的建構函式或先初始化本地變數為
default(T),只要在首次使用之前將所有公用欄位都設置在結構上即可。 將任何新的欄位 - 公用或非公用 - 新增至這類結構是這些呼叫端的來源中斷性變更,因為編譯程式現在會要求初始化其他欄位。此外,將任何新的欄位-- 無論是公用或非公用 -- 新增至沒有欄位或只有公用欄位的結構體,對已套用
[SkipLocalsInit]至其程式碼的呼叫端而言,將造成二進位相容性中斷。 由於編譯器在編譯時期不知道這些欄位,因此可能會產生未完全初始化結構體的中間語言 (IL),導致結構體從未初始化的堆疊數據中建立。如果結構有任何非公用字段,編譯程式已經透過建構函式 或
default(T)強制執行初始化,而且新增實例字段不是重大變更。❌ 不允許:觸發以前從未觸發過的現有事件
行為變更
組件
✔️ 允許:在仍支援相同平臺時,使組件具備可攜性
❌ 不允許:變更元件的名稱
❌ 不允許:修改組件的公鑰
屬性、欄位、參數和傳回值
✔️ ALLOWED:將屬性、字段、傳回值或 out 參數的值變更為更衍生的類型
✔️ ALLOWED:如果成員不是虛擬,請增加屬性或參數可接受的值範圍
雖然可以傳遞至方法或成員傳回的值範圍可以展開,但參數或成員類型無法展開。 例如,雖然傳遞至方法的值可以從 0-124 展開至 0-255,但參數類型無法從 Byte 變更為 Int32。
❌ DISALLOWED:如果成員為虛擬,請增加屬性或參數的已接受值範圍
這項變更會中斷現有的覆寫成員,這些成員在擴展的數值範圍內將無法正常運作。
❌ DISALLOWED:縮減屬性或參數的接受值範圍
❌ 不允許:增加屬性、欄位、傳回值或 out 參數的傳回值範圍
❌ 禁止:變更屬性、欄位、方法回傳值或 out 參數
❌ 不允許:變更屬性、欄位或參數的預設值
更改或移除 參數 的預設值不會造成二進位相容性問題。 拿掉參數預設值是來源中斷,而變更參數預設值可能會導致重新編譯后的行為中斷。
基於這個理由,移除參數預設值在特定案例中是可接受的,即「將」這些預設值移至新的方法多載,以消除模棱兩可。 例如,請考慮現有的方法
MyMethod(int a = 1)。 如果您引入帶有兩個選擇性參數MyMethod和a的重載,您可以將b的預設值移至新的重載,以保留相容性。 現在兩個重載是MyMethod(int a)和MyMethod(int a = 1, int b = 2)。 此模式允許MyMethod()編譯。❌ 不允許:變更數值傳回值的精度
❓ 需要判斷:輸入解析和拋出新例外狀況的變更(即使文件中未指定解析行為)
❌ 禁止:從聲明中新增或移除案件類型
union從
union類型新增或移除案例類型同時會造成二進位中斷和源代碼中斷。加入案例類型後,模式匹配測試不再是完整的。 編譯器會將模式匹配表達式標記為非窮盡式。 執行時,非預期值會導致運行時例外。 移除一個案例類型會移除該案件類型的建構子宣告。
例外狀況
✔️ 允許:擲回比現有例外狀況更多的衍生例外狀況
因為新的例外狀況是現有例外狀況的子類別,因此先前的例外狀況處理程式碼會繼續處理例外狀況。 例如,在 .NET Framework 4中,如果無法找到文化資訊,建立和檢索方法會開始拋出 CultureNotFoundException,而不是 ArgumentException。 由於 CultureNotFoundException 衍生自 ArgumentException,因此這是可接受的變更。
✔️ 允許:丟出比NotSupportedException、NotImplementedException、NullReferenceException更具體的例外
✔️ 允許:拋出被視為無法復原的例外
無法復原的例外狀況不應攔截,而應由高階的全局處理程序負責處理。 因此,使用者不應該有攔截這些明確例外狀況的程序代碼。 無法復原的例外狀況如下:
✔️ 允許:在新程式碼路徑中拋出新的例外
例外狀況只能套用至以新參數值或狀態執行的新程式代碼路徑,而且無法由以舊版為目標的現有程式代碼執行。
✔️ 允許:移除例外狀況以啟用更健全的行為或新案例
例如,
Divide方法先前只處理正值,並在其他情況下擲回一個 ArgumentOutOfRangeException,可以更改為同時支援負值和正值,而不擲回例外。✔️ 允許:變更錯誤訊息的文字
開發人員不應依賴錯誤訊息的文字,這也會根據使用者的文化特性而變更。
❌ 不允許:在上述未列出的任何其他情況下拋出例外
❌ 不允許:在未列出的任何其他情況中移除例外
屬性
✔️ ALLOWED:變更 非可觀察的 屬性值
❌ 不允許:變更 可觀察的屬性 值
❓ 需要判斷:移除屬性
在大部分情況下,移除屬性 (例如 NonSerializedAttribute) 是重大變更。
平台支援
✔️ 允許:在先前不支援的平臺上支持作業
❌ 不允許:不支援或現在要求特定 Service Pack 執行先前在平台上支援的作業
內部實作變更
❓ REQUIRES 判斷:變更內部類型的表面積
雖然這類變更通常被允許,但它們會中斷私有反射。 在某些情況下,當熱門的第三方程式庫或大量開發人員依賴內部 API 時,可能無法進行這類變更。
❓ 需要判斷:變更成員的內部實作
雖然這些變更會中斷私人反映,但通常允許這些變更。 在某些情況下,客戶程式代碼經常相依於私人反映,或變更引入非預期的副作用時,可能不允許這些變更。
✔️ 允許:改善作業的效能
修改作業效能的能力很重要,但這類變更可能會中斷依賴作業目前速度的程序代碼。 這特別適用於相依於異步作時間的程序代碼。 性能改變應不會影響所提及的 API 的其他行為;否則,此變更將會破壞。
✔️ 允許:間接變更作業效能(且通常是不利的)
如果所提到的變更未因其他原因被分類為重大改動,那麼這是可以接受的。 通常,需要採取可能包含額外作業或新增新功能的動作。 這幾乎總是會影響效能,但使該API能如預期運作可能是必要的。
❌ 不允許:將同步 API 變更為異步 (反之亦然)
程式碼變更
✔️ 允許:將 params 新增為參數的一部分
❌ 不允許:將 checked 語句新增至程式代碼區塊
這項變更可能會導致先前執行的程式碼拋出 OverflowException ,而這是不可接受的。
❌ 禁用:從參數移除屬性
❌ 禁止:更改參數的
params集合類型從 C# 13 開始,
params參數支援非陣列集合類型,包括 Span<T>、 ReadOnlySpan<T>、 結構體或類別類型,這些類型透過可存取的無參數建構子與實例IEnumerable<T>方法實作Add,以及特定的介面類型如 IList<T>。 改變現有params參數的集合類型(例如從params T[]到params ReadOnlySpan<T>),會改變方法的 IL 簽名,且屬於二進位破壞性變更。 根據前一個版本編譯的呼叫器必須重新編譯。✔️ 允許:將擴充方法轉換為 擴展區塊成員語法
從 C# 14 開始,除了舊有的
this-參數語法之外,還可以使用extension區塊來宣告擴充成員。 兩種形式產生相同的 IL,因此來電者無法區分。 將現有擴充方法轉換為新的擴充區塊語法是二進位且與原始碼相容的。❌ 不允許:更改觸發事件的順序
開發人員可以合理預期事件會以相同順序引發,而開發人員程式代碼經常取決於引發事件的順序。
❌ 不允許:移除指定動作的事件引發
❌ 不允許:變更呼叫指定事件的次數
❌ 不允許:將 FlagsAttribute 加入列舉型別