1 背景与动机

传统面向对象编程的核心思想是一个对象有着唯一标识,表现为对象引用,封装着随时可变的属性状态,如果你改变了一个属性的状态,这个对象还是原来那个对象,就是对象引用没有因为状态的改变而改变,也就是说该对象可以有很多种状态。C#从最初开始也是一直这样设计和工作的。但是一些时候,你可能非常需要一种恰好相反的方式,例如我需要一个对象只有一个状态,那么原来那种默认方式往往会成为阻力,使得事情变得费时费力。

当一个类型的对象在创建时被指定状态后,就不会再变化的对象,我们称之为不可变类型。这种类型是线程安全的,不需要进行线程同步,非常适合并行计算的数据共享。它减少了更新对象会引起各种bug的风险,更为安全。System.DateTime和string就是不可变类型非常经典的代表。

原来,我们要用类来创建一个不可变类型,你首先要定义只读字段和属性,并且还要重写涉及相等判断的方法等。在C#9.0中,引入了record,专门用来以最简的方式创建不可变类型的新方式。如果你需要一个行为像值类型的引用类型,你可以使用record;如果你需要整个对象都是不可变的,且行为像一个值,那么你也可考虑将其声明为一个record类型。 那么什么是record类型?

2 Record介绍

record类型是一种用record关键字声明的新的引用类型,与类不同的是,它是基于值相等而不是唯一的标识符——对象引用。他有着引用类型的支持大对象、继承、多态等特性,也有着结构的基于值相等的特性。可以说有着class和struct两者的优势,在一些情况下可以用以替代class和struct。

提到不可变的类型,我们会想到readonly struct,那么为什么要选择添加一个新的类型,而不是用readonly struct呢?这是因为记录有着如下优点:

  • 在构造不可变的数据结构时,它的语法简单易用。

  • record为引用类型,不用像值类型在传递时需要内存分配,并进行整体拷贝。

  • 构造函数和结构函数为一体的、简化的位置记录

  • 有力的相等性支持,重写了Equals(object), IEquatable, 和GetHashCode()这些基本方法。

2.1 record类型的定义与使用

2.1.1 常规方式

record类型可以定义为可变的,也可以是不可变的。现在,我们用record定义一个只有只读属性的Person类型如下。这种只有只读属性的类型,因为其在创建好之后,属性就不能再被修改,我们通常把这种类型叫做不可变类型。

public record Person
{
public string LastName { get; }
public string FirstName { get; } public Person(string first, string last) => (FirstName, LastName) = (first, last);
}

上面这种声明,在使用时,只能用带参的构造函数进行初始化。要创建一个record对象跟类没有什么区别:

Person person = new("Andy", "Kang");

如果要支持用对象初始化器进行初始化,则在属性中使用init关键字。这种形式,如果不需要用带参的构造函数进行初始化,可以不定义带参的构造函数,上面的Person可以改为下面形式。

public record Person
{
public string? FirstName { get; init; }
public string? LastName { get; init; }
}

现在,创建Person对象时,用初始化器进行初始化如下:

Person person = new() { FirstName = "Andy", LastName = "Kang"};

如果需要是可变类型的record,我们定义如下。这种因为有set访问器,所有它支持用对象初始化器进行初始化,如果你想用构造函数进行初始化,你可以添加自己的构造函数。

public record Person
{
public string? FirstName { get; set; }
public string? LastName { get; set; } }

2.1.2 位置记录 / Positional records

为了支持将record对象能解构成元组,我们给record添加解构函数Deconstruct。这种record就称为位置记录。下面代码定义的Person,记录的内容是通过构造函数的参数传入,并且通过位置解构函数提取出来。你完全可以在记录中定义你自己的构造和解构函数(注意不是析构函数)。如下所示:。

public record Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
public Person(string firstName, string lastName)
=> (FirstName, LastName) = (firstName, lastName);
public void Deconstruct(out string firstName, out string lastName)
=> (firstName, lastName) = (FirstName, LastName);
}

针对上面如此复杂的代码,C#9.0提供了更精简的语法表达上面同样的内容。需要注意的是,这种记录类型是不可变的。这也就是为什么有record默认是不可变的说法由来。

public record Person(string FirstName, string LastName);

该方式声明了公开的、仅可初始化的自动属性、构造函数和解构函数。现在创建对象,你就可以写如下代码:

var person = new Person("Mads", "Torgersen"); // 位置构造函数
var (firstName, lastName) = person; // 位置解构函数

当然,如果你不喜欢产生的自动属性、构造函数和解构函数,你可以自定义同名成员代替,产生的构造函数和解构函数将会只使用你自定义的那个。在这种情况下,被自定义参数处于你用于初始化的作用域内,例如,你想让FirstName是个保护属性:

public record Person(string FirstName, string LastName)
{
protected string FirstName { get; init; } = FirstName;
}

如上例子所示,对位置记录进行扩展,你可以在大括号里添加你想要的任何成员。

一个位置记录可以像下面这样调用父类构造函数。

public record Student(string FirstName, string LastName, int ID) : Person(FirstName, LastName);

2.1.3 定义的总结

record默认情况下是被设计用来进行描述不可变类型的,因此位置记录这种短小简明的声明方式是推荐方式。

2.2 with表达式

当使用不可变的数据时,一个常见的模式是从现存的值创建新值来呈现一个新状态。例如,如果Person打算改变他的姓氏(last name),我们就需要通过拷贝原来数据,并赋予一个不同的last name值来呈现一个新Person。这种技术被称为非破坏性改变。作为描绘随时间变化的person,record呈现了一个特定时间的person的状态。为了帮助进行这种类型的编程,针对records就提出了with表达式,用于拷贝原有对象,并对特定属性进行修改:

var person = new Person { FirstName = "Mads", LastName = "Nielsen" };
var otherPerson = person with { LastName = "Torgersen" };

如果只是进行拷贝,不需要修改属性,那么无须指定任何属性修改,如下所示:

Person clone = person with { };

with表达式使用初始化语法来说明新对象在哪里与原有对象不同。with表达式实际上是拷贝原来对象的整个状态值到新对象,然后根据对象初始化器来改变指定值。这意味着属性必须有init或者set访问器,才能用with表达式进行更改。

需要注意的是:

  • with表达式左边操作数必须为record类型。
  • record的引用类型的成员在拷贝的时候,只是将所指实例的引用进行了拷贝。

2.3 record的面向对象的特性——继承、多态等

记录(record)和类一样,在面向对象方面,支持继承,多态等所有特性。除过前面提到的record专有的特性,其他语法写法跟类也是一样。同其他类型一样,record的基类依然是object。

要注意的是:

  • 记录只能从记录继承,不能从类继承,也不能被任何类继承。

  • record不能定义为static的,但是可以有static成员。

下面一个学生record,它继承自Person:

public record Person
{
public string? FirstName { get; init; }
public string? LastName { get; init; }
} public sealed record Student : Person
{
public int ID { get; init; }
}

对于位置记录,只要保持record特有的写法即可:

public record Person(string FirstName, string LastName);

public sealed record Student(string FirstName, string LastName, int Level) : Person(FirstName, LastName);

public sealed record Teacher(string FirstName, string LastName, string Title) : Person(FirstName, LastName)
{
public override string ToString()
{
StringBuilder s = new();
base.PrintMembers(s);
return $"{s.ToString()} is a Teacher";
}
}

with表达式和值相等性与记录的继承结合的很好,因为他们不仅是静态的已知类型,而且考虑到了整个运行时对象。比如,我创建一个Student对象,将其存在Person变量里。

Person student = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 129 };

with表达式仍然拷贝整个对象并保持着运行时的类型:

var otherStudent = student with { LastName = "Torgersen" };
WriteLine(otherStudent is Student); // true

同样地,值相等性确保两个对象有着同样的运行时类型,然后比较他们的所有状态:

Person similarStudent = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 130 };
WriteLine(student != similarStudent); //true, 由于ID值不同

2.4 record实现原理

从本质上来讲,record仍然是一个类,但是关键字record赋予这个类额外的几个像值的行为。也就是,当你定义了record时候,编译器会自动生成以下方法,来实现基于值相等的特性(即只要两个record的所有属性都相等,且类型相同,那么这两个record就相等)、对象的拷贝和成员及其值的输出。

  • 基于值相等性的比较方法,如Equals,==,!=,EqualityContract等。

  • 重写GetHashCode()

  • 拷贝和克隆成员

  • PrintMembers和ToString()方法

例如我先定义一个Person的记录类型:

public record Person(string FirstName, string LastName);

编译器生成的代码和下面的代码定义是等价的。但是要注意的是,跟编译器实际生成的代码相比,名字的命名是有所不同的。

public class Person : IEquatable<Person>
{
private readonly string _FirstName;
private readonly string _LastName; protected virtual Type EqualityContract
{
get
{
return typeof(Person);
}
} public string FirstName
{
get
{
return _FirstName;
}
init
{
_FirstName = value;
}
}
public string LastName
{
get
{
return _LastName;
}
init
{
_LastName = value;
}
}
public Person(string FirstName, string LastName)
{
_FirstName = FirstName;
_LastName = LastName;
} public override string ToString()
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.Append("Person");
stringBuilder.Append(" { ");
if (PrintMembers(stringBuilder))
{
stringBuilder.Append(" ");
}
stringBuilder.Append("}");
return stringBuilder.ToString();
} protected virtual bool PrintMembers(StringBuilder builder)
{
builder.Append("FirstName");
builder.Append(" = ");
builder.Append((object)FirstName);
builder.Append(", ");
builder.Append("LastName");
builder.Append(" = ");
builder.Append((object)LastName);
return true;
} public static bool operator !=(Person r1, Person r2)
{
return !(r1 == r2);
} public static bool operator ==(Person r1, Person r2)
{
return (object)r1 == r2 || ((object)r1 != null && r1.Equals(r2));
} public override int GetHashCode()
{
return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295
+ EqualityComparer<string>.Default.GetHashCode(_FirstName)) * -1521134295
+ EqualityComparer<string>.Default.GetHashCode(_LastName);
} public override bool Equals(object obj)
{
return Equals(obj as Person);
} public virtual bool Equals(Person other)
{
return (object)other != null
&& EqualityContract == other.EqualityContract
&& EqualityComparer<string>.Default.Equals(_FirstName, other._FirstName)
&& EqualityComparer<string>.Default.Equals(_LastName, other._LastName);
} public virtual Person Clone()
{
return new Person(this);
} protected Person(Person original)
{
_FirstName = original._FirstName;
_LastName = original._LastName;
}
public void Deconstruct(out string FirstName, out string LastName)
{
FirstName = this.FirstName;
LastName = this.LastName;
}
}

这些由编译器生成的一些成员,是允许编程人员自定义的,一旦编译器发现有自定义的某个成员,它就不会再生成这个成员。

由此可见,record实际上就是编译器特性,并且records由他们的内容来界定,不是他们的引用标识符。从这一点上讲,records更接近于结构,但是他们依然是引用类型。

2.4.1 基于值的相等

所有对象都从object类型继承了 Equals(object),这是静态方法 Object.Equals(object, object) 用来比较两个非空参数的基础。结构重写了这个方法,通过递归调用每个结构字段的Equals方法,从而有了“基于值的相等”。Recrods也是这样。这意味着只要他们的值保持一致,两个record对象可以不是同一个对象实例就会相等。例如我们将修改的Last name又修改回去了:

var originalPerson = otherPerson with { LastName = "Nielsen" };

现在我们会得到 ReferenceEquals(person, originalPerson) = false (他们不是同一对象),但是 Equals(person, originalPerson) = true (他们有同样的值).。与基于值的Equals一起的,还伴有基于值的GetHashCode()的重写。另外,records实现了IEquatable并重载了==和 !=这两个操作符,这些都是为了基于值的行为在所有的不同的相等机制方面保持一致。

基于值的相等和可变性契合的不总是那么好。一个问题是改变值可能引起GetHashCode的结果随时变化,如果这个对象被存放在哈希表中,就会出问题。我们没有不允许使用可变的record,但是我们不鼓励那样做,除非你已经想到了后果。

如果你不喜欢默认Equals重写的字段与字段比较行为,你可以进行重写。你只需要认真理解基于值的相等时如何在records中工作原理,特别是涉及到继承的时候。

除了熟悉的Equals,==和!=操作符之外,record还多了一个新的EqualityContract只读属性,该属性返回类型是Type类型,返回值默认为该record的类型。该属性用来在判断两个具有继承关系不同类型的record相等时,该record所依据的类型。下面我们看一个有关EqualityContract的例子,定义一个学生record,他继承自Person:

public record Student(string FirstName, string LastName, int Level) : Person(FirstName, LastName);

这个时候,我们分别创建一个Person和Student实例,都用来描述同样的人:

Person p = new Person("Jerry", "Kang");
Person s = new Student("Jerry", "Kang", 1);
WriteLine(p == s); // False

这两者比较的结果是False,这与我们实际需求不相符。那么我们可以重写EqualityContract来实现两种相等:

public record Student(string FirstName, string LastName, int Level) : Person(FirstName, LastName)
{
protected override Type EqualityContract
{
get => typeof(Person);
}
}

经过此改造之后,上面例子中的两个实例就会相等。EqualityContract的修饰符是依据下面情况确定的:

  • 如果基类是object, 属性是virtual;
  • 如果基类是另一个record类型,则该属性是override;
  • 如果基类类型是sealed,则该属性也是sealed的。

2.4.2 拷贝克隆与with表达式

一个record在编译的时候,会自动生成一个带有保护访问级别的“拷贝构造函数”,用来将现有record对象的字段值拷贝到新对象对应字段中:

protected Person(Person original) { /* 拷贝所有字段 */ } // 编译器生成

with表达式就会引起拷贝构造函数被调用,然后应用对象初始化器来有限更改属性相应值。如果你不喜欢默认的产生的拷贝构造函数,你可以自定义该构造函数,编译器一旦发现有自定义的构造函数,就不会在自动生成,with表达式也会进行调用。

public record Person(string FirstName, string LastName)
{
protected Person(Person original)
{
this.FirstName = original.FirstName;
this.LastName = original.LastName;
}
}

编译器默认地还会生成with表达式会使用的一个Clone方法用于创建新的record对象,这个方法是不能在record类型里面自定义的。

2.4.3 PrintMembers和ToString()方法

如果你用Console.WriteLine来输出record的实例,就会发现其输出与用class定义的类型的默认的ToString完全不同。其输出为各成员及其值组成的字符串:

Person {FirstName = Andy, LastName = Kang}

这是因为,基于值相等的类型,我们更加关注于具体的值的情况,因此在编译record类型时会自动生成重写了ToString的行为的代码。针对record类型,编译器也会自动生成一个保护级别的PrintMembers方法,该方法用于生成各成员及其值的字符串,即上面结果中的红色字体部分。ToString中,就调用了PrintMembers来生成其成员字符串部分,其他部分即蓝色字体部分在ToString中补充。

我们也可以定义PrintMembers和重写ToString方法来实现自己想要的功能,如下面实现ToString输出为Json格式:

public record Person(string FirstName, string LastName)
{
protected virtual bool PrintMembers(StringBuilder builder)
{
builder.Append("\"FirstName\"");
builder.Append(" : ");
builder.Append($"\"{ FirstName}\"");
builder.Append(", ");
builder.Append("\"LastName\"");
builder.Append(" : ");
builder.Append($"\"{ LastName}\"");
return true;
} public override string ToString()
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.Append("{");
if (PrintMembers(stringBuilder))
{
stringBuilder.Append(" ");
}
stringBuilder.Append("}");
return stringBuilder.ToString();
}
}

record因为都是继承自Object,因此ToString都是采用override修饰符。而PrintMembers方法修饰符是依据下面情况决定的:

  • 如果记录不是sealed而是从object继承的, 该方法是protected virtual;

  • 如果记录基类是另一个record类型,则该方法是protected override;

  • 如果记录类型是sealed,则该方法也是private的。

3 应用场景

3.1 Web Api

用于web api返回的数据,通常作为一种一次性的传输型数据,不需要是可变的,因此适合使用record。

3.2 并发和多线程计算

作为不可变数据类型record对于并行计算和多线程之间的数据共享非常适合,安全可靠。

3.3 数据日志

record本身的不可变性和ToString的数据内容的输出,不需要很多人工编写很多代码,就适合进行日志处理。

3.4 其他

其他涉及到有大量基于值类型比较和复制的场景,也是record的常用的使用场景。

4 结束语

在生产应用中,有着众多的使用场景,以便我们用record来替换写一个类。未知的还在等我们进一步探索。

如对您有价值,请推荐,您的鼓励是我继续的动力,在此万分感谢。关注本人公众号“码客风云”,享第一时间阅读最新文章。

C# 9.0新特性详解系列之五:记录(record)和with表达式的更多相关文章

  1. C#9.0新特性详解系列之六:增强的模式匹配

    自C#7.0以来,模式匹配就作为C#的一项重要的新特性在不断地演化,这个借鉴于其小弟F#的函数式编程的概念,使得C#的本领越来越多,C#9.0就对模式匹配这一功能做了进一步的增强. 为了更为深入和全面 ...

  2. C# 9.0新特性详解系列之一:只初始化设置器(init only setter)

    1.背景与动机 自C#1.0版本以来,我们要定义一个不可变数据类型的基本做法就是:先声明字段为readonly,再声明只包含get访问器的属性.例子如下: struct Point { public ...

  3. C# 9.0新特性详解系列之二:扩展方法GetEnumerator支持foreach循环

    1.介绍 我们知道,我们要使一个类型支持foreach循环,就需要这个类型满足下面条件之一: 该类型实例如果实现了下列接口中的其中之一: System.Collections.IEnumerable ...

  4. C# 9.0新特性详解系列之三:模块初始化器

    1 背景动机 关于模块或者程序集初始化工作一直是C#的一个痛点,微软内部外部都有大量的报告反应很多客户一直被这个问题困扰,这还不算没有统计上的客户.那么解决这个问题,还有基于什么样的考虑呢? 在库加载 ...

  5. [转]Servlet 3.0 新特性详解

    原文地址:http://blog.csdn.net/xiazdong/article/details/7208316 Servlet 3.0 新特性概览 1.Servlet.Filter.Listen ...

  6. Servlet 3.0 新特性详解

    转自:http://www.ibm.com/developerworks/cn/java/j-lo-servlet30/#major3 Servlet 是 Java EE 规范体系的重要组成部分,也是 ...

  7. 【转帖】Servlet 3.0 新特性详解

    http://www.ibm.com/developerworks/cn/java/j-lo-servlet30/ Servlet 3.0 新特性概述 Servlet 3.0 作为 Java EE 6 ...

  8. Servlet 3.0 新特性详解 (转载)

    原文地址:https://www.ibm.com/developerworks/cn/java/j-lo-servlet30/ Servlet 3.0 新特性概述 Servlet 3.0 作为 Jav ...

  9. Android6.0 新特性详解

    一 运行时权限 Android6.0 引入了一个新的应用权限模型,期望对用户更容易理解,更易用和更安全.该模型将标记为危险的权限从安装时权限(Install Time Permission)模型 移动 ...

随机推荐

  1. Docker UnicodeEncodeError: 'ascii' codec can't encode characters in position

    在容器里查询nova服务的时候字符集报错问题留档及处理方法: 1.在容器里执行nova list --all 提示 [root@stack1 region_01]# nova list --all E ...

  2. rbd-mirror新功能

    RBD 的 mirroring 功能将会在下一个稳定版本Jewel中实现,这个Jewel版本已经发布了第一个版本10.1.0,这个功能已经在这个发布的版本中实现了 一.基本原理 我们试图解决的或者至少 ...

  3. ceph单机多mon的实现

    ceph默认情况下是以主机名来作为mon的识别的,所以这个情况下用部署工具是无法创建多个mon的,这个地方使用手动的方式可以很方便的创建多个mon 1.创建mon的数据存储目录 mkdir /var/ ...

  4. xenserver使用ceph的rbd的方法

    首先安装的xenserver6.5的环境,看到有地方有提到这个上面可以安装rbd的支持,网上有一种方式是libvirt+kvm方式,因为ceph对libviet是原生支持的,但是xenserver底层 ...

  5. Python_异常处理、调试

    1.try except 机制 # 错误处理 # 一般程序都要用到错误捕获,当没有加且有错误的时候Python解释器会执行错误捕获,且是一层层向上捕获[所以问题点会在最下面] try: print(' ...

  6. get_object_vars

    get_object_vars()

  7. 万字长文!从底层开始带你了解并发编程,彻底帮你搞懂java锁!

    线程是否要锁住同步资源 锁住 悲观锁 不锁住 乐观锁 锁住同步资源失败 线程是否要阻塞 阻塞 不阻塞自旋锁,适应性自旋锁 多个线程竞争同步资源的流程细节有没有区别 不锁住资源,多个线程只有一个能修改资 ...

  8. 如何使用ABBYY FineReader 手动管理文档区域

    在运用OCR编辑器时,ABBYY FineReader 15(Windows系统)OCR文字识别软件会对扫描仪或数码相机导入的图像进行识别和检测.在识别和检测之前,软件会自动对PDF文档中的文本.图片 ...

  9. PDF编辑:pdfFactory文本备注功能详解

    除了word的doc文件外,PDF也是我们经常接触到的文件格式,经常需要在pdf文件上进行编辑与修改,或者给内容做提示和备注. 文件的文本备注功能可以用pdfFactory来进行,编辑打印PDF一条龙 ...

  10. 网络系列之 jsonp 百度联想词

    jsonp 可以跨域,ajax 不可以,ajax 会受到浏览器的同源策略影响,何为同源策略? 同源策略就是,如果 A 网站 想拿 B网站里的资源, 那么 有三个条件, 你得满足才能拿. 第一个:域名相 ...