C#记录类型与集合的深度解析

本文深入探讨了C#记录类型与集合在实际应用中的各种技术细节,包括记录类型的相等性比较、不可变集合的相等性语义、引用相等性比较器的使用,以及Visual Studio对记录类型的工具支持等核心问题。

记录类型与集合

这篇文章在某种程度上是我在选举站点中使用记录类型和集合时遇到的各种摩擦点的汇总。

记录类型回顾

这可能会成为本系列中最具普遍实用性的博客文章。尽管记录类型自C# 10版本就已存在,但我自己使用得并不多。(我期待使用它们已有十多年,但那是另一回事了。)

决定让所有数据模型都不可变后,在C#中使用记录类型(在我的情况下总是密封记录)来实现这些模型几乎是理所当然的。只需使用与主构造函数相同的格式指定所需的属性,编译器就会为您完成一堆样板工作。

作为一个简单示例,考虑以下记录声明:

1
public sealed record Candidate(int Id, string Name, int? MySocietyId, int? ParliamentId);

这生成的代码大致相当于:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public sealed class Candidate : IEquatable<Candidate>
{
    public int Id { get; }
    public string Name { get; }
    public int? MySocietyId { get; }
    public int? ParliamentId { get; }
    
    public Candidate(int id, string name, int? mySocietyId, int? parliamentId)
    {
        Id = id;
        Name = name;
        MySocietyId = mySocietyId;
        ParliamentId = parliamentId;
    }
    
    public override bool Equals(object? obj) => obj is Candidate other && Equals(other);
    
    public override int GetHashCode()
    {
        // 真实代码还使用了EqualityContract,此处跳过
        int idHash = EqualityComparer<int>.Default.GetHashCode(Id);
        int hash = idHash * -1521134295;
        int nameHash = EqualityComparer<string>.Default.GetHashCode(Name);
        hash = (hash + nameHash) * -1521134295;
        int mySocietyIdHash = EqualityComparer<int?>.Default.GetHashCode(MySocietyId);
        hash = (hash + mySocietyIdHash) * -1521134295;
        int parliamentIdHash = EqualityComparer<int?>.Default.GetHashCode(ParliamentId);
        hash = (hash + parliamentIdHash) * -1521134295;
        return hash;
    }
    
    public bool Equals(Candidate? other)
    {
        if (ReferenceEquals(this, other))
        {
            return true;
        }
        if (other is null)
        {
            return false;
        }
        // 真实代码还使用了EqualityContract,此处跳过
        return EqualityComparer<int>.Default.Equals(Id, other.Id) &&
            EqualityComparer<string>.Default.Equals(Name, other.Name) &&
            EqualityComparer<int?>.Default.Equals(MySocietyId, other.MySocietyId) &&
            EqualityComparer<int?>.Default.Equals(ParliamentId, other.ParliamentId);
    }
    
    public static bool operator==(Candidate? left, Candidate? right) =>
    {
        if (ReferenceEquals(left, right))
        {
            return true;
        }
        if (left is null)
        {
            return false;
        }
        return left.Equals(right);
    }
    
    public static bool operator!=(Candidate? left, Candidate? right) => !(left == right);
    
    public override string ToString() =>
        $"Candidate {{ Id = {Id}, Name = {Name}, MySocietyId = {MySocietyId}, ParliamentId = {ParliamentId} }}";
    
    public void Deconstruct(out int Id, out string Name, out int? MySocietyId, out int? ParliamentId) =>
        (Id, Name, MySocietyId, ParliamentId) = (this.Id, this.Name, this.MySocietyId, this.ParliamentId);
}

(这可以使用主构造函数写得更紧凑,但我坚持使用"老派"C#以避免混淆。)

此外,编译器允许对记录使用with运算符,基于现有实例和一些更新的属性创建新实例。例如:

1
2
var original = new Candidate(10, "Jon", 20, 30);
var updated = original with { Id = 40, Name = "Jonathan" };

这一切都很棒!除非它不完全如此……

记录类型相等性

如上所示,记录类型的默认相等性实现为每个属性使用EqualityComparer<T>.Default。当属性类型的默认相等性比较器是您想要的时,这很好——但情况并非总是如此。在我们的选举数据模型案例中,大多数类型都没问题——但ImmutableList<T>不是,我们经常使用它。

ImmutableList<T>本身不重写EqualsGetHashCode——因此它具有引用相等性语义。我真正想要的是使用元素类型的相等性比较器,并说如果两个不可变列表具有相同的计数,并且元素成对考虑时相等,则它们是相等的。这很容易实现——以及合适的GetHashCode方法。它可以轻松地包装在实现IEqualityComparer<ImmutableList<T>>的类型中,尽管我碰巧还没有这样做。

不幸的是,C#中记录类型的工作方式,无法为给定属性指定要使用的相等性比较器。如果直接实现EqualsGetHashCode方法,则使用这些方法而不是生成的版本(并且生成的Equals(object)代码仍将使用您实现的版本),但这意味着您必须为所有属性实现它。这反过来意味着,如果在记录中添加新属性,您需要记住修改EqualsGetHashCode(我至少忘记过一次)——而如果您乐于使用默认生成的实现,添加属性是微不足道的。

我真正想要的是某种方式向编译器指示它应该使用指定类型来获取属性的相等性比较器(可以假定为无状态的)。例如,假设我们有这些类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 想象这在框架中...
public interface IEqualityComparerProvider
{
    static abstract IEqualityComparer<T> GetEqualityComparer<T>();
}

// 这个也是...
[AttributeUsage(AttributeTargets.Property)]
public sealed class EqualityComparerAttribute : Attribute
{
    public Type ProviderType { get; }
    
    public EqualityComparer(Type providerType)
    {
        ProviderType = providerType;
    }
}

现在我可以像这样实现接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public sealed class CollectionEqualityProvider : IEqualityComparerProvider
{
    public static IEqualityComparer<T> GetEqualityComparer<T>()
    {
        var type = typeof(T);
        if (!type.IsGenericType)
        {
            throw new InvalidOperationException("Unsupported type");
        }
        var genericTypeDefinition = type.GetGenericTypeDefinition();
        if (genericTypeDefinition == typeof(ImmutableList<>))
        {
            // 实例化并返回适当的相等性比较器
        }
        if (genericTypeDefinition == typeof(ImmutableDictionary<,>))
        {
            // 实例化并返回适当的相等性比较器
        }
        // 等等...
        throw new InvalidOperationException("Unsupported type");
    }
}

不幸的是,注释会涉及进一步的反射——但这肯定是可行的。

然后我们可以这样声明一个记录:

1
2
3
public sealed record Ballot(
    Constituency Constituency,
    [IEqualityComparerProvider(typeof(CollectionEqualityProvider))] ImmutableList<Candidacy> Candidacies);

……我期望编译器生成如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public sealed class Ballot
{
    private static readonly IEqualityComparer<ImmutableList<Candidacy>> candidaciesComparer;
    
    // 跳过今天会生成的代码
    
    public bool Equals(Candidate? other)
    {
        if (ReferenceEquals(this, other))
        {
            return true;
        }
        if (other is null)
        {
            return false;
        }
        return EqualityComparer<Constituency>.Default.Equals(Constituency, other.Constituency) &&
            candidaciesComparer.Equals(Candidacies, other.Candidacies);
    }
    
    public override int GetHashCode()
    {
        int constituencyHash = EqualityComparer<Constituency>.Default.GetHashCode(Constituency);
        int hash = constituencyHash * -1521134295;
        int candidaciesHash = candidaciesComparer.GetHashCode(Candidacies);
        hash = (hash + candidaciesHash) * -1521134295;
        return hash;
    }
}

我确信还有其他方法可以做到这一点。属性可以改为指定用于获取相等性比较器的私有静态只读属性的名称,从而移除接口。或者GetEqualityComparer方法可以是非泛型的,带有Type参数(让编译器在调用后生成强制转换)。我几乎没有考虑过——但重要的是,对单个属性进行自定义相等性比较的要求独立于所有其他属性。如果您已经有一个具有9个属性的记录,其中默认相等性比较很好,那么添加需要更多自定义的第10个属性很容易——而今天,您需要实现包括所有10个属性的EqualsGetHashCode

(对于属性的字符串格式也可以这样说,但这还不是困扰我的领域。)

我遇到的下一个摩擦点也是关于相等性,但是另一个方向。

引用相等性

如果您记得我关于数据模型的文章,在单个ElectionContext中,模型的引用相等性就是我们所需要的。站点永远不需要通过指定来自不同上下文的Constituency来从一个上下文获取(例如)2024年选举的选区结果。实际上,如果我曾经发现代码试图这样做,它可能表明一个错误:任何给定Web请求中的所有内容都应引用相同的ElectionContext

鉴于此,当我创建ImmutableDictionary<Constituency, Result>时,我想提供一个仅执行引用比较的IEqualityComparer<Constituency>。虽然这似乎很简单,但我发现当重新加载上下文时,它对构建视图模型所花费的时间产生了相当显著的影响。

我期望在框架中找到一个引用相等性比较器很容易——但如果有一个,我错过了。

更新,2025-03-27T21:04Z,感谢Michael Damatov

正如Michael在评论中指出的,框架中有一个:System.Collections.Generic.ReferenceEqualityComparer——我记得当我第一次发现我需要一个时找到了它。但我愚蠢地忽略了它。您看,它是非泛型的:

1
2
3
public sealed class ReferenceEqualityComparer :
    System.Collections.Generic.IEqualityComparer<object>,
    System.Collections.IEqualityComparer

我当时想,这很奇怪且不太有用。为什么我只想要IEqualityComparer<object>而不是泛型的?

哦,Jon。愚蠢,愚蠢的Jon。

IEqualityComparer<T>T中是逆变的——因此对于任何类类型X,从IEqualityComparer<object>IEqualityComparer<X>存在隐式引用转换。

我现在已经移除了我自己的泛型ReferenceEqualityComparer<T>类型……尽管这意味着我必须在以前通过比较器类型推断类型的地方进行强制转换或显式指定一些类型参数。

更新结束

我现在已经习惯在数据模型中的任何地方使用引用相等性比较,这使得值得添加一些扩展方法——而这些方法可能不太有意义添加到框架中(尽管它们可以轻松地由NuGet包提供):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public static ImmutableDictionary<TKey, TValue> ToImmutableReferenceDictionary<TSource, TKey, TValue>(
    this IEnumerable<TSource> source,
    Func<TSource, TKey> keySelector,
    Func<TSource, TValue> elementSelector) where TKey : class =>
    source.ToImmutableDictionary(keySelector, elementSelector, ReferenceEqualityComparer<TKey>.Instance);

public static ImmutableDictionary<TKey, TSource> ToImmutableReferenceDictionary<TSource, TKey>(
    this IEnumerable<TSource> source,
    Func<TSource, TKey> keySelector) where TKey : class =>
    source.ToImmutableDictionary(keySelector, ReferenceEqualityComparer<TKey>.Instance);

public static ImmutableDictionary<TKey, TValue> ToImmutableReferenceDictionary<TKey, TValue>(
    this IDictionary<TKey, TValue> source) where TKey : class =>
    source.ToImmutableDictionary(ReferenceEqualityComparer<TKey>.Instance);

public static ImmutableDictionary<TKey, TValue> ToImmutableReferenceDictionary<TKey, TValue, TSourceValue>(
    this IDictionary<TKey, TSourceValue> source, Func<KeyValuePair<TKey, TSourceValue>, TValue> elementSelector) where TKey : class =>
    source.ToImmutableDictionary(pair => pair.Key, elementSelector, ReferenceEqualityComparer<TKey>.Instance);

(我当然可以轻松地为构建查找添加类似方法。)请随意对名称提出异议——虽然它们只在选举仓库中,我不会太担心它们。

插曲

为什么不使引用相等性成为默认值?

我可能在这里一石二鸟。如果我经常想要引用相等性,而"深度"相等性相对难以实现,为什么不直接提供使所有我的记录表现为引用相等性比较的EqualsGetHashCode方法?

这当然是一个选项——但我确实依赖深度相等性比较进行测试目的:例如,如果我两次加载相同的上下文,结果应该是相等的,否则就有问题。

此外,由于记录类型鼓励深度相等性,感觉通过指定引用相等性比较,我会颠覆它们的自然行为。虽然我不期望其他任何人会看到这段代码,但我不喜欢编写会混淆基于大多数代码工作方式期望的读者的代码。

插曲结束

说到常用比较器的扩展方法……

序数字符串比较

字符串比较让我紧张。我绝对不是国际化专家,但我知道足够多知道它很复杂。

我也知道足够多,相当确信默认字符串比较对于EqualsGetHashCode是序数的,但对于CompareTo是文化敏感的。如我所说,我相当确信这一点——但我总是发现很难验证,所以鉴于我几乎总是想使用序数比较,我喜欢明确。以前我指定了StringComparer.Ordinal(或偶尔StringComparer.OrdinalIgnoreCase)但是——就像上面引用相等性比较器一样——如果您经常使用它,这会变得烦人。

因此,我创建了另一堆扩展方法,只是为了明确表示我想使用序数字符串比较——即使(在相等性的情况下)这已经是默认值。

我不会用完整的方法让您厌烦,但我有:

  • OrderByOrdinal
  • OrderByOrdinalDescending
  • ThenByOrdinal
  • ThenByOrdinalDescending
  • ToImmutableOrdinalDictionary(4个重载,像上面的ToImmutableReferenceDictionary一样)
  • ToOrdinalDictionary(又是4个重载)
  • ToOrdinalLookup(2个重载)

(我实际上不常用ToOrdinalLookup,但实现所有这些感觉是明智的。)

这些在框架中会有用吗?可能。我明白为什么它们不在那里——字符串真的"只是另一种类型"……但我敢打赌,LINQ使用中有很高比例最终以某种形式将字符串作为键。可能我应该为MoreLINQ建议这个——尽管在15年前启动了这个项目,我已经十多年没有为它贡献了……

Visual Studio中主构造函数和记录"调用层次结构"的小问题

我一直在Visual Studio中使用"调用层次结构"。将光标放在成员上,然后按Ctrl-K,Ctrl-T,您可以看到调用该成员的所有内容,以及调用调用者的内容,等等。

对于主构造函数和记录参数,“查找引用"有效(Ctrl-K,Ctrl-R)但"调用层次结构"无效。我对"调用层次结构"对主构造函数参数无效没问题,但由于记录参数成为属性,我期望看到它们的调用层次结构,就像我可以看到任何其他属性一样。

但更令人沮丧的是无法看到"调用构造函数"的调用层次结构。鉴于类/记录的声明在某种程度上也充当构造函数的声明,我原以为将光标放在类/记录声明上(在名称中)会有效。这不是模糊不清——Visual Studio只是抱怨"光标必须在成员名称上”。您可以通过在解决方案资源管理器中展开源文件条目来访问调用,但仅在这种情况下必须这样做很奇怪。

功能请求(针对C#语言、.NET和Visual Studio)

总之,我喜欢记录类型,喜欢不可变集合——但通过引入以下内容可以减少一些摩擦:

  • 某种方式控制(基于每个属性)在生成代码中使用哪个相等性比较器
  • 不可变集合的相等性比较器,能够指定要使用的元素比较
  • 执行引用比较的IEqualityComparer<T>实现
  • “调用层次结构"显示对主构造函数和记录构造函数的调用

结论

我在记录类型和集合中发现的一些小问题至少在一定程度上特定于我的选举站点,尽管我强烈怀疑我不是唯一在其记录中拥有不可变集合并希望在相等性比较中使用它们的开发人员。

总的来说,记录类型在站点中到目前为止对我很有用,我肯定很高兴它们可用,即使仍有改进的可能。同样,很高兴自然拥有不可变集合——但在对它们执行比较方面的一些帮助将受到欢迎。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计