自動預設結構
注意
本文是功能規格。 規格可作為功能的設計檔。 其中包含建議的規格變更,以及功能設計和開發期間所需的資訊。 這些文章會發佈,直到提議的規格變更完成並併併入目前的ECMA規格為止。
功能規格與已完成實作之間可能有一些差異。 這些差異是在 語言設計會議(LDM)的相關記錄中擷取的。
您可以在關於 規格的一文中,深入了解將功能規範細節納入 C# 語言標準的流程。
https://github.com/dotnet/csharplang/issues/5737
總結
這項功能使得在結構體建構子中,我們能識別哪些欄位在返回或使用之前未被使用者明確指派,並隱含地將其初始化為 default
,而不會產生明確的指派錯誤。
動機
針對在 dotnet/csharplang#5552 和 dotnet/csharplang#5635 中發現的可用性問題,以及為了解決 #5563(所有欄位必須確定已分配,但在建構函式中無法存取 field
),提出此建議作為一個可能的緩解措施。
自 C# 1.0 起,結構建構函式必須確實指派 this
,如同將其視為 out
參數。
public struct S
{
public int x, y;
public S() // error: Fields 'S.x' and 'S.y' must be fully assigned before control is returned to the caller
{
}
}
這會在半自動屬性上手動定義 setter 時發生問題,因為編譯程式無法將屬性的賦值視作等同於備份欄位的賦值。
public struct S
{
public int X { get => field; set => field = value; }
public S() // error: struct fields aren't fully assigned. But caller can only assign 'this.field' by assigning 'this'.
{
}
}
我們假設針對 setter 引入更精細的限制,例如一種配置是 setter 不採用 ref this
,而是採用 out field
作為參數,這對於某些使用案例而言,可能會過於特定且不夠完整。
我們所面臨的一個基本矛盾是,當結構的屬性手動實作了 setter 方法時,開發者通常必須執行某種形式的「重複」操作,要麼反覆指派,要麼多次重複其邏輯過程:
struct S
{
private int _x;
public int X
{
get => _x;
set => _x = value >= 0 ? value : throw new ArgumentOutOfRangeException();
}
// Solution 1: assign some value in the constructor before "really" assigning through the property setter.
public S(int x)
{
_x = default;
X = x;
}
// Solution 2: assign the field once in the constructor, repeating the implementation of the setter.
public S(int x)
{
_x = x >= 0 ? x : throw new ArgumentOutOfRangeException();
}
}
先前的討論
小組已探討此問題,並考慮了一些可能的解決方案:
- 當半自動化屬性具有手動實作的屬性 setter 時,要求使用者指定
this = default
。 我們同意這是錯誤的解決方案,因為它會覆蓋欄位初始化器中設定的值。 - 隱式初始化自動/半自動屬性的所有支援欄位。
- 這解決了「半自動屬性設定器」問題,清楚地將明確宣告的欄位置於不同的規則之下:「不要隱含地初始化我的欄位,但要隱含地初始化我的自動屬性。」
- 提供一種方式來指派半自動屬性的備份欄位,並要求使用者指派它。
- 與(2)相比,這可能很麻煩。 自動屬性應該是「自動化」的,也許這包括欄位的自動初始化。 當基礎欄位被指派給屬性,以及呼叫屬性 setter 時,可能會造成混淆。
我們也收到想要的使用者 意見反應,例如,在結構中包含一些欄位初始化表達式,而不需要明確指派所有專案。 我們可以同時解決此問題,以及「使用手動實作 setter 的半自動屬性」問題。
struct MagnitudeVector3d
{
double X, Y, Z;
double Magnitude = 1;
public MagnitudeVector3d() // error: must assign 'X', 'Y', 'Z' before returning
{
}
}
調整確定指派
我們不會執行明確的指派分析來為 this
上未指派的欄位提供錯誤,而是要判斷 哪些欄位需要以隱含方式初始化。 這類初始化被插入在建構函式的 開頭。
struct S
{
int x, y;
// Example 1
public S()
{
// ok. Compiler inserts an assignment of `this = default`.
}
// Example 2
public S()
{
// ok. Compiler inserts an assignment of `y = default`.
x = 1;
}
// Example 3
public S()
{
// valid since C# 1.0. Compiler inserts no implicit assignments.
x = 1;
y = 2;
}
// Example 4
public S(bool b)
{
// ok. Compiler inserts assignment of `this = default`.
if (b)
x = 1;
else
y = 2;
}
// Example 5
void M() { }
public S(bool b)
{
// ok. Compiler inserts assignment of `y = default`.
x = 1;
if (b)
M();
y = 2;
}
}
在範例 (4) 和 (5) 中,產生的codegen 有時會有欄位的「雙重指派」。 這通常沒問題,但對於擔心這類雙重指派的使用者,我們可以將過去做為明確指派錯誤診斷的內容轉為 預設停用 警告診斷。
struct S
{
int x;
public S() // warning: 'S.x' is implicitly initialized to 'default'.
{
}
}
將此診斷嚴重性設定為「錯誤」的使用者,將會選擇使用 C# 11 之前的行為。 這類使用者基本上會使用手動實作 setter 來「關閉」半自動屬性。
struct S
{
public int X
{
get => field;
set => field = field < value ? value : field;
}
public S() // error: backing field of 'S.X' is implicitly initialized to 'default'.
{
X = 1;
}
}
乍一看,這感覺就像功能中的“洞”,但它實際上是 做正確的事情。 藉由啟用診斷,用戶會告訴我們,他們不希望編譯程式在建構函式中隱含地初始化其字段。 無法避免此處的隱含初始化,因此解決方案是使用不同於手動實作 setter 的欄位初始化方式,例如手動宣告欄位並指派值,或使用欄位初始化器。
目前,JIT 不會透過引用(refs)消除無效儲存,這表示這些隱含的初始化確實具有實際成本。 但這可能是可以修正的。 https://github.com/dotnet/runtime/issues/13727
值得注意的是,初始化個別欄位而不是整個對象,這實際上只是一種優化措施。 編譯器應該能夠自由地實作任何它所想要的啟發法,只要它符合一個不變條件,即在所有傳回點上,未明確指派的字段或在對 this
的任何非字段成員存取之前,自動完成初始化。
例如,如果結構有 100 個字段,而且只有其中一個字段已明確初始化,則對整個專案執行 initobj
比隱含地發出 99 個其他字段的 initobj
更有意義。 不過,隱式發出 initobj
以涵蓋其他99個欄位的實作仍然有效。
語言規格的變更
我們會調整標準的下一節:
https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#12814-this-access
如果建構函式宣告沒有建構函式初始化表達式,則
this
變數的行為與結構類型的out
參數完全相同。 特別是,這表示變數應該在實例建構函式的每個執行路徑中明確指派。
我們會調整此語言以便更好地理解:
如果建構函式宣告沒有建構函式初始化表達式,則 this
變數的行為與結構類型的 out
參數類似,不同之處在於,當不符合明確指派需求時,它不是錯誤({9.4.1)。 相反地,我們會介紹下列行為:
- 當
this
變數本身不符合需求時,在所有違反需求的點上,this
內所有未指派的實例變數會在建構函式的初始化階段中,在執行任何其他程式碼之前,自動初始化為預設值(§9.3)。 - 當 內的實例變數
this
不符合需求,或 v 內任何巢狀層級的實例變數不符合需求時,v 會在建構函式執行任何其他程式碼之前,於 初始化階段中隱含初始化為預設值。