必要成員
注意
本文是功能規格。 規格可作為功能的設計檔。 其中包含建議的規格變更,以及功能設計和開發期間所需的資訊。 這些文章會發佈,直到提議的規格變更完成並併併入目前的ECMA規格為止。
功能規格與已完成實作之間可能有一些差異。 這些差異已記錄在 語言設計會議(LDM)的相關會議記錄中。
總結
此提案提出了加入這樣一種方法,以指定在物件初始化期間必須設定屬性或欄位,強制實例創建者在創建時的物件初始化器中提供成員的初始值。
動機
物件階層目前需要大量樣板代碼,才能在階層中的所有層級上傳輸數據。 讓我們看看與 C# 8 中定義的 Person
相關的簡單階層:
class Person
{
public string FirstName { get; }
public string MiddleName { get; }
public string LastName { get; }
public Person(string firstName, string lastName, string? middleName = null)
{
FirstName = firstName;
LastName = lastName;
MiddleName = middleName ?? string.Empty;
}
}
class Student : Person
{
public int ID { get; }
public Student(int id, string firstName, string lastName, string? middleName = null)
: base(firstName, lastName, middleName)
{
ID = id;
}
}
這裡充滿了重複:
- 在階層的根部,每個屬性的類型必須重複兩次,而名稱必須重複四次。
- 在衍生層級,每個繼承屬性的類型必須重複一次,而且名稱必須重複兩次。
這是一個具有 3 個屬性和 1 個繼承層級的簡單階層,但許多這類型的真實世界範例會深入許多層級,並在繼承過程中累積愈來愈多的屬性數目。 Roslyn 就是在各種樹狀結構中構建我們的 CST 和 AST 的一個程式碼,例如。 這個巢狀結構相當乏味,因此我們有程式代碼產生器來產生這些類型的建構函式和定義,許多客戶會採用與問題類似的方法。 C# 9 引進紀錄型別,在某些案例中可改進資料結構:
record Person(string FirstName, string LastName, string MiddleName = "");
record Student(int ID, string FirstName, string LastName, string MiddleName = "") : Person(FirstName, LastName, MiddleName);
record
會排除第一個重複來源,但第二個重複來源保持不變:不幸的是,這個重複來源會隨著階層的增長而增加,並且在修改階層後是最難解決的部分,因為需要追蹤所有位置中的階層改變,甚至可能跨專案,潛在地影響使用者。
為了避免這種重複,我們早已看到使用者開始使用物件初始化表達式作為避免撰寫建構函式的方式。 不過,在 C# 9 之前,這有 2 個主要缺點:
- 物件階層必須是完全可變動的,且每個屬性都有
set
存取子。 - 無法確保在圖表中物件的每次具現化都會設定所有成員。
C# 9 在這裡再次解決了第一個問題,方法是引進 init
存取子:透過它,這些屬性可以在物件建立/初始化上設定,但之後不會設定這些屬性。 不過,我們還是有第二個問題:C# 中的屬性自 C# 1.0 起一直是選擇性的。 C# 8.0 引進的可空參考型別解決了這個問題的部分:如果建構函式未初始化不可空的參考型別屬性,系統將警告使用者。 不過,這無法解決問題:這裡的使用者想要不要在建構函式中重複其類型的大部分,他們想要傳遞 需求,將屬性設定為取用者。 它也不會提供任何來自 ID
的 Student
警告,因為那是實值型別。 這些情境在資料庫模型的物件關聯對映(ORM)中非常常見,例如 EF Core,需要有一個公用的不帶參數的建構函式,然後根據屬性的可空性來決定資料列的可空性。
此提案通過在 C# 中引入新功能——必要成員來解決這些疑慮。 必須的成員將由使用者而非類型作者初始化,並提供各種自定義以允許多個建構函式和其他情境的靈活性。
詳細設計
class
、struct
和 record
型別可宣告 所需成員列表。 此清單列出所有類型的屬性和欄位,這些屬性和欄位被視為 必要,並且必須在類型實例的建構和初始化過程中初始化。 類型會自動從其基底類型繼承這些清單,以提供順暢的使用體驗,並移除冗長和重複的代碼。
required
修飾詞
我們會將 'required'
新增至 field_modifier 和 property_modifier中的修飾詞清單。 類型的 required_member_list 是由所有已被套用 required
的成員所組成。 因此,先前的 Person
類型現在看起來像這樣:
public class Person
{
// The default constructor requires that FirstName and LastName be set at construction time
public required string FirstName { get; init; }
public string MiddleName { get; init; } = "";
public required string LastName { get; init; }
}
具有 required_member_list 之型別上的所有建構函式都會自動公告 合約, 類型取用者必須初始化清單中的所有屬性。 建構函式宣告合約時,如果合約要求的成員不至少與建構函式本身同樣可存取,這就是一個錯誤。 例如:
public class C
{
public required int Prop { get; protected init; }
// Advertises that Prop is required. This is fine, because the constructor is just as accessible as the property initer.
protected C() {}
// Error: ctor C(object) is more accessible than required property Prop.init.
public C(object otherArg) {}
}
required
在 class
、struct
和 record
類型中才有效。 在 interface
類型中無效。
required
無法與下列修飾詞結合:
fixed
ref readonly
ref
const
static
不允許將 required
套用至索引器。
當 Obsolete
套用至型別的必要成員時,編譯程式會發出警告:
- 類型未標示為
Obsolete
、 或 - 任何未使用
SetsRequiredMembersAttribute
屬性的建構函式都不會標示為Obsolete
。
SetsRequiredMembersAttribute
類型中具有必要成員或基底類型指定必要成員的所有建構函式,在呼叫該建構函式時,必須讓取用者設定這些成員。 為了讓建構函式免除此需求,可以使用 SetsRequiredMembersAttribute
屬性來移除這些需求。 建構函式主體沒有被驗證以確保它可以確實設定類型的必需成員。
SetsRequiredMembersAttribute
會從建構函式中移除 所有 需求,而且這些需求不會以任何方式檢查是否有效。 NB:如果必須從具有無效必要成員列表的類型繼承,請視此為緊急插口:將該類型的建構函式標註為 SetsRequiredMembersAttribute
,這樣就不會報告任何錯誤。
如果建構函式 C
鏈結至具有 base
屬性的 this
或 SetsRequiredMembersAttribute
建構函式,那麼 C
也必須被賦予 SetsRequiredMembersAttribute
屬性。
對於記錄類型,如果記錄類型或其任何基底類型具有必要的成員,我們會在記錄的合成複製建構函式上發出 SetsRequiredMembersAttribute
。
NB:此提案的早期版本中有一個更大的元語言環繞著初始化,允許在建構函式中新增和移除單個必需的成員,並驗證建構函式是否已設置所有必需的成員。 對於初始版本而言,這被認為太複雜,並已移除。 我們可以將新增更複雜的合約和修改視為後續功能。
執法
對於類型 Ci
中具有必要成員 T
的每個建構函式 R
,呼叫 Ci
的取用者必須執行下列其中一項動作:
- 在
R
的 object_initializer 中設定 的所有成員。 - 或透過
R
區段在 attribute_target中設定 的所有成員。
除非 Ci
被賦予 SetsRequiredMembers
。
如果目前的上下文不允許 object_initializer 或不是 attribute_target,且 Ci
沒有被指定 SetsRequiredMembers
,則呼叫 Ci
是錯誤的。
new()
條件約束
不允許具有無參數建構函式並宣告 合約 的類型被替代為受限於 new()
的類型,因為在泛型實例化過程中,無法確保滿足這些需求。
struct
default
s
在以 struct
或 default
建立的 default(StructType)
型別實例上,不會強制執行必要成員。 即使 struct
沒有無參數建構函式,而且使用預設結構建構函式,也會針對使用 new StructType()
建立的 StructType
實例強制執行它們。
可及性
如果成員無法在包含類型可見的任何情境中設定,則將該成員標記為必須是錯誤的。
- 如果成員是欄位,則不能使用
readonly
。 - 如果成員是屬性,它至少必須有 setter 或 initer,才能與成員的包含類型一樣可存取。
這表示不允許下列情況:
interface I
{
int Prop1 { get; }
}
public class Base
{
public virtual int Prop2 { get; set; }
protected required int _field; // Error: _field is not at least as visible as Base. Open question below about the protected constructor scenario
public required readonly int _field2; // Error: required fields cannot be readonly
protected Base() { }
protected class Inner
{
protected required int PropInner { get; set; } // Error: PropInner cannot be set inside Base or Derived
}
}
public class Derived : Base, I
{
required int I.Prop1 { get; } // Error: explicit interface implementions cannot be required as they cannot be set in an object initializer
public required override int Prop2 { get; set; } // Error: this property is hidden by Derived.Prop2 and cannot be set in an object initializer
public new int Prop2 { get; }
public required int Prop3 { get; } // Error: Required member must have a setter or initer
public required int Prop4 { get; internal set; } // Error: Required member setter must be at least as visible as the constructor of Derived
}
隱藏 required
成員是錯誤,因為該成員無法再由取用者設定。
覆寫 required
成員時,方法簽章必須包含 required
關鍵詞。 如此設計可以在未來若我們希望允許透過覆寫不再需要某個屬性時,保留足夠的設計空間來實現。
允許對基底類型中未標記為 required
的成員 required
進行覆寫。 已將已標示的成員新增至衍生型別的必要成員清單。
允許類型覆寫必要的虛擬屬性。 這表示,如果基底虛擬屬性具有記憶體,且衍生類型嘗試存取該屬性的基底實作,他們可能會觀察到未初始化的記憶體。 NB:這是一般的 C# 反模式,我們不認為這個提案應該嘗試解決這個問題。
對可空性分析的影響
標記為 required
的成員不需要在建構函式的結尾初始化為有效的可為 Null 狀態。 此類型的所有 required
成員以及基底類型中的任何成員在此類型的建構函式開頭都會被「可為 Null 分析」視為預設,除非鏈結至具 this
屬性的 base
或 SetsRequiredMembersAttribute
建構函式。
可為 Null 的分析會警告目前和基底類型的所有 required
成員,這些成員在建構函式的結尾沒有有效的可為 Null 狀態,且具有 SetsRequiredMembersAttribute
。
#nullable enable
public class Base
{
public required string Prop1 { get; set; }
public Base() {}
[SetsRequiredMembers]
public Base(int unused) { Prop1 = ""; }
}
public class Derived : Base
{
public required string Prop2 { get; set; }
[SetsRequiredMembers]
public Derived() : base()
{
} // Warning: Prop1 and Prop2 are possibly null.
[SetsRequiredMembers]
public Derived(int unused) : base()
{
Prop1.ToString(); // Warning: possibly null dereference
Prop2.ToString(); // Warning: possibly null dereference
}
[SetsRequiredMembers]
public Derived(int unused, int unused2) : this()
{
Prop1.ToString(); // Ok
Prop2.ToString(); // Ok
}
[SetsRequiredMembers]
public Derived(int unused1, int unused2, int unused3) : base(unused1)
{
Prop1.ToString(); // Ok
Prop2.ToString(); // Warning: possibly null dereference
}
}
元數據表示法
C# 編譯程式已知下列 2 個屬性,且此功能必須能夠運作:
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public sealed class RequiredMemberAttribute : Attribute
{
public RequiredMemberAttribute() {}
}
}
namespace System.Diagnostics.CodeAnalysis
{
[AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)]
public sealed class SetsRequiredMembersAttribute : Attribute
{
public SetsRequiredMembersAttribute() {}
}
}
手動將 RequiredMemberAttribute
套用至類型是錯誤的。
標示 required
的任何成員都會被套用 RequiredMemberAttribute
。 此外,任何定義這類成員的類型都會被標示為RequiredMemberAttribute
,作為一個標記,以指出此類型中包含必要成員。 請注意,如果類型 B
衍生自 A
,且 A
定義 required
成員,但 B
不會加入任何新的或覆寫任何現有的 required
成員,B
將不會標示為 RequiredMemberAttribute
。
若要完整判斷 B
中是否有任何必要的成員,必須檢查繼承階層的完整性。
類型中具有 required
成員且未套用 SetsRequiredMembersAttribute
的任何建構函式都會以兩個屬性標示:
-
System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute
功能名稱"RequiredMembers"
。 -
System.ObsoleteAttribute
帶有字串"Types with required members are not supported in this version of your compiler"
,屬性被標記為錯誤,以防止舊版編譯器使用這些構造函數。
我們在這裡不會使用 modreq
,因為它是維護二進位相容性的目標:如果最後一個 required
屬性已從類型中移除,編譯程式就不會再合成這個 modreq
,這是二進位中斷變更,而且所有取用者都必須重新編譯。 瞭解 required
成員的編譯程式將會忽略這個過時的屬性。 請注意,成員也可以來自基底類型:即使目前類型中沒有任何新的 required
成員,如果有任何基底類型具有 required
成員,就會產生這個 Obsolete
屬性。 如果建構函式已經有 Obsolete
屬性,則不會產生其他 Obsolete
屬性。
我們同時使用 ObsoleteAttribute
和 CompilerFeatureRequiredAttribute
,因為後者是這個版本的新功能,而較舊的編譯程式則不瞭解。 未來,我們或許能夠卸除 ObsoleteAttribute
和/或不使用它來保護新功能,但現在我們需要這兩者來獲得完整的保護。
若要針對指定類型 required
建置 R
T
成員的完整清單,包括所有基底類型,則會執行下列演算法:
- 針對每個
Tb
,從T
開始,透過基本類型鏈,直到到達object
為止。 - 如果
Tb
被標示為RequiredMemberAttribute
,那麼所有被標示為Tb
的RequiredMemberAttribute
成員將收集進Rb
中。- 針對
Ri
中的每個Rb
,如果Ri
被R
的任何成員覆寫,將被略過。 - 否則,如果任何
Ri
被R
的成員隱藏,則查閱必要的成員會失敗,並且不會採取進一步的步驟。 呼叫未標註為T
的SetsRequiredMembers
的任何建構函式會引發錯誤。 - 否則,
Ri
會新增至R
。
- 針對
開啟問題
巢狀成員初始化表達式
巢狀成員初始化表達式的強制執行機制為何? 他們會完全不允許嗎?
class Range
{
public required Location Start { get; init; }
public required Location End { get; init; }
}
class Location
{
public required int Column { get; init; }
public required int Line { get; init; }
}
_ = new Range { Start = { Column = 0, Line = 0 }, End = { Column = 1, Line = 0 } } // Would this be allowed if Location is a struct type?
_ = new Range { Start = new Location { Column = 0, Line = 0 }, End = new Location { Column = 1, Line = 0 } } // Or would this form be necessary instead?
已討論的問題
init
子句的強制執行層級
init
子句功能並未在 C# 11 中實作。 它仍然是一個積極的建議。
我們是否要嚴格要求在 init
子句中指定但缺少初始化表達式的成員必須初始化所有成員? 我們似乎可能會這樣做,否則我們會創造一個容易犯錯的陷阱。 不過,我們也有可能重新引入我們在 C# 9 中使用 MemberNotNull
解決的相同問題。 如果我們想要嚴格強制執行此動作,可能需要協助程式方法以指出其設定成員的方式。 我們已為此討論的一些可能語法:
- 允許
init
方法。 這些方法只能從建構函式或從另一個init
方法呼叫,而且可以存取this
,就好像在建構函式中一樣(例如,設定readonly
和init
字段/屬性)。 這可以與這類方法上的init
子句結合。 如果在方法/建構函式主體中明確指派 子句中的成員,則會將init
子句視為滿足。 使用包含成員的init
子句呼叫方法,這視為對該成員進行指派。 如果我們決定這是我們想要追求的路線,現在或將來,我們似乎不應該在建構函式上使用init
作為 init 子句的關鍵詞,因為這會令人困惑。 - 允許
!
運算子明確隱藏警告/錯誤。 如果以複雜的方式初始化成員(例如在共用方法中),使用者可以將!
新增至 init 子句,以指出編譯程式不應該檢查初始化。
結論:討論后,我們喜歡 !
運算符的想法。 它可讓使用者刻意處理更複雜的案例,同時不會在 init 方法周圍產生大型設計缺口,並將每個方法標註為設定成員 X 或 Y。之所以選擇 !
,是因為我們已經用它來隱藏可為 Null 的警告,並在其他地方用來告訴編譯器「我比您更聰明」,這是語法格式的一種自然延伸。
必要的介面成員
此提案不允許介面視需要標記成員。 這保護我們,避免現在就需要解決泛型中 new()
和介面條件約束的複雜情況,這也直接與工廠和泛型建構有關。 為了確保在此區域中有設計空間,我們禁止在介面中使用 required
,並禁止用 required_member_lists 的類型來替代被限制為 new()
的型別參數。 當我們想要用更全面的角度來瞭解工廠的一般建構情境時,可以重新檢視這個問題。
語法問題
init
子句功能並未在 C# 11 中實作。 它仍然是一個積極的建議。
-
init
正確的字嗎?init
作為建構函式上的後置修飾詞,如果我們希望重複利用它於工廠,也可能干擾到我們使用前置修飾詞來啟用init
方法。 其他可能性:set
- 是否
required
是用來表示所有成員已被初始化的正確描述符? 其他建議:default
all
- 帶著驚嘆號! 表示複雜的邏輯
- 我們需要
base
/this
與init
之間的分隔符嗎?-
:
分隔符 - ',' 分隔符
-
-
required
正確的修飾詞嗎? 其他已建議的替代方案:req
require
mustinit
must
explicit
結論:我們目前已移除 init
建構子條件,並會繼續使用 required
作為屬性修飾器。
初始化子句限制
init
子句功能並未在 C# 11 中實作。 它仍然是一個積極的建議。
我們是否應該允許在 init 子句中存取 this
? 如果我們希望在 init
中的指派是建構函式中成員指派的簡便方式,那麼看起來我們應該這樣做。
此外,它會建立新的範圍,例如 base()
,還是與方法主體共用相同的範圍? 特別是本機函式,init 子句可能需要存取;或者,若 init 運算式透過 out
參數引入變數,可能涉及名稱遮蔽。這些都是很重要的考量。
結論:init
子句已被移除。
輔助功能需求和 init
init
子句功能並未在 C# 11 中實作。 它仍然是一個積極的建議。
在此提案具有 init
子句的版本中,我們討論到了可能出現以下情境:
public class Base
{
protected required int _field;
protected Base() {} // Contract required that _field is set
}
public class Derived : Base
{
public Derived() : init(_field = 1) // Contract is fulfilled and _field is removed from the required members list
{
}
}
不過,我們目前已從提案中移除 init
條款,因此我們需要決定是否以有限的方式允許此案例。 我們擁有的選項如下:
- 不允許該情境。 這是最保守的方法,輔助功能 中的規則目前是基於此假設制定的。 規則是,任何必要的成員必須至少與其所屬的類型具有同等的可見性。
- 要求所有建構函式皆為以下之一:
- 比最低可見的必要成員不更可見。
- 將
SetsRequiredMembersAttribute
套用到建構函式中。 這些措施可確保任何能夠看到建構子的人要麼可以設定其匯出的所有內容,要麼則無需設定任何內容。 這可能對於那些只能通過靜態Create
方法或類似構建器創建的類型很有用,但其用途似乎整體有限。
- 重新添加一種方法來將合約的特定部分移除到提案中,如先前在 LDM 中討論的。
結論:選項 1,所有必要成員的可見性必須至少與其包含類型相同。
覆寫規則
目前的規格指出,必須複製 required
關鍵詞,並且覆寫可以使成員 更加需要,但不能降低該需求。 這是我們想要做的嗎?
允許移除需求所需的合約修改能力比我們目前建議的要多。
結論:允許在覆寫上新增 required
。 如果被覆寫的成員是 required
,那麼覆寫的成員也必須是 required
。
替代元數據表示法
我們也可以對元數據表示採取不同的方法,從擴充方法取得頁面。 我們可以將 RequiredMemberAttribute
放在類型上,以指出類型包含必要成員,然後將 RequiredMemberAttribute
放在每個必要成員上。 這會簡化查閱順序(不需要執行成員查閱,只要尋找具有 屬性的成員即可)。
結論:已核准替代專案。
元數據表示法
元數據表示法 需要被核准。 我們還需要決定這些屬性是否應該包含在 BCL 中。
- 對於
RequiredMemberAttribute
,此屬性更類似於我們用於 nullable/nint/tuple 成員名稱的一般嵌入式屬性,而不會由 C# 中的使用者手動套用。 不過,其他語言可能想要手動套用此屬性。 - 另一方面,
SetsRequiredMembersAttribute
是直接由消費者使用,因此應該位於 BCL 中。
如果我們在上一節中使用替代表示法,這可能會改變我們對於 RequiredMemberAttribute
的考量:與其說它類似於 nint
/nullable/tuple 成員名稱的一般內嵌屬性,不如說它更接近自擴充方法以來就包含在框架中的 System.Runtime.CompilerServices.ExtensionAttribute
。
結論:我們將這兩個屬性放在 BCL 中。
警告與錯誤
不設定必要成員應當是警告還是錯誤? 透過 Activator.CreateInstance(typeof(C))
或類似方式,當然可以欺騙系統,這表示我們可能無法完全保證一律設定所有屬性。 我們也允許在建構函式位置使用 !
來隱藏診斷訊息,但我們通常不允許這樣用於錯誤。 不過,此功能類似於唯讀欄位或 init 屬性,因此如果使用者在初始化之後嘗試設定這類成員,會發生嚴格錯誤,但可以透過反射來規避。
結論:錯誤。