共用方式為


主要建構函式

注意

本文是功能規格。 規格可作為功能的設計檔。 其中包含建議的規格變更,以及功能設計和開發期間所需的資訊。 這些文章會發佈,直到提議的規格變更完成並併併入目前的ECMA規格為止。

功能規格與已完成實作之間可能有一些差異。 這些差異是在 的相關語言設計會議(LDM)注意事項中擷取的。

您可以在 規格的文章中深入瞭解將功能規範納入 C# 語言標準的過程

冠軍問題:https://github.com/dotnet/csharplang/issues/2691

總結

類別和結構可以有參數清單,而且其基類規格可以有自變數清單。 主要建構函式參數在整個類別或結構體宣告中都處於作用範圍內,若被函式成員或匿名函式捕獲,則會適當地儲存(例如作為宣告類別或結構體中的不可見的私有欄位)。

提案將已經在記錄上提供的主要建構函式,作為一種更通用的特性進行重新設計(retcon),並合成了一些額外的成員。

動機

C# 中的類別或結構可以有多個建構函式,這提供了一般性,但也使得宣告語法變得比較繁瑣,因為建構函式的輸入和類別的狀態必須清楚分隔。

主要建構函式會將一個建構函式的參數放在整個類別或結構的範圍內,以用於初始化或直接做為對象狀態。 權衡在於任何其他建構函式都必須通過主建構函式呼叫。

public class B(bool b) { } // base class

public class C(bool b, int i, string s) : B(b) // b passed to base constructor
{
    public int I { get; set; } = i; // i used for initialization
    public string S // s used directly in function members
    {
        get => s;
        set => s = value ?? throw new ArgumentNullException(nameof(S));
    }
    public C(string s) : this(true, 0, s) { } // must call this(...)
}

詳細設計

這描述了記錄與非記錄之間的廣泛設計,然後詳細說明在存在主要建構函式時,如何透過新增一組合成成員來指定記錄的現有主要建構函式。

語法

類別和結構宣告會擴增,以允許類型名稱上的參數清單、基類上的自變數清單,以及只包含一個 ;的主體:

class_declaration
  : attributes? class_modifier* 'partial'? class_designator identifier type_parameter_list?
  parameter_list? class_base? type_parameter_constraints_clause* class_body
  ;
  
class_designator
  : 'record' 'class'?
  | 'class'
  
class_base
  : ':' class_type argument_list?
  | ':' interface_type_list
  | ':' class_type  argument_list? ',' interface_type_list
  ;  
  
class_body
  : '{' class_member_declaration* '}' ';'?
  | ';'
  ;
  
struct_declaration
  : attributes? struct_modifier* 'partial'? 'record'? 'struct' identifier type_parameter_list?
    parameter_list? struct_interfaces? type_parameter_constraints_clause* struct_body
  ;

struct_body
  : '{' struct_member_declaration* '}' ';'?
  | ';'
  ;
  
interface_declaration
  : attributes? interface_modifier* 'partial'? 'interface'
    identifier variant_type_parameter_list? interface_base?
    type_parameter_constraints_clause* interface_body
  ;  
    
interface_body
  : '{' interface_member_declaration* '}' ';'?
  | ';'
  ;

enum_declaration
  : attributes? enum_modifier* 'enum' identifier enum_base? enum_body
  ;

enum_body
  : '{' enum_member_declarations? '}' ';'?
  | '{' enum_member_declarations ',' '}' ';'?
  | ';'
  ;

附注: 這些內容會取代 記錄 中的 record_declaration,並在 記錄結構中取代 record_struct_declaration,這兩者都已過時。

封入的 class_declaration 如果不包含 parameter_list,那麼 class_baseargument_list 是錯誤的。 部分類別或結構的部分型別宣告最多只能提供一個 parameter_listrecord 宣告中的 parameter_list 參數必須全部是值參數。

請注意,根據這項提案,class_bodystruct_bodyinterface_bodyenum_body 只允許由 ;組成。

具有 parameter_list 的類別或結構具有隱含的公用建構函式,其簽章會對應至類型宣告的值參數。 這稱為類型的主要建構子 ,並會抑制(如果存在)隱含宣告的無參數建構子。 在類型宣告中,如果有一個主要建構函式和另一個具有相同簽章的建構函式,將會發生錯誤。

查找

簡單名稱的 查閱會增強,以處理主要建構函式參數。 這些變更會在下列摘錄中 以粗體 顯示:

  • 否則,針對每個實例類型 T§15.3.2),從最內層的封閉型別宣告的實例類型開始,並依序繼續檢查每個封閉類別或結構宣告的實例類型(如果有的話):
    • 如果宣告 T 包含主要建構子參數 I,並且參考發生在 Tclass_baseargument_list 內,或在 T的欄位、屬性或事件的初始化程式碼中,則結果為主要建構子參數 I
    • 否則, 如果 e 為零,且 T 宣告包含名稱為 I的類型參數,則 simple_name 會參考該類型參數。
    • 否則,如果在 T 中使用 e 型別參數對 I 進行成員檢索(§12.5)找到符合項目:
      • 如果 T 是直接封閉的類別或結構體的實例類型,而查閱會識別一或多個方法,則結果是具有相關聯實例表示式的方法群組 this。 如果指定了類型自變數清單,則會用於呼叫泛型方法 (\12.8.10.2)。
      • 否則,如果 T 是立即封閉類別或結構類型的實例類型,當查找識別到一個實例成員,且當參考發生在實例構造函式、實例方法或實例存取子的 區塊 內,則結果與形式 this.I的成員存取(§12.2.1)相同(§12.8.7)。 這隻能在 e 為零時發生。
      • 否則,結果與以 T.IT.I<A₁, ..., Aₑ>的成員存取(§12.8.7)相同。
    • 否則,如果宣告 T 包含主要建構函式參數 I,則結果是主要建構函式參數 I

第一個新增項目會對應至記錄 主要建構函式所產生的變更,並確保在初始化表達式和基類自變數內的任何對應欄位之前找到主要建構函式參數。 它也會將此規則延伸至靜態初始化表達式。 不過,由於記錄一律有與 參數同名的實例成員,因此延伸模組只能導致錯誤訊息的變更。 對參數的非法存取與實例成員的非法存取。

第二個新增可讓主要建構函式參數在類型主體內的其他位置找到,但前提是成員並未遮蔽。

如果參考未在下列其中一項內發生,則參考主要建構函式參數是錯誤的:

  • nameof 自變數
  • 宣告型別的實例欄位、屬性或事件的初始化器(用於型別宣告具有參數的主要建構子的類型)。
  • argument_list 屬於宣告型別的 class_base
  • 實例方法的主體(請注意,宣告型別的實例建構函式已排除)。
  • 宣告類型的實例存取器的主體。

換句話說,主要建構函式參數在宣告類型主體的範圍內。 它們會在宣告型別的欄位、屬性或事件的初始化表達式中,或在宣告型別的 class_baseargument_list 中,隱藏該宣告型別的成員。 在其他所有地方,它們會被宣告類型的成員所掩蓋。

因此,在下列宣告中:

class C(int i)
{
    protected int i = i; // references parameter
    public int I => i; // references field
}

欄位的初始化表示式 i 會參考參數 i,而 屬性的主體 I 參考字段 i

警告從基底的成員遮蔽

如果基底成員遮蔽了一個主要建構函式參數,而該參數未通過基底類型的建構函式傳遞,那麼在使用該識別符號時,編譯器會產生警告。

class_base中的參數滿足所有下列條件時,主要建構函式的參數會被視為透過其建構函式傳遞至基底類型:

  • 自變數代表主要建構函式參數的隱含或明確識別轉換;
  • 參數不是展開的 params 參數的一部分;

語義學

主要建構函式會導致在具備指定參數的封閉類型上生成一個實例建構函式。 如果 class_base 具有自變數清單,產生的實例建構函式將會有 base 初始化運算式,且具有相同自變數清單。

類別/結構宣告中的主要建構函式參數可以宣告為 refinout。 在記錄宣告的主要建構函式中,宣告 refout 參數仍然不合法。

類別主體中的所有實例成員初始化表達式都會成為所產生建構函式中的指派。

如果主要建構函式參數是從實例成員內參考,而且參考不在 nameof 自變數內,則會擷取到封入類型的狀態,以便在建構函式終止之後仍可存取它。 可能的實作策略是透過使用名稱混淆的私有欄位。 在唯讀結構中,擷取字段將會是唯讀的。 因此,對只讀結構所擷取參數的存取將具有與唯讀欄位存取類似的限制。 唯讀成員內所擷取參數的存取將具有與相同內容中實例欄位存取類似的限制。

對於具有類似 ref 類型的參數,不允許擷取,而且不允許擷取 refinout 參數。 這類似於在 lambda 中擷取的限制條件。

如果主要建構函式參數只從實例成員初始化表達式內參考,這些參數可以直接參考產生的建構函式參數,因為它們會作為其中一部分執行。

主要建構函式會執行下列作業順序:

  1. 參數值會儲存在擷取欄位中,如果有的話。
  2. 實例初始器將會執行
  3. 呼叫基底建構函式的初始化器

任何使用者程式代碼中的參數參考會被替換為相應的捕獲欄位參考。

例如,此宣告:

public class C(bool b, int i, string s) : B(b) // b passed to base constructor
{
    public int I { get; set; } = i; // i used for initialization
    public string S // s used directly in function members
    {
        get => s;
        set => s = value ?? throw new ArgumentNullException(nameof(value));
    }
    public C(string s) : this(true, 0, s) { } // must call this(...)
}

產生類似下列的程式代碼:

public class C : B
{
    public int I { get; set; }
    public string S
    {
        get => __s;
        set => __s = value ?? throw new ArgumentNullException(nameof(value));
    }
    public C(string s) : this(0, s) { ... } // must call this(...)
    
    // generated members
    private string __s; // for capture of s
    public C(bool b, int i, string s)
    {
        __s = s; // capture s
        I = i; // run I's initializer
        B(b) // run B's constructor
    }
}

非主要建構函式宣告與主要建構函式具有相同的參數清單是錯誤的。 所有非主要建構函式宣告都必須使用 this 初始化表達式,以便最終呼叫主要建構函式。

如果主要建構函式參數未在實例初始化表達式或基底初始化表達式內讀取,則記錄會產生警告。 類別和結構中的主要建構子參數將會報告類似的警告:

  • 對於 by-value 參數,若該參數未被擷取,且未在任何實例初始化器或基底初始化器中被讀取。
  • 如果參數未在任何實例初始化表達式或基底初始化表達式內讀取,則為 in 參數。
  • ref參數的情況下,若該參數未在任何實例初始化器或基底初始化器中被讀取或寫入。

相同的簡單名稱和類型名稱

在某些通常稱為「色彩色彩」的特殊案例中,有一套特定的語言規則 - 相同的簡單名稱和類型名稱

在形式 E.I的成員存取中,如果 E 是單一標識符,並且如果 E 作為 simple_name 的意義(§12.8.4)是一個常數、欄位、屬性、局部變數或參數,其類型與 E 作為 type_name 的意義(§7.8.1)相同,則允許 E 的兩種可能意義。 E.I 的成員查閱絕不模稜兩可,因為在這兩種情況下,I 必然是 E 類型的成員。 換句話說,規則只會允許存取靜態成員和巢狀類型的 E,否則會發生編譯時期錯誤。

就主要建構函式而言,規則會影響實例成員內的標識符是否應被視為類型參考,或做為主要建構函式參數參考,進而將參數擷取到封入型別的狀態。 即使「E.I 的成員查找絕不模棱兩可」,但當查找產生成員群組(member group)時,在某些情況下,若不完全解析(系結)成員存取,就無法確定成員存取指的是靜態成員還是實例成員。 同時,擷取主要建構函式的參數會以影響語意分析的方式改變包含的類型的屬性。 例如,該類型可能會變成「無管理」,並因此無法滿足某些條件約束。 甚至有一些場景,不論參數是否被視為已擷取,都可能成功綁定。 例如:

struct S1(Color Color)
{
    public void Test()
    {
        Color.M1(this); // Error: ambiguity between parameter and typename
    }
}

class Color
{
    public void M1<T>(T x, int y = 0)
    {
        System.Console.WriteLine("instance");
    }
    
    public static void M1<T>(T x) where T : unmanaged
    {
        System.Console.WriteLine("static");
    }
}

如果我們將接收者 Color 視為一個值,我們就會捕捉到該參數,然後「S1」就會被管理。 然後,靜態方法會因為條件約束而變得不可套用,而我們會呼叫 instance 方法。 不過,如果我們將接收者視為類型,則不會擷取參數,而 'S1' 仍然是 Unmanaged,則這兩種方法都適用,但靜態方法會「更好」,因為它沒有選擇性參數。 這兩個選項都不會產生錯誤,但每個都會導致不同的行為。

鑒於此情況,編譯程式會在符合下列所有條件時,針對成員存取產生模棱兩可的錯誤 E.I

  • E.I 的成員查閱會產生同時包含實例和靜態成員的成員群組。 適用於接收者類型的擴充方法會被視為此檢查目的的實例方法。
  • 如果將 E 視為簡單名稱,而非類型名稱,它將參考主要建構函式參數,並將該參數納入封閉類型的狀態中。

雙重記憶體警告

如果將主要構造函式的參數傳遞給基底,並且 都捕獲了這個參數,則該參數意外地在物件中被儲存兩次的風險很高。

當下列所有條件都成立時,編譯程式會在 class_baseargument_list 中針對 in 或按值傳遞的參數產生警告:

  • 自變數代表主要建構函式參數的隱含或明確識別轉換;
  • 該參數不屬於展開的 params 參數的一部分。
  • 主要建構函式參數會納入到封閉類型的狀態中。

當下列所有條件都成立時,編譯程式會產生 variable_initializer 的警告:

  • 變數初始化表達式代表主要建構函式參數的隱含或明確識別轉換;
  • 主要建構函式參數會擷取到封入類型的狀態。

例如:

public class Person(string name)
{
    public string Name { get; set; } = name;   // warning: initialization
    public override string ToString() => name; // capture
}

以主要建構函式為目標的屬性

https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-03-13.md 我們決定接受 https://github.com/dotnet/csharplang/issues/7047 提案。

在具有 parameter_listclass_declaration/struct_declaration 上允許 「method」 屬性目標,併產生具有該屬性的對應主要建構函式。 在不含 parameter_listclass_declaration/struct_declaration 上具有 method 目標的屬性會遭到忽略,並出現警告。

[method: FooAttr] // Good
public partial record Rec(
    [property: Foo] int X,
    [field: NonSerialized] int Y
);
[method: BarAttr] // warning CS0657: 'method' is not a valid attribute location for this declaration. Valid attribute locations for this declaration are 'type'. All attributes in this block will be ignored.
public partial record Rec
{
    public void Frobnicate()
    {
        ...
    }
}
[method: Attr] // Good
public record MyUnit1();
[method: Attr] // warning CS0657: 'method' is not a valid attribute location for this declaration. Valid attribute locations for this declaration are 'type'. All attributes in this block will be ignored.
public record MyUnit2;

記錄的主要建構函式

使用此提案時,記錄不再需要個別指定主要建構函式機制。 相反地,帶有主要構造函式的類別和結構之記錄宣告會按照一般規則進行,並遵循以下幾項簡單的補充規定:

  • 對於每個主要建構函式參數,如果具有相同名稱的成員已經存在,它必須是實例屬性或字段。 如果沒有,則會合成一個同名的僅限公用的自動屬性,並使用屬性初始化器從參數進行指派。
  • 解構子會透過 out 參數生成,以符合主要建構函式的參數。
  • 如果明確建構函式宣告是「複製建構函式」,建構函式會採用封入類型的單一參數,則不需要呼叫 this 初始化表達式,而且不會執行記錄宣告中存在的成員初始化表達式。

缺點

  • 建構物件的配置大小較不明顯,因為編譯程式會根據 類別的全文決定是否為主要建構函式參數配置字段。 此風險類似於 Lambda 表達式隱含擷取變數。
  • 常見的誘惑(或意外的模式)可能是在多個繼承層級擷取「相同」參數,在它沿著建構函式鏈結傳遞時,而不是在基類中明確配置一個受保護的欄位,這樣做會導致對象中相同數據的重複配置。 這與今天使用自動屬性覆蓋自動屬性的風險非常類似。
  • 如這裡所述,建構函式主體中通常可能會表示的其他邏輯沒有位置。 以下的「主要建構函式主體」擴展會解決這一問題。
  • 如建議,執行順序語意與一般建構函式中的語意有些不同,將成員初始化表達式延遲至基底呼叫之後。 這可能得到補救,但代價是一些延長提案(特別是“主要構造函數體”)。
  • 提案僅適用於單一建構函式可被指定為主要的情境。
  • 無法表達 類別和主要建構函式的個別存取範圍。 例如,當所有公用建構函式都委派給一個私用「綜合建構」建構函式時。 如有必要,可以稍後提出該語法。

替代方案

沒有擷取

功能更簡單的版本會禁止主要建構函式參數出現在成員主體中。 引用它們是錯誤的。 如果所需的記憶體超出初始化程序代碼,則必須明確宣告欄位。

public class C(string s)
{
    public string S1 => s; // Nope!
    public string S2 { get; } = s; // Still allowed
}

這仍然可以在稍後演變成完整的提案,並避免一些決策和複雜性,其代價是一開始移除較少的重複模板,而且可能也顯得不直觀。

明確生成的欄位

替代方法是讓主要建構函式參數一律且明顯地產生相同名稱的字段。 與其以本地和匿名函數的方式封閉參數,不如明確地生成成員宣告,這與在記錄中為主要建構函數參數生成的公用屬性類似。 就像記錄一樣,如果已有適當的成員存在,則不會產生一個。

如果產生的欄位是私有的,則當它未在成員體中作為欄位使用時,它仍可被省略。 不過,在類別中,私有欄位通常不是合適的選擇,因為它可能會在衍生類別中導致狀態重複。 這裡的選項是改為在類別中產生受保護的欄位,鼓勵跨繼承層重複使用記憶體。 不過,我們將無法省略宣告,並且每個主要建構函式參數會產生分配成本。

這將使非記錄的主建構函式與記錄建構函式更一致,因為成員總是會被生成(至少在概念上),即使這些成員具有不同的存取權限和類型。 但這也會導致在 C# 中其他地方擷取參數與局部變量時的顯著差異。 例如,若我們曾允許區域類別,它們將會隱含地捕捉包含的參數和局部變數。 為其顯示陰影區域似乎不是一種合理的行為。

這種方法經常引發的另一個問題是,許多開發人員對參數和字段有不同的命名慣例。 主要建構函式參數應該使用哪一個? 任一選擇都會導致程式代碼的其餘部分不一致。

最後,明顯產生成員宣告實際上是記錄遊戲的名稱,但對於非記錄類別和結構來說,更令人驚訝和“失控”。 總而言之,這些都是主要提案選擇了隱含捕捉的原因,當需要時,具有明確成員宣告的合理行為(與記錄一致)。

從初始化表達式範圍移除實例成員

上述查閱規則的目的是在手動宣告對應的成員時,允許記錄中主要建構函式參數的目前行為,以及在未宣告時說明所產生成員的行為。 這需要通過查找來區分「初始化範圍」(此/基底初始化器、成員初始化器)和「主體範圍」(成員主體),上述提案通過在查找 時根據參考發生的位置更改 主要建構函式參數來達成此目的。

有一個觀察是,在初始化作用域中以簡單名稱引用實例成員總是會導致錯誤。 我們是否可以直接將實例成員移出範圍,而不是只在那些地方遮蔽成員? 如此一來,就不會有這個奇怪的條件式範圍順序。

這種替代方案可能是可能的,但它會產生一些影響深遠和可能不受歡迎的後果。 首先,如果我們從初始化器範疇中移除實例成員,則 對應到實例成員的簡單名稱,而且 到主要建構函式參數,可能會意外地綁定到類型宣告外的元素! 這看起來好像並不是有意為之,出錯反而會更好。

此外,靜態 成員可以在初始化範圍中被參考。 因此,我們必須在查找中區分靜態和實例成員,這是我們目前尚未進行的事情。 (我們確實會針對多載分辨進行區分,但在這裡不適用)。 因此,這也必須變更,導致更多情況,例如在靜態內容中,某些內容會「進一步」系結,而不是錯誤,因為它發現實例成員。

總而言之,這種「簡化」最終可能引發沒有人需要的複雜問題。

可能的擴充功能

這些是核心提案的變異或新增專案,可能與該提案一起考慮,或在稍後階段視為有用。

建構函式內的主要建構函式參數存取

上述規則會讓參考另一個建構函式內的主要建構函式參數時發生錯誤。 不過,這可以在其他建構函式的 主體 內允許,因為主要建構函式會先執行。 然而,它需要保持在 this 初始化器的引數列表中不被允許。

public class C(bool b, int i, string s) : B(b)
{
    public C(string s) : this(b, s) // b still disallowed
    { 
        i++; // could be allowed
    }
}

這類存取仍然會導致捕獲,因為這是建構子主體在主要建構子執行後,存取變數的唯一方式。

可以放寬禁止在 this 初始化器參數中的主要建構函式參數的限制,允許使用它們,但不明確賦值。然而,這似乎並沒有實際的用處。

允許不含 this 初始化器的建構函式

可以允許沒有 this 初始化表達式的建構函式(也就是使用隱含或明確 base 初始化表達式)。 這類建構函式 不會 執行實例欄位、屬性和事件初始化表達式,因為這些函式只會被視為主要建構函式的一部分。

在這類基底呼叫建構函式的存在中,有數個選項可用來處理主要建構函式參數擷取。 最簡單的方式是完全不允許在這種情況下擷取。 主要建構函式參數只有在這類建構函式存在時才會進行初始化。

或者,如果結合之前所述的選項來允許在建構子中存取主要建構子參數,那麼參數可以進入建構子內部而不被確定指派,且那些被捕獲的參數需要在建構子結束時被明確指派。 它們基本上會是隱含的輸出參數。 如此一來,被捕獲的主要建構函式參數在被其他函式成員取用前,總會有合理的值,也就是明確指派的值。

此延伸模組(任一形式)的吸引力在於,它會完全將記錄中「複製建構函式」的目前豁免一般化,而不會導致觀察到未初始化的主要建構函式參數的情況。 基本上,以替代方式初始化物件的建構函式會很好。 擷取相關限制不會是記錄中現有手動定義複製建構函式的重大變更,因為記錄永遠不會擷取其主要建構函式參數(而是產生字段)。

public class C(bool b, int i, string s) : B(b)
{
    public int I { get; set; } = i; // i used for initialization
    public string S // s used directly in function members
    {
        get => s;
        set => s = value ?? throw new ArgumentNullException(nameof(value));
    }
    public C(string s2) : base(true) // cannot use `string s` because it would shadow
    { 
        s = s2; // must initialize s because it is captured by S
    }
    protected C(C original) : base(original) // copy constructor
    {
        this.s = original.s; // assignment to b and i not required because not captured
    }
}

主要建構函式主體

建構函式本身通常包含參數驗證邏輯或其他無法用初始化表達式表示的複雜初始化代碼。

主要建構函式可以擴充為允許語句區塊直接出現在類別主體中。 這些語句會在產生的建構函式中插入,出現在初始化賦值語句之間,因此會與初始化器交錯執行。 例如:

public class C(int i, string s) : B(s)
{
    {
        if (i < 0) throw new ArgumentOutOfRangeException(nameof(i));
    }
	int[] a = new int[i];
    public int S => s;
}

如果我們引進「最終初始化器」,它會在建構函式 和任何物件/集合初始化表達式 完成後執行,這可能會充分涵蓋此案例。 不過,自變數驗證是最好儘早發生的一件事。

主要建構函式主體也可以提供一個位置,允許主要建構函式的存取修飾詞,使其偏離封入類型的存取範圍。

合併的參數和成員宣告

可能且經常提及的加法可能是允許批註主要建構函式參數,讓它們 也會 在型別上宣告成員。 最常見的是建議允許指定參數的存取權限來觸發成員生成:

public class C(bool b, protected int i, string s) : B(b) // i is a field as well as a parameter
{
    void M()
    {
        ... i ... // refers to the field i
        ... s ... // closes over the parameter s
    }
}

有一些問題:

  • 如果需要的是屬性,而不是欄位,該怎麼辦? 在參數清單中內嵌 { get; set; } 語法似乎並不吸引人。
  • 如果參數和欄位使用不同的命名慣例,該怎麼辦? 然後這項功能是無用的。

這是未來可能會或不會採用的新增項目。 目前的提案保留了可能性。

未解決問題

類型參數的查閱順序

https://github.com/dotnet/csharplang/blob/main/proposals/csharp-12.0/primary-constructors.md#lookup 區段指定宣告類型的類型參數應在這些參數有作用範圍的每個上下文中,位於類型主建構子的參數之前。 不過,我們已經有涉及紀錄的現有行為,即主要建構函式參數會在基底初始化和欄位初始化時,位於類型參數之前。

我們應該如何處理這個差異?

  • 調整規則以符合行為。
  • 調整行為(可能的中斷性變更)。
  • 不允許 primiry 建構函式參數使用類型參數的名稱(可能的中斷性變更)。
  • 不執行任何動作,接受規格與實作之間的不一致。

結論:

調整規則以符合行為(https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-09-25.md#primary-constructors)。

擷取的主要建構函式參數的欄位目標屬性

我們是否應該允許針對擷取的主要建構函式參數設定字段目標屬性?

class C1([field: Test] int x) // Parameter is captured, the attribute goes to the capture field
{
    public int X => x;
}
class C2([field: Test] int x) // Parameter is not captured, the attribute is ignored with a warning CS0657: 'field' is not a valid attribute location for this declaration. Valid attribute locations for this declaration are 'param'. All attributes in this block will be ignored.
{
    public int X = x;
}

現在,不論是否擷取參數,屬性都會被忽略並發出警告。

請注意,對於記錄,當屬性被合成時,允許欄位定向的屬性。 屬性接著會移至備份欄位。

record R1([field: Test]int X); // Ok, the attribute goes on the backing field
record R2([field: Test]int X) // warning CS0657: 'field' is not a valid attribute location for this declaration. Valid attribute locations for this declaration are 'param'. All attributes in this block will be ignored.
{
    public int X = X;
}

結論:

不允許 (https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-05-03.md#attributes-on-captured-parameters)。

警告基底類別成員被衍生類別成員遮蔽

當基底成員在成員內部遮蔽主要建構函式參數時,我們是否應該回報警告(請參閱 https://github.com/dotnet/csharplang/discussions/7109#discussioncomment-5666621)。

結論:

已核准替代設計 - https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-05-08.md#primary-constructors

擷取關閉中封入類型的實例

當被擷取至封閉類型狀態的參數也被實例初始化表達式或基底初始化表達式內的 lambda 表達式參考時,該封閉類型的 lambda 表達式和狀態應該引用相同變數位置來表示該參數。 例如:

partial class C1
{
    public System.Func<int> F1 = Execute1(() => p1++);
}

partial class C1 (int p1)
{
    public int M1() { return p1++; }
    static System.Func<int> Execute1(System.Func<int> f)
    {
        _ = f();
        return f;
    }
}

將參數擷取到類別狀態的基本實作通常只是將參數儲存在私用的實例欄位中,因此 Lambda 必須參考相同的欄位。 因此,它必須能夠存取 類型的實例。 這需要在引動基底建構函式之前將 this 捕捉到一個封閉中。 反過來,這會導致安全,但無法驗證的 IL。 這是可以接受的嗎?

或者,我們可以:

  • 不允許像這樣的 Lambda;
  • 或者,相反地,可以在一個單獨類別(又一個閉包)的實例中捕捉這類參數,並在閉包和封閉類型的實例之間共用該實例。 因此,不需要在關閉時擷取 this

結論:

我們熟悉在叫用基底建構函式之前,先將 this 擷取到閉包中(https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-02-15.md)。 運行時間小組也沒有發現 IL 模式有問題。

給結構體中的 this 賦值

C# 允許在結構中對 this 賦值。 如果結構擷取主要建構函式參數,指派將會覆寫其值,這對使用者可能並不明顯。 是否需要對此類指派發出警告?

struct S(int x)
{
    int X => x;
    
    void M(S s)
    {
        this = s; // 'x' is overwritten
    }
}

結論:

允許,沒有警告(https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-02-15.md)。

初始化及擷取所引發的雙重記憶體警告

當主要建構函數參數傳遞至基底時,而且 也同時捕獲,我們會發出警告,因為該參數有高風險可能在物件中被重複儲存兩次。

看起來,如果使用參數來初始化一個成員,並且這個參數也被捕捉,會有類似的風險。 以下是一個小範例:

public class Person(string name)
{
    public string Name { get; set; } = name;   // initialization
    public override string ToString() => name; // capture
}

對於 Person的給定實例,Name 的變更不會反映在 ToString的輸出中,這可能並非開發人員的原意。

我們是否應該針對這種情況引入雙重記憶體警告?

這就是其運作方式:

當下列所有條件都成立時,編譯程式會產生 variable_initializer 的警告:

  • 變數初始化表達式代表主要建構函式參數的隱含或明確識別轉換;
  • 主要建構函式參數會擷取到封入類型的狀態。

結論:

已核准,請參閱 https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-05-15.md#primary-constructors

LDM 會議