MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C#记录类型(Record)与不可变对象设计

2022-07-221.4k 阅读

C# 记录类型(Record)基础

记录类型的定义

在C# 9.0 引入了记录类型(Record),它是一种引用类型,主要用于表示不可变的数据结构。记录类型为创建不可变对象提供了一种简洁的方式。定义记录类型非常简单,使用 record 关键字替代 class 关键字即可。例如:

public record Person
{
    public string Name { get; init; }
    public int Age { get; init; }
}

在上述代码中,Person 是一个记录类型。注意属性使用了 init 访问器,这确保了属性只能在对象初始化时赋值,从而实现了不可变性。

记录类型的特性

  1. 不可变性:记录类型默认倾向于不可变设计。除了使用 init 访问器的属性,记录类型还可以通过构造函数进行初始化,一旦初始化完成,对象的状态就不再改变。
public record Book
{
    public string Title { get; init; }
    public string Author { get; init; }
    public Book(string title, string author)
    {
        Title = title;
        Author = author;
    }
}
  1. 值语义:记录类型支持值语义。这意味着当比较两个记录类型的实例时,比较的是它们的属性值,而不是对象的引用。例如:
var book1 = new Book("C# in Depth", "Jon Skeet");
var book2 = new Book("C# in Depth", "Jon Skeet");
bool areEqual = book1 == book2; // true
  1. 解构:记录类型自动支持解构。解构允许你将对象的属性值解包到多个变量中。对于前面定义的 Book 记录类型,可以这样解构:
var book = new Book("Effective C#", "Bill Wagner");
var (title, author) = book;
Console.WriteLine($"Title: {title}, Author: {author}");

不可变对象设计的重要性

线程安全

在多线程环境下,可变对象可能会导致数据竞争和不一致性问题。不可变对象由于其状态不可改变,天然是线程安全的。例如,考虑一个多线程访问银行账户余额的场景:

// 可变的银行账户类
public class MutableBankAccount
{
    public decimal Balance { get; set; }
    public void Deposit(decimal amount) => Balance += amount;
    public void Withdraw(decimal amount) => Balance -= amount;
}

如果多个线程同时调用 DepositWithdraw 方法,可能会导致 Balance 值的不一致。而使用不可变对象可以避免这种情况:

// 不可变的银行账户记录类型
public record ImmutableBankAccount
{
    public decimal Balance { get; init; }
    public ImmutableBankAccount Deposit(decimal amount) =>
        new ImmutableBankAccount { Balance = Balance + amount };
    public ImmutableBankAccount Withdraw(decimal amount) =>
        new ImmutableBankAccount { Balance = Balance - amount };
}

在上述不可变版本中,DepositWithdraw 方法返回新的 ImmutableBankAccount 实例,而不是修改现有实例,因此在多线程环境下是安全的。

数据一致性

不可变对象有助于维护数据的一致性。一旦对象创建,其状态就不能改变,这使得程序的行为更容易预测。例如,在一个订单处理系统中,如果订单对象是不可变的,那么在订单处理的各个阶段,订单的信息不会被意外修改,确保了订单处理的准确性。

简化编程模型

不可变对象可以简化编程模型,尤其是在函数式编程风格中。函数式编程强调函数的无副作用,不可变对象正好符合这一理念。因为对象不可变,函数只依赖于输入参数,而不会对外部状态产生影响,使得代码更易于理解和测试。

C# 记录类型与不可变对象设计的结合

使用记录类型创建不可变对象

如前文所述,C# 记录类型通过 init 访问器和构造函数等方式,很容易创建不可变对象。除了简单的属性初始化,记录类型还支持位置参数,进一步简化不可变对象的创建。例如:

public record Employee(string Name, int Age);
var employee = new Employee("Alice", 30);

这里的 Employee 记录类型使用位置参数定义,创建实例时直接传入参数值,简洁明了。

记录类型的复制与变异

虽然记录类型是不可变的,但有时需要基于现有实例创建一个新的实例,同时修改部分属性值。记录类型提供了 with 表达式来实现这一点。例如,对于前面的 Book 记录类型:

var originalBook = new Book("Clean Code", "Robert C. Martin");
var newBook = originalBook with { Title = "Clean Architecture" };

在上述代码中,with 表达式基于 originalBook 创建了一个新的 Book 实例 newBook,并修改了 Title 属性的值。

嵌套记录类型

记录类型可以嵌套在其他记录类型或类中,这在表示复杂的不可变数据结构时非常有用。例如:

public record Address
{
    public string Street { get; init; }
    public string City { get; init; }
}

public record Customer
{
    public string Name { get; init; }
    public Address ShippingAddress { get; init; }
}

这里 Customer 记录类型包含一个 Address 记录类型的属性 ShippingAddress,形成了一个嵌套的不可变数据结构。

记录类型在实际项目中的应用

领域模型

在领域驱动设计(DDD)中,记录类型非常适合表示领域模型中的值对象。值对象通常表示一个没有唯一标识符、仅由其属性定义的对象。例如,在一个电子商务系统中,货币金额、日期范围等都可以用记录类型表示。

public record Money
{
    public decimal Amount { get; init; }
    public string Currency { get; init; }
}

public record DateRange
{
    public DateTime Start { get; init; }
    public DateTime End { get; init; }
}

这些记录类型作为不可变的值对象,在领域模型中传递和使用,有助于保持数据的一致性和完整性。

数据传输对象(DTO)

记录类型也很适合作为数据传输对象(DTO)。DTO 通常用于在不同层之间传递数据,例如从控制器到服务层,或者从服务层到数据库访问层。由于 DTO 主要用于数据传输,不需要有复杂的行为,记录类型的简洁性和不可变性正好满足这一需求。

public record UserDto
{
    public string Username { get; init; }
    public string Email { get; init; }
}

在 Web API 项目中,可以使用 UserDto 记录类型将用户信息从控制器传递到服务层进行处理。

事件溯源

在事件溯源系统中,记录类型可以很好地表示事件。事件通常是不可变的,记录了系统状态的变化。例如,在一个银行转账系统中,转账事件可以用记录类型表示:

public record TransferEvent
{
    public Guid TransactionId { get; init; }
    public Guid FromAccountId { get; init; }
    public Guid ToAccountId { get; init; }
    public decimal Amount { get; init; }
    public DateTime Timestamp { get; init; }
}

这些事件记录可以存储在事件存储中,用于重建系统的状态或进行审计。

与传统类的对比

语法简洁性

记录类型在语法上比传统类更简洁。定义记录类型时,不需要显式地声明构造函数和属性访问器(在使用位置参数的情况下)。例如,定义一个表示点的对象:

// 记录类型
public record Point(int X, int Y);

// 传统类
public class TraditionalPoint
{
    public int X { get; set; }
    public int Y { get; set; }
    public TraditionalPoint(int x, int y)
    {
        X = x;
        Y = y;
    }
}

可以明显看出记录类型的定义更简洁。

不可变特性

传统类默认是可变的,要实现不可变性需要额外的努力,如使用 readonly 字段、只提供 init 访问器或不可变集合等。而记录类型天生倾向于不可变设计,通过 init 访问器和构造函数等方式更容易实现不可变性。

相等性比较

传统类默认使用引用比较(== 比较对象的引用),如果要进行值比较,需要重写 Equals 方法和 GetHashCode 方法。而记录类型默认支持值语义,自动实现了基于属性值的相等性比较。例如:

// 记录类型的相等性比较
var point1 = new Point(10, 20);
var point2 = new Point(10, 20);
bool recordEqual = point1 == point2; // true

// 传统类的相等性比较(默认引用比较)
var traditionalPoint1 = new TraditionalPoint(10, 20);
var traditionalPoint2 = new TraditionalPoint(10, 20);
bool traditionalEqual = traditionalPoint1 == traditionalPoint2; // false

如果要让 TraditionalPoint 类支持值比较,需要重写 EqualsGetHashCode 方法:

public class TraditionalPoint
{
    public int X { get; set; }
    public int Y { get; set; }
    public TraditionalPoint(int x, int y)
    {
        X = x;
        Y = y;
    }
    public override bool Equals(object obj)
    {
        if (obj is TraditionalPoint other)
        {
            return X == other.X && Y == other.Y;
        }
        return false;
    }
    public override int GetHashCode()
    {
        return HashCode.Combine(X, Y);
    }
}

相比之下,记录类型在相等性比较方面更加便捷。

记录类型的局限性与注意事项

继承与多态

记录类型虽然支持继承,但在使用继承时需要注意一些限制。由于记录类型的主要设计目标是不可变性和值语义,继承可能会破坏这些特性。例如,派生记录类型可能会添加新的属性,这可能导致相等性比较变得复杂。此外,记录类型不支持虚方法和抽象方法,这在需要多态行为时可能会受到限制。

public record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);

在上述代码中,Dog 记录类型继承自 Animal 记录类型。如果在比较 Dog 实例时,不仅要比较 Name 属性,还要比较 Breed 属性,这就需要额外的处理。

性能

虽然记录类型在很多方面带来了便利,但在性能敏感的场景下,需要注意其性能表现。由于记录类型是引用类型,创建和销毁记录类型实例会涉及到垃圾回收。此外,记录类型的自动属性访问器和相等性比较等功能也会带来一定的性能开销。在性能关键的代码中,可能需要对记录类型的使用进行性能测试和优化。

序列化与反序列化

在进行序列化和反序列化时,记录类型可能会遇到一些问题。默认情况下,记录类型的 init 访问器可能会导致反序列化失败,因为反序列化框架通常需要一个无参数的构造函数或者公共的设置器。为了解决这个问题,可以提供一个无参数的构造函数或者使用支持 init 访问器的反序列化框架。例如,使用 System.Text.Json 进行序列化和反序列化时,可以配置 JsonSerializerOptions 来支持 init 访问器:

var options = new JsonSerializerOptions
{
    PropertyNameCaseInsensitive = true,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true
};
options.Converters.Add(new JsonStringEnumConverter());
options.IncludeFields = true;
options.MetadataPropertyHandling = MetadataPropertyHandling.Ignore;
options.NumberHandling = JsonNumberHandling.AllowReadingFromString;
options.ReadCommentHandling = JsonReadCommentHandling.Skip;
options.UnknownTypeHandling = JsonUnknownTypeHandling.JsonNode;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.Converters.Add(new JsonStringEnumConverter());
options.AddContext<MyJsonContext>();
options.Converters.Add(new InitOnlyPropertyConverter());

var json = JsonSerializer.Serialize(book, options);
var deserializedBook = JsonSerializer.Deserialize<Book>(json, options);

在上述代码中,通过自定义 InitOnlyPropertyConverter 来支持 init 访问器的反序列化。

综上所述,C# 记录类型为不可变对象设计提供了强大而简洁的方式。在实际项目中,合理使用记录类型可以提高代码的可读性、可维护性和安全性。但同时,也需要了解其局限性和注意事项,以确保在各种场景下都能发挥其优势。无论是在领域模型、数据传输对象还是事件溯源等方面,记录类型都有着广泛的应用前景,开发者可以根据项目的具体需求,灵活运用记录类型来优化代码结构和提升系统性能。