记录(record)是一种主要用来保存数据的类型,它的相等性由这些数据决定。记录是在 C# 9 中引入的。你只需写一行,编译器就会生成数据类所需要的成员:属性、构造函数、基于值的 Equals 和 ==、GetHashCode、易读的 ToString、Deconstruct,以及对 with 复制的支持。
记录需要 C# 9 或更高版本(.NET 5+),所以本页中关于记录的代码以普通 C# 代码块展示,输出写在注释里。最后一节会用 C# 7 手写出相同的成员,那段代码可以运行。
位置记录
最简短的写法是在名称后面的括号里列出属性。每个参数都会变成一个同名的公共属性,只能在初始化时赋值:
public record Product(string Sku, string Name, decimal Price);
var mug = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(mug.Name); // Mug
Console.WriteLine(mug); // Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
// mug.Price = 4m; // error CS8852: init-only property
var (sku, name, price) = mug; // generated Deconstruct
Console.WriteLine($"{sku} {price}"); // MUG-01 8.50
编译器从这一行代码生成:
- 一个接收
(string Sku, string Name, decimal Price)的构造函数; - 三个
public ... { get; init; }属性; Equals(object)、Equals(Product)、GetHashCode(),以及==和!=运算符,它们都会比较这三个属性;- 打印类型名和每个公共属性的
ToString(); Deconstruct(out string Sku, out string Name, out decimal Price);- 一个复制构造函数(protected,在 sealed 记录中是 private),以及
with所使用的隐藏克隆方法。
记录也可以用普通的类体来写,当属性需要默认值或校验时这样很方便:
public record Customer
{
public required string Email { get; init; } // required: C# 11
public string Name { get; init; } = "";
}
var c = new Customer { Email = "ana@example.com" };
两种写法还可以结合使用:位置参数,再加上大括号里的额外成员。
public record Order(string Id, decimal Subtotal)
{
public decimal Tax => Subtotal * 0.23m;
public decimal Total => Subtotal + Tax;
}
值相等
对于普通类,== 问的是“它们是同一个对象吗?”。对于记录,它问的是“它们的值相同吗?”:
var a = new Product("MUG-01", "Mug", 8.50m);
var b = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(ReferenceEquals(a, b)); // False: still two objects
GetHashCode 也会相应地生成,所以记录可以正确地用作字典的键,或放进 HashSet<T>:值相同的第二个记录能找到第一个记录的条目。
相等性比较会用 EqualityComparer<T>.Default 逐个比较字段(对于位置记录,就是每个属性背后的字段),它调用的是该类型自己的 Equals。对于集合类型的属性,这意味着引用相等,很多人会因此感到意外:
public record Basket(string Owner, List<string> Items);
var x = new Basket("Ana", new List<string> { "tea" });
var y = new Basket("Ana", new List<string> { "tea" });
Console.WriteLine(x == y); // False: two different List objects
如果记录中包含集合,并且应该按集合内容比较,就重写 Equals(Basket other) 和 GetHashCode(),或者自己使用一个具有值语义的不可变集合。
with 表达式:非破坏性修改
记录通常是不可变的,所以要“修改”一个记录,就创建一个修改过的副本。with 会复制每个属性,然后应用大括号里的赋值:
var mug = new Product("MUG-01", "Mug", 8.50m);
var sale = mug with { Price = 6.00m };
Console.WriteLine(sale); // Product { Sku = MUG-01, Name = Mug, Price = 6.00 }
Console.WriteLine(mug.Price); // 8.50: the original is untouched
这种复制是浅复制。引用类型的属性复制的是引用,所以两个记录共享同一个对象:
public record Customer { public List<string> Tags { get; init; } = new(); /* ... */ }
var c1 = new Customer { Email = "ana@example.com", Tags = { "vip" } };
var c2 = c1 with { Name = "Ana" };
c2.Tags.Add("newsletter");
Console.WriteLine(string.Join(",", c1.Tags)); // vip,newsletter
要么让记录的属性从上到下都保持不可变(只填充一次的 IReadOnlyList<T>,或 ImmutableList<T>),要么在 with 中创建一个新列表:c1 with { Tags = new List<string>(c1.Tags) }。
ToString
生成的 ToString 会打印类型名和每个公共属性,这让记录在日志和调试器中都很好用:
Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
集合会打印成它的类型名(System.Collections.Generic.List`1[System.String]),嵌套的记录会递归打印。你可以重写 ToString 来替换整个输出:
public record Money(decimal Amount, string Currency)
{
public override string ToString() => $"{Amount:F2} {Currency}";
}
record struct(C# 10)
单独写 record 表示的是 record class:一个引用类型。C# 10 新增了 record struct,它是拥有相同生成成员的值类型:
public readonly record struct Point(int X, int Y);
var p = new Point(3, 4);
var q = p with { Y = 10 };
Console.WriteLine(p == new Point(3, 4)); // True
Console.WriteLine(q); // Point { X = 3, Y = 10 }
默认行为的差别值得记住:位置 record struct 的属性是可变的({ get; set; }),和结构体通常的行为一致;而 readonly record struct 和 record class 的属性只能在初始化时赋值。在两者之间选择,就像在结构体和类之间选择一样:可以随意复制的小型值适合 readonly record struct;其他情况用 record。
继承
记录可以继承另一个记录(不能继承类,类也不能继承记录)。位置参数会像构造函数实参一样传给基类:
public abstract record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Square(string Color, double Side) : Shape(Color);
Shape a = new Circle("red", 2);
Shape b = new Circle("red", 2);
Shape c = new Square("red", 2);
Console.WriteLine(a == b); // True
Console.WriteLine(a == c); // False: different runtime types are never equal
Console.WriteLine(a); // Circle { Color = red, Radius = 2 }
相等性会通过一个生成的 EqualityContract 属性把运行时类型也考虑进去。所以即使都是通过 Shape 来比较,Circle 也永远不会等于 Color 相同的 Square;也正因如此,即使变量的类型是基类,ToString 和 with 仍然作用于派生类型。
用 C# 7 实现同样的效果:具有值相等的类
记录生成的代码你也可以自己写,看一看这些代码就能理解记录的行为。下面是一个与 public record Point(int X, int Y); 等价的 C# 7 类:只读属性、构造函数、Deconstruct、值相等、与之匹配的哈希码、==、ToString,以及一个代替 with 表达式的 With 方法。
输出:
True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False
两个属性就要写大约 30 行,而且每新增一个属性,都要再去改构造函数、Deconstruct、Equals、GetHashCode 和 ToString。漏掉其中一个就是经典的 bug(两个点 == 相等,哈希值却不同,于是 HashSet 找不到它们)。记录省掉的正是这些维护工作。
这个类特意声明为 sealed:值相等再加上继承,就需要记录通过 EqualityContract 生成的那种额外类型检查,而密封类直接避开了这个问题。
什么时候使用记录
记录适合由值来定义、创建之后不再改变的数据:
- Web API 的请求和响应模型;
- 在系统各部分之间传递的消息、命令和事件;
- 配置和选项对象;
- 复合的字典键(
record CacheKey(string Region, int Year)); - 计算结果(
record PriceQuote(decimal Net, decimal Tax))。
在身份比值更重要的地方,记录就不太合适:一个 Entity Framework 实体即使改了名字,也仍然是“42 号客户”,而且 EF Core 的变更跟踪依赖引用身份。这种情况请使用类。
常见错误
- 以为集合会进行深度比较。
List<T>属性按引用比较。两个列表看起来相同的记录并不相等。 - 以为
with会深复制。 嵌套对象和集合在原记录和副本之间是共享的。 - 无意中写出了可变的位置
record struct。 除非你确实需要可写的属性,否则加上readonly。 - 把记录用作 EF Core 实体。 值相等和复制会与变更跟踪相冲突。
- 在 C# 8 项目中添加记录。 记录需要 C# 9(.NET 5 及以后版本的默认语言版本)。在较老的目标框架上,请像上面那样手写类。
常见问题
C# 中的 record 是什么?
record(C# 9)是一个类,用 record struct(C# 10)时则是一个结构体,编译器会为它生成基于值的相等性、易读的 ToString()、Deconstruct 方法以及对 with 复制的支持。public record Product(string Sku, decimal Price); 就是一个完整的类型,带有两个只能在初始化时赋值的属性。两个属性值相等的记录就是相等的,即使它们是不同的对象。
C# 中 record 和 class 有什么区别?
record 底层就是一个类,所以它是引用类型,也可以继承其他记录。区别在于编译器生成的成员:记录按值比较(== 和 Equals 会检查每个字段),ToString() 会打印出它的属性,并且支持 with。普通类按引用比较,打印的是类型名,除非你自己写这些成员。
C# 中的 with 表达式有什么作用?
var sale = product with { Price = 6.00m }; 会创建一个新记录:先复制 product 的每个属性,再设置列出的那些属性。原来的记录保持不变。这种复制是浅复制:List<T> 属性由两个记录共享,通过其中一个添加元素,另一个也能看到。
C# 中的 record struct 是什么?
record struct(C# 10)是一个值类型,拥有和 record class 相同的生成成员:值相等、ToString、Deconstruct 和 with。和 record class 不同,它的位置属性默认是可变的;把它声明为 readonly record struct,属性就变成只能在初始化时赋值。它适合坐标、金额这类小型值。
C# 中什么时候应该使用 record?
用于由值决定身份的数据:DTO、API 的请求和响应模型、消息和事件、配置,以及字典的键。不要把记录用于会随时间变化、靠 id 来识别的实体,例如 Entity Framework 实体,因为值相等和 with 复制会和变更跟踪相冲突。