共用方式為


使用者定義的複合指派運算符

備註

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

功能規格與已完成實作之間可能有一些差異。 這些差異已記錄在相關的 語言設計會議(LDM)備忘錄中。

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

Champion 期數:https://github.com/dotnet/csharplang/issues/9101

總結

允許使用者類型以就地修改指派目標的方式自定義複合指派運算符的行為。

動機

C# 支援使用者定義類型的開發人員多載運算子實作。 此外,它也支援「複合指派運算元」,讓使用者撰寫程式代碼類似於 x += y ,而不是 x = x + y。 不過,語言目前不允許開發人員多載這些複合指派運算符,雖然預設行為會執行正確的動作,特別是因為它與不可變的實值類型有關,但不一定是「最佳」。

假設下列範例

class C1
{
    static void Main()
    {
        var c1 = new C1();
        c1 += 1;
        System.Console.Write(c1);
    }
    
    public static C1 operator+(C1 x, int y) => new C1();
}

使用目前的語言規則,複合指派運算子 c1 += 1 會叫用使用者定義的 + 運算子,然後將其傳回值指派給局部變數 c1。 請注意,運算符實作必須配置並傳回 的新實例 C1,而從取用者的觀點來看,就地變更為 的原始實例 C1 會正常運作(指派之後不會用到),並具有避免額外配置的額外好處。

當程式使用複合指派作業時,最常見的效果是原始值「遺失」,而且不再可供程式使用。 具有大型數據的類型(例如 BigInteger、Tensors 等)產生淨新目的地、反覆運算和複製記憶體的成本往往相當昂貴。 就地突變可在許多情況下允許略過此費用,這可以為這類案例提供顯著的改善。

因此,可讓 C# 允許使用者類型自定義複合指派運算符的行為,並優化不需要配置和複製的案例。

詳細設計

語法

https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/classes.md#15101-general 文法會依下列方式調整。

運算子是透過operator_declaration宣告的:

operator_declaration
    : attributes? operator_modifier+ operator_declarator operator_body
    ;

operator_modifier
    : 'public'
    | 'static'
    | 'extern'
    | unsafe_modifier   // unsafe code support
    | 'abstract'
    | 'virtual'
    | 'sealed'
+   | 'override'
+   | 'new'
+   | 'readonly'
    ;

operator_declarator
    : unary_operator_declarator
    | binary_operator_declarator
    | conversion_operator_declarator
+   | increment_operator_declarator
+   | compound_assignment_operator_declarator
    ;

unary_operator_declarator
    : type 'operator' overloadable_unary_operator '(' fixed_parameter ')'
    ;

logical_negation_operator
    : '!'
    ;

overloadable_unary_operator
-   : '+' | 'checked'? '-' | logical_negation_operator | '~' | 'checked'? '++' | 'checked'? '--' | 'true' | 'false'
+   : '+' | 'checked'? '-' | logical_negation_operator | '~' | 'true' | 'false'
    ;

binary_operator_declarator
    : type 'operator' overloadable_binary_operator
        '(' fixed_parameter ',' fixed_parameter ')'
    ;

overloadable_binary_operator
    : 'checked'? '+'  | 'checked'? '-'  | 'checked'? '*'  | 'checked'? '/'  | '%'  | '&' | '|' | '^'  | '<<'
    | right_shift | '==' | '!=' | '>' | '<' | '>=' | '<='
    ;

conversion_operator_declarator
    : 'implicit' 'operator' type '(' fixed_parameter ')'
    | 'explicit' 'operator' type '(' fixed_parameter ')'
    ;

+increment_operator_declarator
+   : type 'operator' overloadable_increment_operator '(' fixed_parameter ')'
+   | 'void' 'operator' overloadable_increment_operator '(' ')'
+   ;

+overloadable_increment_operator
+   : 'checked'? '++' | 'checked'? '--'
+    ;

+compound_assignment_operator_declarator
+   : 'void' 'operator' overloadable_compound_assignment_operator
+       '(' fixed_parameter ')'
+   ;

+overloadable_compound_assignment_operator
+   : 'checked'? '+=' | 'checked'? '-=' | 'checked'? '*=' | 'checked'? '/=' | '%=' | '&=' | '|=' | '^=' | '<<='
+   | right_shift_assignment
+   | unsigned_right_shift_assignment
+   ;

operator_body
    : block
    | '=>' expression ';'
    | ';'
    ;

多載運算子有五種類別:一元運算子二元運算元、轉換運算元、遞增運算子複合指派運算符

下列規則適用於所有運算符宣告:

  • 運算符宣告應同時publicstatic 修飾詞。

複合指派和實例遞增運算符可以隱藏基類中宣告的運算符。 因此,下列段落已不再正確,而且應該據以調整,也可以加以移除:

因為運算符宣告一律需要宣告運算子參與運算符簽章的類別或結構,所以在衍生類別中宣告的運算符無法隱藏基類中宣告的運算符。 因此,在運算符宣告中,new 修飾詞永遠不需要,因此也不允許使用。

一元運算子

請參閱 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/classes.md#15102-unary-operators

運算符宣告應包含 static 修飾詞,不得包含 override 修飾詞。

已移除下列項目符號點:

  • 一元 ++-- 運算符應採用類型 T 為 或 T? 的單一參數,並傳回衍生自它的相同類型或型別。

下列段落會調整為不再提及 ++-- 運算子標記:

一元運算子的簽章包含運算元 Token (+-!~++、、--truefalse) 和單一參數的類型。 傳回型別不是一元運算符簽章的一部分,也不是參數的名稱。

區段中的範例應該調整為不使用使用者定義的遞增運算符。

二元運算子

請參閱 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/classes.md#15103-binary-operators

運算符宣告應包含 static 修飾詞,不得包含 override 修飾詞。

轉換運算元

請參閱 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/classes.md#15104-conversion-operators

運算符宣告應包含 static 修飾詞,不得包含 override 修飾詞。

遞增運算符

下列規則適用於靜態遞增運算符宣告,其中 T 表示包含運算符宣告的類別或結構實例類型:

  • 運算符宣告應包含 static 修飾詞,不得包含 override 修飾詞。
  • 運算符應採用 類型 T 為 或 T? 的單一參數,並傳回衍生自它的相同類型或型別。

靜態遞增運算符的簽章包含運算符標記 ('checked'? ++, 'checked'? --) 和單一參數的類型。 傳回類型不是靜態遞增運算符簽章的一部分,也不是參數的名稱。

靜態遞增運算子與一元運算元非常類似

下列規則適用於實例遞增運算符宣告:

  • 運算符宣告不得包含 static 修飾詞。
  • 運算子不應採用任何參數。
  • 運算子應具有 void 傳回類型。

實際上,實例遞增運算符是傳回沒有參數且元數據中具有特殊名稱的 void 傳回實例方法。

實例遞增運算符的簽章是由運算符令牌所組成('checked'?'++' |'checked'?'--').

宣告 checked operator 需要的成對宣告 regular operator。 否則會發生編譯時期錯誤。 另見 ../csharp-11.0/checked-user-defined-operators.md#semantics。

方法的目的是將 實例的值調整為要求增量作業的結果,無論這在宣告類型的內容中的意義為何。

範例:

class C1
{
    public int Value;

    public void operator ++()
    {
        Value++;
    }
}

實例遞增運算符可以使用基類中宣告的相同簽章覆寫運算符, override 修飾詞可用於此目的。

下列「保留」特殊名稱應新增至 ECMA-335,以支援遞增/遞減運算符的實例版本: |名稱 |運算子 | |-----|-------- | |op_DecrementAssignment| -- | |op_IncrementAssignment| ++ | |op_CheckedDecrementAssignment|checked -- | |op_CheckedIncrementAssignment| checked ++ |

複合指派運算符

下列規則適用於複合指派運算符宣告:

  • 運算符宣告不得包含 static 修飾詞。
  • 運算子應採用一個參數。
  • 運算子應具有 void 傳回類型。

實際上,複合指派運算符是會傳回實例方法的 void,該方法會採用一個參數,並在元數據中具有特殊名稱。

複合指派運算符的簽章是由運算元令牌所組成('checked'?'+=','checked'?'-=','checked'?'*=','checked'?'/=', '%=', '&=', '|=', '^=', '<<=', right_shift_assignment, unsigned_right_shift_assignment) 和單一參數的類型。 參數的名稱不是複合指派運算子簽章的一部分。

宣告 checked operator 需要的成對宣告 regular operator。 否則會發生編譯時期錯誤。 另見 ../csharp-11.0/checked-user-defined-operators.md#semantics。

方法的用途是將 實例的值調整為 的結果 <instance> <binary operator token> parameter

範例:

class C1
{
    public int Value;

    public void operator +=(int x)
    {
        Value+=x;
    }
}

複合指派運算符可以使用基類中宣告的相同簽章來覆寫運算符, override 修飾詞可用於此目的。

ECMA-335 已經針對使用者定義的遞增運算符「保留」下列特殊名稱: |名稱 |運算子 | |-----|-------- | |op_AdditionAssignment|'+=' | |op_SubtractionAssignment|'-=' | |op_MultiplicationAssignment|'*=' | |op_DivisionAssignment|'/=' | |op_ModulusAssignment|'%=' | |op_BitwiseAndAssignment|'&=' | |op_BitwiseOrAssignment|'|=' | |op_ExclusiveOrAssignment|'^=' | |op_LeftShiftAssignment|'<<='| |op_RightShiftAssignment|right_shift_assignment| |op_UnsignedRightShiftAssignment|unsigned_right_shift_assignment|

不過,它指出 CLS 合規性要求運算符方法必須是具有兩個參數的非空白靜態方法,亦即符合 C# 二進位運算符。 我們應該考慮放寬 CLS 合規性需求,以允許運算符使用單一參數傳回實例方法的 void。

應該新增下列名稱以支援已檢查的運算子版本:| 名稱 | 運算子 | | ----- | -------- | | op_CheckedAdditionAssignment |(已檢查)'+=' | | op_CheckedSubtractionAssignment |(已檢查)'-=' | | op_CheckedMultiplicationAssignment |(已檢查)'*=' | | op_CheckedDivisionAssignment |(已檢查)'/=' |

前置遞增和遞減運算子

請參閱 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#1296-prefix-increment-and-decrement-operators

如果 x 中的 «op» x 分類為變數,且以新的語言版本為目標,則會將優先順序指定給 實例遞增運算符 ,如下所示。

首先,藉由套用 實例遞增運算子多載解析,嘗試處理作業。 如果進程不會產生任何結果且沒有錯誤,則會套用目前指定的一元運算元多載解析 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#1296-prefix-increment-and-decrement-operators 來處理作業。

否則,作業 «op»x 會評估如下。

如果的x型別已知為參考型別,則會評估 為取得實例 xx₀並在該實例上叫用 運算符方法,並x₀傳回作業的結果。 如果 x₀null,運算符方法調用將會擲回 NullReferenceException。

例如:

var a = ++(new C()); // error: not a variable
var b = ++a; // var temp = a; temp.op_Increment(); b = temp; 
++b; // b.op_Increment();
var d = ++C.P1; // error: setter is missing
++C.P1; // error: setter is missing
var e = ++C.P2; // var temp = C.op_Increment(C.get_P2()); C.set_P2(temp); e = temp;
++C.P2; // var temp = C.op_Increment(C.get_P2()); C.set_P2(temp);

class C
{
    public static C P1 { get; } = new C();
    public static C P2 { get; set; } = new C();

    public static C operator ++(C x) => ...;
    public void operator ++() => ...;
}

如果 的型 x 別未知為參考型別:

  • 如果使用遞增的結果, x 則會評估 為取得實例 x₀,則會在該實例上叫用 運算符方法、 x₀ 指派給 x ,並 x₀ 傳回復合指派的結果。
  • 否則,會在上 x叫用 運算符方法。

請注意,中的 x 副作用只會在程序中評估一次。

例如:

var a = ++(new S()); // error: not a variable
var b = ++S.P2; // var temp = S.op_Increment(S.get_P2()); S.set_P2(temp); b = temp;
++S.P2; // var temp = S.op_Increment(S.get_P2()); S.set_P2(temp);
++b; // b.op_Increment(); 
var d = ++S.P1; // error: set is missing
++S.P1; // error: set is missing
var e = ++b; // var temp = b; temp.op_Increment(); e = (b = temp); 

struct S
{
    public static S P1 { get; } = new S();
    public static S P2 { get; set; } = new S();

    public static S operator ++(S x) => ...;
    public void operator ++() => ...;
}

後置遞增和遞減運算子

請參閱 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#12816-postfix-increment-and-decrement-operators

如果使用或 xx «op» 將 作業的結果分類為變數,或以舊語言版本為目標,則會套用目前指定的一元運算元多載解析 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#12816-postfix-increment-and-decrement-operators 來處理作業。 使用 result 時,我們甚至不會嘗試實例遞增運算符的原因是,如果我們正在處理參考型別,則無法在作業就地變動時產生的值 x 。 如果我們處理實值類型,我們還是必須製作複本等等。

否則,會將優先順序指定給 實例遞增運算符 ,如下所示。

首先,藉由套用 實例遞增運算子多載解析,嘗試處理作業。 如果進程不會產生任何結果且沒有錯誤,則會套用目前指定的一元運算元多載解析 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#12816-postfix-increment-and-decrement-operators 來處理作業。

否則,作業 x«op» 會評估如下。

如果的 x 型別已知為參考型別,則會在 上 x叫用 運算符方法。 如果 xnull,運算符方法調用將會擲回 NullReferenceException。

例如:

var a = (new C())++; // error: not a variable
var b = new C(); 
var c = b++; // var temp = b; b = C.op_Increment(temp); c = temp; 
b++; // b.op_Increment();
var d = C.P1++; // error: missing setter
C.P1++; // error: missing setter
var e = C.P2++; // var temp = C.get_P2(); C.set_P2(C.op_Increment(temp)); e = temp;
C.P2++; // var temp = C.get_P2(); C.set_P2(C.op_Increment(temp));

class C
{
    public static C P1 { get; } = new C();
    public static C P2 { get; set; } = new C();

    public static C operator ++(C x) => ...; 
    public void operator ++() => ...;
}

如果的型 x 別未知為參考型別,則會在 上 x叫用 運算符方法。

例如:

var a = (new S())++; // error: not a variable
var b = S.P2++; // var temp = S.get_P2(); S.set_P2(S.op_Increment(temp)); b = temp;
S.P2++; // var temp = S.get_P2(); S.set_P2(S.op_Increment(temp));
b++; // b.op_Increment(); 
var d = S.P1++; // error: set is missing
S.P1++; // error: missing setter
var e = b++; // var temp = b; b = S.op_Increment(temp); e = temp; 

struct S
{
    public static S P1 { get; } = new S();
    public static S P2 { get; set; } = new S();

    public static S operator ++(S x) => ...; 
    public void operator ++() => ...;
}

實例遞增運算子多載解析

表單 «op» xx «op»的作業,其中 «op» 是可多載的實例遞增運算符,而且 x 是 類型的 X表達式,會依下列方式處理:

  • 針對作業X所提供的operator «op»(x)候選使用者定義運算元集合,是使用候選實例遞增運算符的規則來決定。
  • 如果候選使用者定義運算符集合不是空的,則這會成為作業的候選運算元集合。 否則,多載解析不會產生任何結果。
  • 載解析規則 會套用至一組候選運算符,以選取最佳運算符,而此運算符會成為多載解析程序的結果。 如果多載解析無法選取單一最佳運算符,則會發生系結時間錯誤。

候選實例遞增運算符

指定型 T 別和作業 «op»,其中 «op» 是可多載的實例遞增運算符,由 提供的 T 候選使用者定義運算符集合會依下列方式決定:

  • 在評估內容中unchecked,它是一組運算元,只有在實例運算符被視為符合目標名稱 operator «op»()時,成員查閱N
  • 在評估內容中checked,只有實例和實例operator «op»()運算符符合目標名稱operator checked «op»()時,成員查閱N運算符群組。 operator «op»()具有配對比operator checked «op»()對宣告的運算符會從群組中排除。

複合指派

請參閱 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#12214-compound-assignment

處理 dynamic 開頭的段落仍然適用如下。

否則,如果 x 中的 x «op»= y 分類為變數,且將新的語言版本設為目標,則會將優先順序指定給 複合指派運算符 ,如下所示。

首先,藉由套用x «op»= y,嘗試處理表單的作業。 如果進程不會產生任何結果,而且沒有錯誤,則會套用目前指定的二元運算元多載解析 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#12214-compound-assignment 來處理作業。

否則,作業會評估如下。

如果的x型別已知為參考型別,則會評估 為取得實例 xx₀而運算符方法會叫用在該實例y上做為 自變數,並x₀傳回復合指派的結果。 如果 x₀null,運算符方法調用將會擲回 NullReferenceException。

例如:

var a = (new C())+=10; // error: not a variable
var b = a += 100; // var temp = a; temp.op_AdditionAssignment(100); b = temp; 
var c = b + 1000; // c = C.op_Addition(b, 1000)
c += 5; // c.op_AdditionAssignment(5);
var d = C.P1 += 11; // error: setter is missing
var e = C.P2 += 12; // var temp = C.op_Addition(C.get_P2(), 12); C.set_P2(temp); e = temp;
C.P2 += 13; // var temp = C.op_Addition(C.get_P2(), 13); C.set_P2(temp);

class C
{
    public static C P1 { get; } = new C();
    public static C P2 { get; set; } = new C();

    // op_Addition
    public static C operator +(C x, int y) => ...;

    // op_AdditionAssignment
    public void operator +=(int y) => ...;
}

如果 的型 x 別未知為參考型別:

  • 如果使用複合指派的結果,則會評估 為取得 實例,x而運算符方法會在具有 x₀ 自變數的 實例上叫用, yx₀x傳回復合指派x₀的結果。
  • 否則,在上 x 叫用 y 運算符方法做為 自變數。

請注意,中的 x 副作用只會在程序中評估一次。

例如:

var a = (new S())+=10; // error: not a variable
var b = S.P2 += 100; // var temp = S.op_Addition(S.get_P2(), 100); S.set_P2(temp); b = temp;
S.P2 += 100; // var temp = S.op_Addition(S.get_P2(), 100); S.set_P2(temp);
var c = b + 1000; // c = S.op_Addition(b, 1000)
c += 5; // c.op_AdditionAssignment(5); 
var d = S.P1 += 11; // error: setter is missing
var e = c += 12; // var temp = c; temp.op_AdditionAssignment(12); e = (c = temp); 

struct S
{
    public static S P1 { get; } = new S();
    public static S P2 { get; set; } = new S();

    // op_Addition
    public static S operator +(S x, int y) => ...;

    // op_AdditionAssignment
    public void operator +=(int y) => ...;
}

複合指派運算子多載解析

表單 x «op»= y的作業,其中 «op»= 是可多載的複合指派運算符, x 是 類型的 X 表達式,其處理方式如下:

  • 針對作業X所提供的operator «op»=(y)候選使用者定義運算元集合,是使用候選複合指派運算符的規則來決定。
  • 如果集合中至少有一個候選使用者定義運算元適用於自變數清單 (y),則這會成為作業的候選運算符集合。 否則,多載解析不會產生任何結果。
  • 載解析規則 會套用至一組候選運算符,以選取自變數清單 (y)的最佳運算符,而且此運算符會成為多載解析程序的結果。 如果多載解析無法選取單一最佳運算符,則會發生系結時間錯誤。

候選複合指派運算符

指定型 T 別和作業 «op»=,其中 «op»= 是可多載複合指派運算符,由 提供的 T 候選使用者定義運算符集合會依下列方式決定:

  • 在評估內容中unchecked,它是一組運算元,只有在實例運算符被視為符合目標名稱 operator «op»=(Y)時,成員查閱N
  • 在評估內容中checked,只有實例和實例operator «op»=(Y)運算符符合目標名稱operator checked «op»=(Y)時,成員查閱N運算符群組。 operator «op»=(Y)具有配對比operator checked «op»=(Y)對宣告的運算符會從群組中排除。

未解決的問題

[已解決] 修飾符是否應該允許在結構中?

當方法的整個用途是修改 實例時,允許使用 readonly 標記方法並無好處。

結論: 我們將允許 readonly 修改,但目前不會放寬目標需求。

[已解決]是否應該允許陰影?

如果衍生類別宣告具有與基底相同簽章的 'compound assignment'/'instance increment' 運算符,我們應該需要 override 修飾詞嗎?

結論:陰影允許的規則將與方法一致。

[已解決]我們應該在宣告 +=+ 運算符之間強制執行任何一致性嗎?

LDM-2025-02-12期間,有人擔心作者不小心將使用者推入一些奇怪的情境,其中有一種符號 += 可以運作,但另一種 + 卻不行(或反之亦然),因為其中一種符號宣告了比另一種符號額外的運算符。

結論: 不會對不同形式的運算符之間的一致性進行檢查。

替代選擇

繼續使用靜態方法

我們可以考慮使用靜態運算符方法,其中要變動的實例會當做第一個參數傳遞。 如果是實值型別,該參數必須是 ref 參數。 否則,方法將無法變動目標變數。 同時,如果是類別類型,該參數不應該是 ref 參數。 因為在類別的情況下,傳入的 實例必須變動,而不是儲存實例的位置。 不過,在介面中宣告運算符時,通常不知道介面只會由類別或結構實作。 因此,目前還不清楚第一個 ref 參數是否應該是參數。

設計會議