C#中记录类型与集合的痛点与改进思路

本文探讨了在C#中使用记录类型和集合时遇到的实际问题,包括记录类型的默认相等性比较、不可变集合的引用相等性以及工具支持不足等,并提出了具体的改进建议和解决方案。

记录类型与集合

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

记录类型回顾

这可能会是本系列博客中最具普适性的一篇。尽管记录类型自 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)

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

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

结论

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

总的来说,记录类型到目前为止在网站中表现良好,我很高兴它们可用,即使仍有改进的空间。同样,很高兴能自然获得不可变集合——但在对它们进行比较时获得一些帮助将受到欢迎。

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