用户定义的复合赋值运算符

注释

本文是功能规格说明。 此规范是功能的设计文档。 它包括建议的规范变更,以及功能设计和开发过程中所需的信息。 这些文章将持续发布,直至建议的规范变更最终确定并纳入当前的 ECMA 规范。

功能规范与已完成的实现之间可能存在一些差异。 这些差异记录在相关的 语言设计会议(LDM)记录中。

可以在有关 规范的文章中详细了解将功能规范采用 C# 语言标准的过程。

支持者问题: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_declarations 声明:

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 ';'
    | ';'
    ;

可重载运算符有五类:一元运算符、二元运算符转换运算符、递增运算符、复合赋值运算符

以下规则适用于所有运算符声明:

  • 运算符声明应同时包含 a publicstatic修饰符。

复合赋值和实例递增运算符可以隐藏基类中声明的运算符。 因此,以下段落不再准确,应相应地进行调整,也可以将其删除:

由于运算符声明始终需要声明运算符参与运算符签名的类或结构,因此派生类中声明的运算符无法隐藏在基类中声明的运算符。 因此,在运算符声明中从不需要new修改符,因此也不允许使用。

一元运算符

请参阅 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/classes.md#15102-unary-operators

运算符声明应包含 static 修饰符,不得包含 override 修饰符。

删除以下项目符号点:

  • 一元 ++-- 运算符应接受一个类型为 TT? 的参数,并应返回同一类型或其派生类型。

以下段落已调整为不再提及 ++-- 运算符标记:

一元运算符的签名由运算符标记(+-!~++--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 修饰符。
  • 运算符应采用类型或TT?类型的单个参数,并返回同一类型或派生自它的类型。

静态递增运算符的签名由运算符符号(‘checked’?++,‘checked’?--)和单个参数的类型组成。 返回类型不是静态增量运算符签名的一部分,也不是参数的名称。

静态增量运算符与一元运算符非常相似

以下规则适用于实例递增运算符声明:

  • 运算符声明不应包含 static 修饰符。
  • 运算符不采用任何参数。
  • 运算符应具有 void 返回类型。

实际上,实例递增运算符是返回没有参数且元数据中具有特殊名称的 void 返回实例方法。

实例增量运算符的签名由运算符令牌组成 ('checked'? '++' | 'checked'? '--')。

声明 checked operator 需要对一个 regular operator。 否则会发生编译时错误。 另请参阅 https://github.com/dotnet/csharplang/blob/main/proposals/csharp-11.0/checked-user-defined-operators.md#semantics

该方法的目的是将实例的值调整为请求递增作的结果,无论在声明类型的上下文中意味着什么。

示例:

class C1
{
    public int Value;

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

实例递增运算符可以重写在基类中声明的同一 override 签名的运算符,修饰符可用于此目的。

以下“保留”的特殊名称应添加到 ECMA-335 中,以支持增量/减量运算符的实例版本:| Name | Operator | | -----| -------- | |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。 否则会发生编译时错误。 另请参阅 https://github.com/dotnet/csharplang/blob/main/proposals/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 已为用户定义的增量运算符预留了以下特殊名称:| Name | Operator | | -----| -------- | |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。

以下名称应添加以支持操作符的受检查版本:| Name | Operator | | -----| -------- | |op_CheckedAdditionAssignment| checked '+=' | |op_CheckedSubtractionAssignment| checked '-=' | |op_CheckedMultiplicationAssignment| checked '*=' | |op_CheckedDivisionAssignment| checked '/=' |

前缀增量和减量运算符

请参见https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#1296-prefix-increment-and-decrement-operators

如果 x in «op» x 被分类为变量,并且面向新的语言版本,则会将优先级赋予 实例递增运算符 ,如下所示。

首先,通过应用 实例递增运算符重载解析来尝试处理作。 如果进程不生成任何结果且没有错误,则通过应用当前指定的一元运算符重载解析 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#1296-prefix-increment-and-decrement-operators 来处理该作。

否则,将按如下所示计算作 «op»x

如果已知类型为引用类型,x则计算该类型x以获取实例x₀,在该实例上调用运算符方法,并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₀xx₀ 并将其作为复合赋值的结果返回。
  • 否则,将调用 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 来处理该作。 使用结果时,我们甚至没有尝试实例递增运算符的原因是,如果处理引用类型,则如果作就地发生可变,则无法生成 x 作的值。 如果我们处理的是值类型,则必须无论如何复制副本,等等。

否则,将优先级赋予 实例递增运算符 ,如下所示。

首先,通过应用 实例递增运算符重载解析来尝试处理作。 如果进程不生成任何结果且没有错误,则通过应用当前指定的一元运算符重载解析 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#12816-postfix-increment-and-decrement-operators 来处理该作。

否则,将按如下所示计算作 x«op»

如果已知类型 x 为引用类型,则调用 x运算符方法。 x如果是null,运算符方法调用将引发 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» x 的作,其中 x «op»«op» 是可重载的实例递增运算符,并且 x 是类型的 X表达式,按如下所示进行处理:

  • 为作提供的X候选用户定义运算符集是使用候选实例递增运算符operator «op»(x)
  • 如果候选用户定义运算符集不为空,则这将成为操作的候选运算符集。 否则,重载解析不产生任何结果。
  • 载解析规则 将应用于候选运算符集以选择最佳运算符,此运算符将成为重载解析过程的结果。 如果重载解析无法选择单个最佳运算符,则会发生绑定时错误。

候选实例递增运算符

给定类型和T作(其中«op»是可重载实例递增运算符),由提供的«op»候选用户定义运算符集按T如下方式确定:

  • 在计算上下文中unchecked,它是一组运算符,只有在将实例运算符视为与目标名称operator «op»()匹配时,成员查找N
  • checked计算上下文中,它是一组运算符,只有在将实例和实例operator «op»()运算符视为与目标名称operator checked «op»()匹配时,成员查找Noperator «op»()将排除具有成对匹配operator checked «op»()声明的运算符。

复合赋值

请参见https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#12214-compound-assignment

处理 dynamic 开头的段落仍然适用, 原样。

否则,如果将 x in x «op»= y 分类为变量,并且面向新的语言版本,则为复合赋值运算符提供优先级,如下所示。

首先,通过应用x «op»= y来尝试处理表单的作。 如果进程不生成任何结果且没有错误,则通过应用当前指定的二进制运算符重载解析 https://github.com/dotnet/csharpstandard/blob/draft-v8/standard/expressions.md#12214-compound-assignment 来处理该作。

否则,将按如下所示评估该作。

如果已知类型为引用类型,x则计算该类型x以获取实例x₀,则会在该实例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₀,将使用参数在该实例 y 上调用运算符方法, x₀xx₀ 作为复合赋值的结果返回。
  • 否则,将调用xy运算符方法作为参数。

请注意,进程中 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)匹配时,成员查找Noperator «op»=(Y)将排除具有成对匹配operator checked «op»=(Y)声明的运算符。

开放性问题

[已解决]是否应在 readonly 结构中允许修饰符?

感觉允许在方法的整个用途是修改实例时标记方法 readonly 没有任何好处。

结论: 我们将允许 readonly 修饰符,但目前我们不会放宽目标要求。

[已解决] 是否应允许隐藏?

如果派生类声明与基数相同的签名的“复合赋值”/“实例递增”运算符,我们 override 是否需要修饰符?

结论:隐藏将按照与方法相同的规则进行。

[已解决]我们是否应该在声明的+=+运算符之间实施一致性强制措施?

LDM-2025-02-12 期间,有人担心作者可能会意外地将用户推向某些奇怪的情况,其中 += 可能有效,但 + 则无效(或反之亦然),因为其中一种形式比另一种声明了更多的操作符。

结论: 不会对不同形式的运算符之间的一致性执行检查。

替代方案

继续使用静态方法

我们可以考虑使用静态运算符方法,其中将实例作为第一个参数传递。 对于值类型,该参数必须是参数 ref 。 否则,该方法将无法改变目标变量。 同时,对于类类型,该参数不应是参数 ref 。 因为对于类,传入的实例必须可变,而不是存储实例的位置。 但是,在接口中声明运算符时,通常不知道接口是仅由类实现还是仅通过结构实现。 因此,目前还不清楚第一个参数是否应为 ref 参数。

设计会议