Skip to content

Commit 1a268e5

Browse files
CopilotBillWagneradegeo
authored
Improve C# value equality documentation with enhanced content and structured code examples (#48073)
* Initial plan * Add comprehensive documentation improvements for polymorphic equality and records with collections Co-authored-by: BillWagner <[email protected]> * Add AI-usage metadata and finalize documentation improvements Co-authored-by: BillWagner <[email protected]> * Address PR feedback: fix AI metadata, improve records section, and clarify polymorphic equality Co-authored-by: BillWagner <[email protected]> * Apply suggestions from code review * Apply suggestions from code review * Address PR feedback: fix documentation content and break up long code samples with snippet tags Co-authored-by: BillWagner <[email protected]> * Add introductory sentences before consecutive code blocks to improve readability Co-authored-by: adegeo <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: BillWagner <[email protected]> Co-authored-by: Bill Wagner <[email protected]> Co-authored-by: adegeo <[email protected]>
1 parent 0c604a1 commit 1a268e5

File tree

5 files changed

+526
-5
lines changed

5 files changed

+526
-5
lines changed

docs/csharp/programming-guide/statements-expressions-operators/how-to-define-value-equality-for-a-type.md

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ title: "How to define value equality for a class or struct"
33
description: Learn how to define value equality for a class or struct. See code examples and view available resources.
44
ms.topic: how-to
55
ms.date: 03/26/2021
6+
ai-usage: ai-assisted
67
helpviewer_keywords:
78
- "overriding Equals method [C#]"
89
- "object equivalence [C#]"
@@ -56,14 +57,72 @@ The following example shows how records automatically implement value equality w
5657

5758
Records provide several advantages for value equality:
5859

59-
- **Automatic implementation**: Records automatically implement `IEquatable<T>` and override `Equals(object?)`, `GetHashCode()`, and the `==`/`!=` operators.
60-
- **Correct inheritance behavior**: Unlike the class example shown earlier, records handle inheritance scenarios correctly.
60+
- **Automatic implementation**: Records automatically implement <xref:System.IEquatable%601?displayProperty=nameWithType> and override <xref:System.Object.Equals%2A?displayProperty=nameWithType>, <xref:System.Object.GetHashCode%2A?displayProperty=nameWithType>, and the `==`/`!=` operators.
61+
- **Correct inheritance behavior**: Records implement `IEquatable<T>` using virtual methods that check the runtime type of both operands, ensuring correct behavior in inheritance hierarchies and polymorphic scenarios.
6162
- **Immutability by default**: Records encourage immutable design, which works well with value equality semantics.
6263
- **Concise syntax**: Positional parameters provide a compact way to define data types.
6364
- **Better performance**: The compiler-generated equality implementation is optimized and doesn't use reflection like the default struct implementation.
6465

6566
Use records when your primary goal is to store data and you need value equality semantics.
6667

68+
## Records with members that use reference equality
69+
70+
When records contain members that use reference equality, the automatic value equality behavior of records doesn't work as expected. This applies to collections like <xref:System.Collections.Generic.List%601?displayProperty=nameWithType>, arrays, and other reference types that don't implement value-based equality (with the notable exception of <xref:System.String?displayProperty=nameWithType>, which does implement value equality).
71+
72+
> [!IMPORTANT]
73+
> While records provide excellent value equality for basic data types, they don't automatically solve value equality for members that use reference equality. If a record contains a <xref:System.Collections.Generic.List%601?displayProperty=nameWithType>, <xref:System.Array?displayProperty=nameWithType>, or other reference types that don't implement value equality, two record instances with identical content in those members will still not be equal because the members use reference equality.
74+
>
75+
> ```csharp
76+
> public record PersonWithHobbies(string Name, List<string> Hobbies);
77+
>
78+
> var person1 = new PersonWithHobbies("Alice", new List<string> { "Reading", "Swimming" });
79+
> var person2 = new PersonWithHobbies("Alice", new List<string> { "Reading", "Swimming" });
80+
>
81+
> Console.WriteLine(person1.Equals(person2)); // False - different List instances!
82+
> ```
83+
84+
This is because records use the <xref:System.Object.Equals%2A?displayProperty=nameWithType> method of each member, and collection types typically use reference equality rather than comparing their contents.
85+
86+
The following shows the problem:
87+
88+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/RecordCollectionsIssue/Program.cs" id="ProblemExample":::
89+
90+
Here's how this behaves when you run the code:
91+
92+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/RecordCollectionsIssue/Program.cs" id="ProblemDemonstration":::
93+
94+
### Solutions for records with reference-equality members
95+
96+
- **Custom <xref:System.IEquatable%601?displayProperty=nameWithType> implementation**: Replace the compiler-generated equality with a hand-coded version that provides content-based comparison for reference-equality members. For collections, implement element-by-element comparison using <xref:System.Linq.Enumerable.SequenceEqual%2A?displayProperty=nameWithType> or similar methods.
97+
98+
- **Use value types where possible**: Consider if your data can be represented with value types or immutable structures that naturally support value equality, such as <xref:System.Numerics.Vector%601?displayProperty=nameWithType> or <xref:System.Numerics.Plane>.
99+
100+
- **Use types with value-based equality**: For collections, consider using types that implement value-based equality or implement custom collection types that override <xref:System.Object.Equals%2A?displayProperty=nameWithType> to provide content-based comparison, such as <xref:System.Collections.Immutable.ImmutableArray%601?displayProperty=nameWithType> or <xref:System.Collections.Immutable.ImmutableList%601?displayProperty=nameWithType>.
101+
102+
- **Design with reference equality in mind**: Accept that some members will use reference equality and design your application logic accordingly, ensuring that you reuse the same instances when equality is important.
103+
104+
Here's an example of implementing custom equality for records with collections:
105+
106+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/RecordCollectionsIssue/Program.cs" id="SolutionExample":::
107+
108+
This custom implementation works correctly:
109+
110+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/RecordCollectionsIssue/Program.cs" id="SolutionDemonstration":::
111+
112+
The same issue affects arrays and other collection types:
113+
114+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/RecordCollectionsIssue/Program.cs" id="OtherTypes":::
115+
116+
Arrays also use reference equality, producing the same unexpected results:
117+
118+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/RecordCollectionsIssue/Program.cs" id="ArrayExample":::
119+
120+
Even readonly collections exhibit this reference equality behavior:
121+
122+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/RecordCollectionsIssue/Program.cs" id="ImmutableExample":::
123+
124+
The key insight is that records solve the *structural* equality problem but don't change the *semantic* equality behavior of the types they contain.
125+
67126
## Class example
68127
69128
The following example shows how to implement value equality in a class (reference type). This manual approach is needed when you can't use records or need custom equality logic:
@@ -83,9 +142,60 @@ The `==` and `!=` operators can be used with classes even if the class does not
83142
> Console.WriteLine(p1.Equals(p2)); // output: True
84143
> ```
85144
>
86-
> This code reports that `p1` equals `p2` despite the difference in `z` values. The difference is ignored because the compiler picks the `TwoDPoint` implementation of `IEquatable` based on the compile-time type.
87-
>
88-
> The built-in value equality of `record` types handles scenarios like this correctly. If `TwoDPoint` and `ThreeDPoint` were `record` types, the result of `p1.Equals(p2)` would be `False`. For more information, see [Equality in `record` type inheritance hierarchies](../../language-reference/builtin-types/record.md#equality-in-inheritance-hierarchies).
145+
> This code reports that `p1` equals `p2` despite the difference in `z` values. The difference is ignored because the compiler picks the `TwoDPoint` implementation of `IEquatable` based on the compile-time type. This is a fundamental issue with polymorphic equality in inheritance hierarchies.
146+
147+
## Polymorphic equality
148+
149+
When implementing value equality in inheritance hierarchies with classes, the standard approach shown in the class example can lead to incorrect behavior when objects are used polymorphically. The issue occurs because <xref:System.IEquatable%601?displayProperty=nameWithType> implementations are chosen based on compile-time type, not runtime type.
150+
151+
### The problem with standard implementations
152+
153+
Consider this problematic scenario:
154+
155+
```csharp
156+
TwoDPoint p1 = new ThreeDPoint(1, 2, 3); // Declared as TwoDPoint
157+
TwoDPoint p2 = new ThreeDPoint(1, 2, 4); // Declared as TwoDPoint
158+
Console.WriteLine(p1.Equals(p2)); // True - but should be False!
159+
```
160+
161+
The comparison returns `True` because the compiler selects `TwoDPoint.Equals(TwoDPoint)` based on the declared type, ignoring the `Z` coordinate differences.
162+
163+
The key to correct polymorphic equality is ensuring that all equality comparisons use the virtual <xref:System.Object.Equals%2A?displayProperty=nameWithType> method, which can check runtime types and handle inheritance correctly. This can be achieved by using explicit interface implementation for <xref:System.IEquatable%601?displayProperty=nameWithType> that delegates to the virtual method:
164+
165+
The base class demonstrates the key patterns:
166+
167+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/ValueEqualityPolymorphic/Program.cs" id="TwoDPointClass":::
168+
169+
The derived class correctly extends the equality logic:
170+
171+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/ValueEqualityPolymorphic/Program.cs" id="ThreeDPointClass":::
172+
173+
Here's how this implementation handles the problematic polymorphic scenarios:
174+
175+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/ValueEqualityPolymorphic/Program.cs" id="PolymorphicTest":::
176+
177+
The implementation also correctly handles direct type comparisons:
178+
179+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/ValueEqualityPolymorphic/Program.cs" id="DirectTest":::
180+
181+
The equality implementation also works properly with collections:
182+
183+
:::code language="csharp" source="snippets/how-to-define-value-equality-for-a-type/ValueEqualityPolymorphic/Program.cs" id="CollectionTest":::
184+
185+
The preceding code demonstrates key elements to implementing value based equality:
186+
187+
- **Virtual `Equals(object?)` override**: The main equality logic happens in the virtual <xref:System.Object.Equals%2A?displayProperty=nameWithType> method, which is called regardless of compile-time type.
188+
- **Runtime type checking**: Using `this.GetType() != p.GetType()` ensures that objects of different types are never considered equal.
189+
- **Explicit interface implementation**: The <xref:System.IEquatable%601?displayProperty=nameWithType> implementation delegates to the virtual method, preventing compile-time type selection issues.
190+
- **Protected virtual helper method**: The `protected virtual Equals(TwoDPoint? p)` method allows derived classes to override equality logic while maintaining type safety.
191+
192+
Use this pattern when:
193+
194+
- You have inheritance hierarchies where value equality is important
195+
- Objects might be used polymorphically (declared as base type, instantiated as derived type)
196+
- You need reference types with value equality semantics
197+
198+
The preferred approach is to use `record` types to implement value based equality. This approach requires a more complex implementation than the standard approach and requires thorough testing of polymorphic scenarios to ensure correctness.
89199

90200
## Struct example
91201

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
namespace RecordCollectionsIssue;
2+
3+
// <ProblemExample>
4+
// Records with reference-equality members don't work as expected
5+
public record PersonWithHobbies(string Name, List<string> Hobbies);
6+
// </ProblemExample>
7+
8+
// <SolutionExample>
9+
// A potential solution using IEquatable<T> with custom equality
10+
public record PersonWithHobbiesFixed(string Name, List<string> Hobbies) : IEquatable<PersonWithHobbiesFixed>
11+
{
12+
public virtual bool Equals(PersonWithHobbiesFixed? other)
13+
{
14+
if (ReferenceEquals(null, other)) return false;
15+
if (ReferenceEquals(this, other)) return true;
16+
17+
// Use SequenceEqual for List comparison
18+
return Name == other.Name && Hobbies.SequenceEqual(other.Hobbies);
19+
}
20+
21+
public override int GetHashCode()
22+
{
23+
// Create hash based on content, not reference
24+
var hashCode = new HashCode();
25+
hashCode.Add(Name);
26+
foreach (var hobby in Hobbies)
27+
{
28+
hashCode.Add(hobby);
29+
}
30+
return hashCode.ToHashCode();
31+
}
32+
}
33+
// </SolutionExample>
34+
35+
// <OtherTypes>
36+
// These also use reference equality - the issue persists
37+
public record PersonWithHobbiesArray(string Name, string[] Hobbies);
38+
39+
public record PersonWithHobbiesImmutable(string Name, IReadOnlyList<string> Hobbies);
40+
// </OtherTypes>
41+
42+
// <MainProgram>
43+
class Program
44+
{
45+
static void Main(string[] args)
46+
{
47+
// <ProblemDemonstration>
48+
Console.WriteLine("=== Records with Collections - The Problem ===");
49+
50+
// Problem: Records with mutable collections use reference equality for the collection
51+
var person1 = new PersonWithHobbies("Alice", [ "Reading", "Swimming" ]);
52+
var person2 = new PersonWithHobbies("Alice", [ "Reading", "Swimming" ]);
53+
54+
Console.WriteLine($"person1: {person1}");
55+
Console.WriteLine($"person2: {person2}");
56+
Console.WriteLine($"person1.Equals(person2): {person1.Equals(person2)}"); // False! Different List instances
57+
Console.WriteLine($"Lists have same content: {person1.Hobbies.SequenceEqual(person2.Hobbies)}"); // True
58+
Console.WriteLine();
59+
// </ProblemDemonstration>
60+
61+
// <SolutionDemonstration>
62+
Console.WriteLine("=== Solution 1: Custom IEquatable Implementation ===");
63+
64+
var personFixed1 = new PersonWithHobbiesFixed("Bob", [ "Cooking", "Hiking" ]);
65+
var personFixed2 = new PersonWithHobbiesFixed("Bob", [ "Cooking", "Hiking" ]);
66+
67+
Console.WriteLine($"personFixed1: {personFixed1}");
68+
Console.WriteLine($"personFixed2: {personFixed2}");
69+
Console.WriteLine($"personFixed1.Equals(personFixed2): {personFixed1.Equals(personFixed2)}"); // True! Custom equality
70+
Console.WriteLine();
71+
// </SolutionDemonstration>
72+
73+
// <ArrayExample>
74+
Console.WriteLine("=== Arrays Also Use Reference Equality ===");
75+
76+
var personArray1 = new PersonWithHobbiesArray("Charlie", ["Gaming", "Music" ]);
77+
var personArray2 = new PersonWithHobbiesArray("Charlie", ["Gaming", "Music" ]);
78+
79+
Console.WriteLine($"personArray1: {personArray1}");
80+
Console.WriteLine($"personArray2: {personArray2}");
81+
Console.WriteLine($"personArray1.Equals(personArray2): {personArray1.Equals(personArray2)}"); // False! Arrays use reference equality too
82+
Console.WriteLine($"Arrays have same content: {personArray1.Hobbies.SequenceEqual(personArray2.Hobbies)}"); // True
83+
Console.WriteLine();
84+
// </ArrayExample>
85+
86+
// <ImmutableExample>
87+
Console.WriteLine("=== Same Issue with IReadOnlyList ===");
88+
89+
var personImmutable1 = new PersonWithHobbiesImmutable("Diana", [ "Art", "Travel" ]);
90+
var personImmutable2 = new PersonWithHobbiesImmutable("Diana", [ "Art", "Travel" ]);
91+
92+
Console.WriteLine($"personImmutable1: {personImmutable1}");
93+
Console.WriteLine($"personImmutable2: {personImmutable2}");
94+
Console.WriteLine($"personImmutable1.Equals(personImmutable2): {personImmutable1.Equals(personImmutable2)}"); // False! Reference equality
95+
Console.WriteLine($"Content is the same: {personImmutable1.Hobbies.SequenceEqual(personImmutable2.Hobbies)}"); // True
96+
Console.WriteLine();
97+
// </ImmutableExample>
98+
99+
Console.WriteLine("=== Collection Behavior Summary ===");
100+
Console.WriteLine("Type | Equals Result | Reason");
101+
Console.WriteLine("----------------------------------|---------------|------------------");
102+
Console.WriteLine($"Record with List<T> | {person1.Equals(person2),-13} | Reference equality");
103+
Console.WriteLine($"Record with custom IEquatable<T> | {personFixed1.Equals(personFixed2),-13} | Custom equality logic");
104+
Console.WriteLine($"Record with Array | {personArray1.Equals(personArray2),-13} | Reference equality");
105+
Console.WriteLine($"Record with IReadOnlyList<T> | {personImmutable1.Equals(personImmutable2),-13} | Reference equality");
106+
107+
Console.WriteLine("\nPress any key to exit.");
108+
Console.ReadKey();
109+
}
110+
}
111+
// </MainProgram>
112+
113+
/* Expected Output:
114+
=== Records with Collections - The Problem ===
115+
person1: PersonWithHobbies { Name = Alice, Hobbies = System.Collections.Generic.List`1[System.String] }
116+
person2: PersonWithHobbies { Name = Alice, Hobbies = System.Collections.Generic.List`1[System.String] }
117+
person1.Equals(person2): False
118+
Lists have same content: True
119+
120+
=== Solution 1: Custom IEquatable Implementation ===
121+
personFixed1: PersonWithHobbiesFixed { Name = Bob, Hobbies = System.Collections.Generic.List`1[System.String] }
122+
personFixed2: PersonWithHobbiesFixed { Name = Bob, Hobbies = System.Collections.Generic.List`1[System.String] }
123+
personFixed1.Equals(personFixed2): True
124+
125+
=== Arrays Also Use Reference Equality ===
126+
personArray1: PersonWithHobbiesArray { Name = Charlie, Hobbies = System.String[] }
127+
personArray2: PersonWithHobbiesArray { Name = Charlie, Hobbies = System.String[] }
128+
personArray1.Equals(personArray2): False
129+
Arrays have same content: True
130+
131+
=== Same Issue with IReadOnlyList ===
132+
personImmutable1: PersonWithHobbiesImmutable { Name = Diana, Hobbies = System.String[] }
133+
personImmutable2: PersonWithHobbiesImmutable { Name = Diana, Hobbies = System.String[] }
134+
personImmutable1.Equals(personImmutable2): False
135+
Content is the same: True
136+
137+
=== Collection Behavior Summary ===
138+
Type | Equals Result | Reason
139+
----------------------------------|---------------|------------------
140+
Record with List<T> | False | Reference equality
141+
Record with custom IEquatable<T> | True | Custom equality logic
142+
Record with Array | False | Reference equality
143+
Record with IReadOnlyList<T> | False | Reference equality
144+
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
</PropertyGroup>
9+
10+
</Project>

0 commit comments

Comments
 (0)