记录与集合
这篇文章在某种程度上是我在选举网站中使用记录和集合时遇到的各种摩擦点的汇总。
记录类型回顾
这可能会成为本系列中最具普遍实用性的博客文章。尽管记录类型自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>本身没有重写Equals和GetHashCode——因此它具有引用相等性语义。我真正想要的是使用元素类型的相等比较器,并规定如果两个不可变列表具有相同的计数,并且元素成对考虑时相等,那么它们就是相等的。这很容易实现——连同合适的GetHashCode方法。它可以很容易地包装在一个实现IEqualityComparer<ImmutableList<T>>的类型中,尽管碰巧我还没有这样做。
不幸的是,C#中记录的工作方式是,没有办法为特定属性指定要使用的相等比较器。如果你直接实现Equals和GetHashCode方法,那么这些方法将代替生成的版本使用(并且Equals(object)生成的代码仍将使用你实现的版本),但这意味着你必须为所有属性实现它。这反过来意味着,如果你在记录中添加一个新属性,你需要记得修改Equals和GetHashCode(我至少忘记过一次)——而如果你乐于使用默认的生成实现,添加一个属性是微不足道的。
我真正想要的是某种方式向编译器指示,它应该使用指定类型来获取属性的相等比较器(可以假定是无状态的)。例如,假设我们有这些类型:
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个属性的Equals和GetHashCode。
(对于属性的字符串格式化也可以这样说,但这还没有困扰到我。)
我遇到的另一个摩擦点也是关于相等性的,但方向不同。
引用相等性
如果你记得我关于数据模型的文章,在单个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);
|
(当然,我可以轻松添加类似的方法来构建查找。)请随意对名称提出异议——虽然它们只在选举仓库中,我不会太担心它们。
插曲
为什么不将引用相等性设为默认?
我或许可以在这里一石二鸟。如果我经常想要引用相等性,而“深度”相等性相对难以实现,为什么不直接提供Equals和GetHashCode方法,让我所有的记录都使用引用相等性比较呢?
这当然是一个选择——但为了测试目的,我确实依赖于深度相等性比较:例如,如果我加载相同的上下文两次,结果应该是相等的,否则就有问题。
此外,由于记录类型鼓励深度相等性,我觉得通过指定引用相等性比较,我是在颠覆它们的自然行为。虽然我不期望其他人看到这段代码,但我不喜欢编写会混淆那些基于大多数代码工作方式抱有期望的读者的代码。
插曲结束
说到常用比较器的扩展方法……
序数字符串比较
字符串比较让我紧张。我绝对不是国际化专家,但我知道这很复杂。
我也知道足以相当确信,默认的字符串比较对于Equals和GetHashCode是序数的,但对于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>实现
- “调用层次结构”显示对主构造函数和记录的构造函数的调用
结论
我在记录和集合中发现的一些小问题至少在一定程度上特定于我的选举站点,尽管我强烈怀疑我不是唯一在记录中拥有不可变集合并希望在相等性比较中使用它们的开发人员。
总的来说,记录到目前为止在站点中为我服务得很好,我肯定很高兴它们可用,即使仍有改进的可能。同样,很自然地拥有不可变集合是很好的——但在使用它们进行比较时,一些帮助将受到欢迎。