C#的record类型和class类型有何不同?

record和class都是引用类型,但record默认提供值相等性、不可变性支持、自动重写toString/gethashcode/equals及with表达式,适合表示数据;class默认基于引用相等、可变,适合表示具有行为和唯一标识的实体。2. 选择record当类型身份由其数据决定(如dto、值对象),选择class当类型强调行为或拥有独立生命周期(如领域实体、服务)。3. 使用record需注意:不可变性是浅层的,引用类型的属性内部仍可变;继承时相等性比较包含所有成员;存在轻微性能开销;不应滥用在需要可变状态或复杂行为的场景。record通过减少样板代码和推广不可变性,解决了数据类定义中的冗余与并发安全问题,是c#对现代编程范式的回应。

C#的record类型和class类型有何不同?

C#中的

record

类型和

class

类型,从根本上讲,它们都是引用类型,但

record

在设计哲学和默认行为上与

class

有着显著的区别,尤其是在处理数据和值语义方面。你可以把

record

看作是C#为了更好地支持不可变数据和值对象而量身定制的一种引用类型。

解决方案

当我们谈论

record

class

的不同,最核心的几点差异在于它们对“相等性”的定义、默认的“可变性”以及一些语法上的便利。

首先,关于相等性。对于

class

,默认情况下,它的相等性是基于引用相等的。这意味着即使两个

class

实例的属性值完全相同,如果它们指向内存中的不同位置,

==

运算符

Equals()

方法会认为它们是不相等的。这很符合我们对“对象”的直观理解:每个对象都是独一无二的个体。然而,

record

则不同,它默认实现的是值相等。当你比较两个

record

实例时,只要它们所有公共属性(包括字段,如果它们是公共的)的值都相等,那么这两个

record

就被认为是相等的。这在处理数据容器时异常方便,比如你有一个表示坐标的

Point

new Point(1, 2)

和另一个

new Point(1, 2)

,你肯定希望它们是相等的。

record

就是为了这种场景而生,它自动重写了

Equals()

GetHashCode()

以及

==

!=

运算符,省去了我们手动编写这些样板代码的麻烦。

其次,可变性

class

类型默认是可变的,你可以随意修改其公共属性的值。这在很多情况下是必需的,比如一个表示用户会话的

对象,其状态会随着用户的操作而改变。但

record

则倾向于不可变性。虽然你仍然可以在

record

中定义可变的属性(使用

set

),但它强烈推荐使用

init

访问器来创建只读属性,这强制你在对象创建时就设定好所有属性的值,之后就不能再修改了。这种设计哲学在并发编程和函数式编程中尤为重要,因为不可变对象可以安全地在多个线程间共享,无需担心数据竞争问题。而且,

record

还引入了

with

表达式,这是一个非常优雅的特性。当你需要基于现有

record

实例创建新实例,但只修改其中少数几个属性时,

with

表达式能让你以一种非破坏性的方式实现这一点,而不是手动复制所有属性再修改。

再者,是语法糖

record

提供了一种非常简洁的位置记录(positional record)语法。你可以直接在类型声明中定义属性,编译器会自动为你生成一个主构造函数,将这些属性作为参数,并为它们创建

init

访问器。它还会自动生成一个

Deconstruct

方法,方便你将

record

实例的属性解构到单独的变量中。这些都是

class

不具备的,或者说,

class

需要你手动去实现这些功能。

最后,

record

还自动重写了

ToString()

方法,使其默认输出所有公共属性的名称和值,这对于调试和日志记录来说,简直是福音,省去了我们一遍又一遍地编写

ToString()

的麻烦。

为什么C#要引入record类型?它解决了哪些痛点?

说实话,C#引入

record

类型,我觉得是语言发展到一定阶段,对开发者实际痛点的一种回应,也是对现代编程范式的一种拥抱。我们过去写C#代码,尤其是涉及到数据传输对象(DTOs)、值对象或者任何需要表示“纯数据”的结构时,总会遇到一些重复性的工作。

最典型的就是样板代码的冗余。想想看,一个简单的

Person

类,如果你想让

new Person("Alice", 30)

和另一个

new Person("Alice", 30)

被认为是相等的(基于值),你就得手动去重写

Equals()

GetHashCode()

。这不仅繁琐,还容易出错。再比如,为了方便调试,你可能还要重写

ToString()

,让它打印出所有的属性值。这些都是纯粹的“体力活”,而且是每次定义一个数据类都要重复的。

record

的出现,直接把这些“值语义”的样板代码自动化了,极大地提升了开发效率和代码的整洁度。它让我们可以更专注于业务逻辑,而不是那些为了让类型“表现得像个值”而不得不写的额外代码。

另一个痛点是不可变性的推广。在多线程、并发编程日益普遍的今天,可变状态是很多bug的根源。不可变对象天然线程安全,易于推理和测试。虽然

class

也能实现不可变性(通过只读字段或

init

属性),但

record

把它变成了默认和推荐的行为,并且通过

with

表达式提供了一种非常优雅的“非破坏性修改”方式。这让构建基于不可变数据流的系统变得更加自然和简单,减少了副作用,提升了代码的健壮性。对我个人而言,这种默认的不可变性倾向,让我写代码时能更放心地传递数据,不用担心它们在不经意间被修改。

所以,

record

其实是解决了“如何更优雅、更高效地定义和使用值类型或不可变数据类型”这个核心问题。它让C#在处理数据密集型场景时,有了更趁手的工具

在实际开发中,何时选择record,何时选择class?

这是一个非常实用的问题,也是我自己在写代码时会反复思考的。选择

record

还是

class

,并非绝对,更多的是基于你所定义的类型在系统中的“角色”和“行为”。

一般来说,当你的类型主要用于表示“数据”或“值”时,

record

是更优的选择。想想那些不需要独立标识符,其身份完全由其包含的数据决定的对象。例如:

  • 数据传输对象(DTOs):从API接收或发送的数据结构,它们通常只是数据的载体,其相等性应该基于内容。
  • 值对象(Value Objects):在领域驱动设计(DDD)中,像
    Money

    Address

    Coordinates

    这类类型,它们没有自己的生命周期或唯一标识,两个

    Money

    对象如果金额和货币类型都相同,那就是同一个“值”。

  • 不可变配置:应用的配置信息,一旦加载就不应被修改。
  • 临时数据结构:在方法内部或服务之间传递的临时数据。

使用

record

的好处是,你获得了自动的值相等性、漂亮的

ToString()

输出以及

with

表达式带来的便利,这些都能让你的代码更简洁、更安全。

而当你的类型需要表示“实体”或“行为”时,

class

依然是首选

class

更适合那些具有独立生命周期、唯一标识,并且可能包含复杂行为的对象。例如:

  • 领域实体(Domain Entities):在DDD中,像
    User

    Order

    Product

    等,它们有唯一的ID,即使属性值变了,它们仍然是同一个实体。它们的相等性通常是基于ID而不是所有属性。

  • 服务(Services):负责执行业务逻辑的对象,它们通常是单例或有状态的,不适合用
    record

  • 依赖注入的组件:比如一个数据库上下文、一个日志器,它们通常是单例或作用域生命周期,并且需要管理内部状态。
  • 大型或复杂的对象:如果一个对象内部包含大量可变状态,或者其行为比数据更重要,那么
    class

    可能更合适。虽然

    record

    也可以包含可变状态,但它的设计哲学是倾向于不可变性,强行在

    record

    里塞满可变状态可能会有点“别扭”。

总结一下,如果一个对象是“它是什么”比“它能做什么”更重要,并且它的身份由其值决定,那么

record

是你的朋友。如果一个对象是“它能做什么”更重要,或者它需要一个独立于其值的身份,那么

class

依然是不可替代的基石。

record类型有哪些潜在的陷阱或需要注意的地方?

尽管

record

带来了很多便利,但它并非银弹,使用时还是有些地方需要留意,否则可能会踩到一些小坑。

首先,也是最常见的一个误解:

record

的不可变性是“浅层”的。当你定义一个

record

,并使用

init

属性来确保其不可变时,这只保证了

record

本身的属性引用不能被修改。如果这个

record

的属性是一个引用类型(比如一个

List<string>

或另一个

class

实例),那么这个引用本身是不可变的,但它所指向的对象内部的状态仍然是可变的。

举个例子:

public record UserProfile(string Name, List<string> Permissions);  var profile1 = new UserProfile("Alice", new List<string> { "Read", "Write" }); // 使用 with 表达式创建新的 record 实例,Permissions 列表的引用被复制 var profile2 = profile1 with { Name = "Bob" };  // 但如果你直接修改了 profile1 内部的 Permissions 列表 profile1.Permissions.Add("Delete"); // 这行代码是合法的!  // 此时,profile1 和 profile2 的 Permissions 列表都受到了影响,因为它们引用的是同一个 List<string> 实例 Console.WriteLine(string.Join(", ", profile1.Permissions)); // 输出: Read, Write, Delete Console.WriteLine(string.Join(", ", profile2.Permissions)); // 输出: Read, Write, Delete

看到没?

profile1.Permissions

这个属性的引用本身不能被重新赋值,但它指向的

List<string>

对象内部是可以被修改的。这会导致一些意想不到的副作用。要实现真正的深度不可变,你需要确保

record

的所有属性都是不可变的类型,或者在复制时进行深度克隆。这通常需要一些手动的工作,或者使用像

ImmutableList<T>

这样的不可变集合。

其次,继承与相等性

record

支持继承,但在继承链中,

Equals()

GetHashCode()

的实现会变得稍微复杂。基

record

Equals

方法会包含一个

PrintMembers

方法,它会检查所有派生

record

的成员是否也相等。这意味着,如果你有一个基

record

和一个派生

record

,即使派生

record

的额外属性都相同,如果基

record

的属性不同,它们依然被认为是不同的。反之亦然。这通常符合预期,但如果你的继承结构复杂,或者你希望在某些情况下忽略派生类的额外属性进行相等性比较,就需要特别注意。

再来,性能考量。虽然对于大多数应用来说,

record

的性能开销通常可以忽略不计,但它毕竟会生成额外的代码(如

Equals

GetHashCode

ToString

Deconstruct

以及

with

表达式的实现),这会略微增加编译后的IL代码量。在极度性能敏感的场景,比如需要创建数百万个微小对象的循环中,这些额外的开销可能会累积。不过,这通常是过度优化了,只有在profiling显示这里是瓶颈时才需要考虑。

最后,就是滥用问题。就像任何新特性一样,

record

也可能被滥用。如果一个对象的主要目的是封装行为,并且其身份由引用而不是值决定,那么坚持使用

class

会更清晰。强行将一个复杂的、有状态的实体定义为

record

,可能会导致代码的可读性和维护性下降,因为它的行为与

record

的设计初衷不符。所以,理解

record

的设计哲学,并在合适的场景使用它,远比盲目跟风重要。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享