教程:创建复合赋值运算符

C#14 添加了 用户定义的复合赋值运算符 ,这些运算符支持就地改变数据结构,而不是创建新实例。 在早期版本的 C# 中,表达式:

a += b;

已扩展为以下代码:

// compiler-generated code prior to C# 13:
var tmp = a + b;
a = tmp;

根据类型 a,此扩展会导致过度分配以创建新实例,或复制多个属性的值来设置副本上的值。 通过添加用户定义的运算符 += 来表明一个类型可以更有效地就地更新目标对象。

C# 支持现有扩展,但仅在复合用户定义运算符不可用时才使用它。

在本教程中,你将:

  • 运行起始示例。
  • 识别代码中的瓶颈。
  • 实现新的复合赋值运算符。
  • 分析已完成的示例。

先决条件

分析起始示例

运行初学者应用程序。 可以从 GitHub 存储库获取它dotnet/docs。 示例应用程序模拟剧院场地的音乐会出席情况跟踪。 整个晚上,模拟模型再现了从早期到达的与会者到演出前主要高峰的人流到达模式。 此模拟演示了使用传统运算符与用户定义复合赋值运算符时的对象分配效率的比较,展示了后者可能获得的效率提升。

应用程序在音乐会观众通过剧院多个入口时(包括主层和阳台部分),跟踪他们的出席率。 每个入口都使用GateAttendance 记录来进行与会者的计数。 在整个模拟过程中,代码频繁使用增量(++) 和加法(+=) 来更新这些计数。 以下代码显示了该模拟的一部分:

// Gate 1 - busiest entrance (target: ~100-130 people)
gates.MainFloorGates[0] += random.Next(8, 15);     // Corporate group
++gates.MainFloorGates[0];                          // Single patron
gates.MainFloorGates[0] += random.Next(20, 30);    // Tour/large group arrival
gates.MainFloorGates[0] += random.Next(5, 12);     // Family groups
++gates.MainFloorGates[0];                          // Solo attendee

// Gate 2 - second busiest (target: ~85-115 people)
gates.MainFloorGates[1] = gates.MainFloorGates[1] + random.Next(6, 12);  // Group booking
++gates.MainFloorGates[1];                          // Single patron
gates.MainFloorGates[1] += random.Next(18, 28);    // Large family/reunion
gates.MainFloorGates[1] += random.Next(8, 15);     // Corporate/business group
gates.MainFloorGates[1] += random.Next(4, 8);      // Couples/small groups
++gates.MainFloorGates[1];                          // Individual patron

识别代码中的瓶颈

使用传统运算符时,每个作都会创建一个新 GateAttendance 实例,从而导致大量内存分配。 启动器GateAttendance不可变的。 代码在初始化对象后无法修改该对象的状态。 如果需要修改状态,该设计决策需要复制对象。

运行模拟时,详细输出显示:

  • 不同到达期间每个门的到达人数。
  • 所有大门的总出勤人数跟踪。
  • 最后一份包含出勤统计的综合报告。

以下文本显示一些示例输出:

Peak arrival time - all gates busy...

Peak rush period completed - all gates processed heavy traffic.

--- Gate Status After Main Rush (7:15 PM) ---
Main Floor Gates:
  Main-Floor-Gate-1: 145 attendees
  Main-Floor-Gate-2: 168 attendees
  Main-Floor-Gate-3: 149 attendees
  Main-Floor-Gate-4:  71 attendees
  Main Floor Subtotal: 533 attendees

Balcony Gates:
  Balcony-Gate-Left: 164 attendees
  Balcony-Gate-Right: 134 attendees
  Balcony Subtotal: 298 attendees

Total Current Attendance: 831 / 1000

--- Late Arrivals (7:15 PM - 7:30 PM) ---
Final patrons arriving before curtain...

Final arrivals processed - concert about to begin!

检查初始 GateAttendance 记录类:

public record class GateAttendance(string GateId)
{
    public int Count { get; init; }

    public static GateAttendance operator ++(GateAttendance gate)
    {
        GateAttendance updateGate = gate with { Count = gate.Count + 1 };
        return updateGate;
    }

    public static GateAttendance operator +(GateAttendance gate, int partySize)
    {
        GateAttendance updateGate = gate with { Count = gate.Count + partySize };
        return updateGate;
    }
}

InitialImplementation.GateAttendance 记录演示了 C# 中运算符重载的传统方法。 请注意增量运算符 (++) 和加法运算符 (+) 如何创建使用GateAttendance表达式的with全新实例。 每次写入 gate++gate += partySize 时,运算符都会分配一个包含更新后的 Count 值的新记录实例,然后返回该新实例。 虽然此方法保持不可变性和线程安全性,但代价是频繁分配内存。 在具有许多操作的情况下,比如像音乐会模拟这样有数百次出席更新,这些内存分配会迅速累积,可能会影响性能并增加垃圾回收压力。

若要度量此分配行为,请尝试在 Visual Studio 中运行 .NET 对象分配跟踪工具 。 在音乐会模拟期间分析当前实现时,会发现它分配了 134 GateAttendance 个对象来完成相对较小的模拟。 每次运算符调用都会创建一个新实例,展示了在真实场景中分配可能迅速积累的方式。 此度量提供了一个具体的基准,用于比较使用复合赋值运算符所实现的性能改进。

实现复合赋值运算符

C# 14 引入了用户定义的复合赋值运算符,用于启用就地突变,而不是创建新实例。 这些运算符为传统模式提供了更有效的替代方法,同时维护熟悉的复合赋值语法。

复合赋值运算符使用一种新的语法,其中void返回方法通过operator关键字来声明。 将以下运算符添加到 GateAttendance 类:

public void operator +=(int value) => this.property += value;
public void operator ++() => this.property++;

与传统运算符的关键区别是:

  • 突变:它们直接使用 this 修改当前实例。
  • 没有新实例:与传统返回新对象的运算符不同,复合运算符修改现有实例。
  • 返回类型:复合赋值运算符返回 void,而不是类型本身。

当编译器遇到复合赋值表达式(如 a += b++a)遵循此解析顺序时:

  1. 检查复合赋值运算符:如果类型定义用户定义的复合赋值运算符(例如, +=++),则直接使用它。
  2. 回退到传统扩展:如果没有复合运算符,请扩展到传统形式(a = a + b)。

这意味着可以同时实现这两种方法。 复合运算符在可用时优先使用,但传统运算符作为复合赋值不适用时的替代方案。

复合赋值运算符提供以下几个优点:

  • 减少分配:就地修改对象,而不是创建新实例。
  • 改进了性能:消除临时对象创建并减少垃圾回收压力。
  • 熟悉的语法:使用开发人员已经知道的相同语法+=++
  • 向后兼容性:传统运算符继续可作为后备选项。

新的复合赋值运算符显示在以下代码中:

public void operator ++() => Count++;

public void operator +=(int partySize) => Count += partySize;

注释

熟悉C++的开发人员可能想知道为什么只需要一个 ++-- 操作员。 编译器生成代码,以在修改之前或之后使用表达式作为返回值。 编译器生成的代码使用原始值或修改的值执行赋值,具体取决于调用了预增量(++x)还是后增量(x++)。

分析完成的样本

实现复合赋值运算符后,可以测量性能改进。 若要测量内存分配的巨大差异,请在更新的代码上再次运行 .NET 对象分配跟踪工具

分析启用了复合赋值运算符的应用程序时,将观察到显著减少:与之前的 134 个分配相比,整个音乐会模拟期间只分配了 10 GateAttendance 个对象 。 此更新表示对象分配减少 92%!

其余 10 个分配来自每个剧院门(四个主楼门 + 两个阳台门 = 6 个初始实例)的GateAttendance实例的初始创建,外加一些来自模拟中其他无需使用复合运算符的部分的分配。

这种分配减少相当于实际性能优势:

  • 降低内存压力:垃圾回收周期频率较低。
  • 更好的缓存区域:创建对象较少意味着内存碎片更少。
  • 改进了吞吐量:从分配和收集开销中节省的 CPU 周期。
  • 可伸缩性:在具有较高操作量的情况下,优势相乘。

性能改进在生产应用程序中变得更加重要,在这些应用程序中,类似的模式以更大的规模进行-假设跟踪数百万个事务、更新数千个计数器或处理高频率数据流。

尝试在代码库中识别更多使用复合赋值运算符的机会。 寻找使用传统赋值操作的模式,例如 gates.MainFloorGates[1] = gates.MainFloorGates[1] + 4,并考虑它们是否可以受益于复合赋值语法。 虽然其中一些操作已经在模拟代码中使用了 +=,但原则适用于任何反复修改对象而不是创建新实例的场景。

作为最终试验,将 GateAttendance 类型从 a record class 更改为 a record struct。 这是一个不同的优化,在这个模拟中效果很好,因为结构体的内存占用量较小。 GateAttendance结构体的复制不是一项耗费资源的操作。 即便如此,你也取得了小的改进。