CodeGym /Kurse /C# SELF /Objektvergleich in C#: Equ...

Objektvergleich in C#: Equals und GetHashCode()

C# SELF
Level 34 , Lektion 3
Verfügbar

1. Einführung

Beim Programmieren vergleichen wir ständig irgendwas: Zahlen, Strings, Objekte. Aber was bedeutet eigentlich "Gleichheit" von Objekten in C#? Das ist nicht immer so offensichtlich, wie es scheint. Heute schauen wir uns zwei zentrale Methoden an, die bestimmen, wie Objekte verglichen werden: Equals() und GetHashCode(). Diese Methoden zu verstehen ist superwichtig, damit deine Programme korrekt laufen – vor allem, wenn du Collections wie Dictionary oder HashSet benutzt.

In C# gibt es zwei Hauptarten von Gleichheit:

Referenzgleichheit (Reference Equality):

Bedeutet, dass zwei Variablen eines Referenztyps auf dasselbe Objekt im Speicher zeigen. Das prüfst du mit dem Operator == für Referenztypen.

MyClass obj1 = new MyClass();
MyClass obj2 = new MyClass();
MyClass obj3 = obj1;

Console.WriteLine(obj1 == obj2); // false (verschiedene Objekte im Speicher)
Console.WriteLine(obj1 == obj3); // true (beide Referenzen zeigen auf dasselbe Objekt)

Wertgleichheit (Value Equality):

Bedeutet, dass zwei verschiedene Objekte (oder zwei Value-Types) den gleichen Inhalt (Werte ihrer Felder/Properties) haben. Das ist meistens das, was man beim Objektvergleich will. Dafür benutzt man die Methode Equals().

// Angenommen, wir haben eine Klasse Point
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
Point p3 = new Point(30, 40);

// p1 und p2 sind verschiedene Objekte, aber wir wollen, dass sie "gleich" sind vom Wert her
Console.WriteLine(p1.Equals(p2)); // ? Hängt von der Equals()-Implementierung ab
Console.WriteLine(p1.Equals(p3)); // ?

2. Die Methode Equals()

Die Methode Equals() ist im Basisklasse System.Object definiert, von der alle Typen in C# erben. Ihr Hauptzweck ist es zu bestimmen, ob zwei Objekte vom Wert her gleich sind.

Standardverhalten von Equals()

Für Value-Types (struct, int, bool usw.): Die Standardimplementierung von Equals() (geerbt von System.ValueType) macht ein Bit-für-Bit-Vergleich aller Felder. Wenn alle Felder gleich sind, gelten die Objekte als gleich. Das funktioniert meistens wie erwartet.

Für Referenztypen (class, string, array usw.): Die Standardimplementierung von Equals() (geerbt von System.Object) prüft die Referenzgleichheit. Das heißt, obj1.Equals(obj2) gibt standardmäßig nur dann true zurück, wenn obj1 und obj2 auf dasselbe Objekt im Speicher zeigen.
class Person // Referenztyp
{
    public string Name { get; set; }
    public int Alter { get; set; }
}

Person person1 = new Person { Name = "Alice", Alter = 30 };
Person person2 = new Person { Name = "Alice", Alter = 30 }; // Anderes Objekt, aber gleicher Inhalt

Console.WriteLine(person1.Equals(person2)); // false (standardmäßig werden Referenzen verglichen)

Wie du siehst, ist das Standardverhalten für Referenztypen oft nicht das, was wir wollen! Wir wollen, dass zwei "Alices" mit 30 Jahren als gleich gelten, auch wenn es verschiedene Objekte im Speicher sind.

Equals() für eigene Klassen überschreiben

Damit deine eigenen Klassen Wertgleichheit unterstützen, musst du die Methode Equals() überschreiben.

Regeln für das Überschreiben von Equals() (Contract):

  • Reflexivität: x.Equals(x) ist immer true.
  • Symmetrie: Wenn x.Equals(y) true ist, dann muss auch y.Equals(x) true sein.
  • Transitivität: Wenn x.Equals(y) und y.Equals(z) beide true sind, dann muss auch x.Equals(z) true sein.
  • Konsistenz: Mehrfache Aufrufe von x.Equals(y) müssen das gleiche Ergebnis liefern, solange sich die Objekte nicht ändern.
  • Null-Kompatibilität: x.Equals(null) ist immer false.

Beispiel: Equals() für die Klasse Person überschreiben

class Person
{
    public string Name { get; set; }
    public int Alter { get; set; }

    public Person(string name, int alter)
    {
        Name = name;
        Alter = alter;
    }

    // Equals() überschreiben
    public override bool Equals(object? obj)
    {
        // 1. Null-Check
        if (obj == null) return false;

        // 2. Typ-Check
        if (obj.GetType() != this.GetType()) return false; 

        // 3. Typ-Cast
        Person other = (Person)obj;

        // 4. Felder vergleichen
        return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && 
               Alter == other.Alter;
    }
}

// Benutzung:
Person person1 = new Person("Alice", 30);
Person person2 = new Person("Alice", 30);
Person person3 = new Person("Bob", 25);

Console.WriteLine(person1.Equals(person2)); // true (jetzt Wertvergleich!)
Console.WriteLine(person1.Equals(person3)); // false
Console.WriteLine(person1.Equals(null));    // false

3. Die Methode GetHashCode()

Die Methode GetHashCode() ist auch in System.Object definiert. Sie gibt einen Integer-Wert (Hashcode) zurück, der ein Objekt möglichst schnell und eindeutig identifiziert.

Wofür braucht man GetHashCode()?

Hashcodes werden zur Optimierung von Collections, die auf Hash-Tabellen basieren, verwendet. Dazu gehören:

  • Dictionary<TKey, TValue> (Hashcode wird für schnelles Key-Lookup benutzt)
  • HashSet<T> (Hashcode prüft die Einzigartigkeit von Elementen)
  • Hashtable

Wenn du ein Objekt zu einem HashSet hinzufügst oder es als Key in einem Dictionary benutzt, berechnet die Collection zuerst den Hashcode des Objekts. Damit kann sie direkt in den richtigen "Bucket" springen, statt alle Elemente durchzugehen. Innerhalb dieses Buckets wird dann Equals() für den genauen Vergleich benutzt.

Regeln für das Überschreiben von GetHashCode() (Contract):

Wenn du Equals() überschreibst, MUSST du auch GetHashCode() überschreiben! Das ist eine der wichtigsten Regeln in C#.

  • Konsistenz: Wenn Equals() für zwei Objekte true zurückgibt, dann muss GetHashCode() für diese Objekte den gleichen Wert liefern. (Das Umgekehrte gilt nicht: Verschiedene Objekte dürfen denselben Hashcode haben – das nennt man "Kollision".)
  • Stabilität: GetHashCode() muss für ein Objekt immer den gleichen Wert liefern, solange die Felder, die für den Vergleich benutzt werden, sich nicht ändern.
  • Schnelligkeit: GetHashCode() sollte schnell sein und keine aufwändigen Berechnungen machen.

Warum ist das so wichtig? Wenn du Equals() überschreibst, aber nicht GetHashCode(), funktionieren deine Collections (vor allem Hash-Collections) nicht richtig:

  • Dictionary findet deinen Key nicht.
  • HashSet fügt Duplikate hinzu, weil es denkt, sie sind einzigartig.

Das passiert, weil GetHashCode() standardmäßig einen Hash auf Basis der Objektreferenz (bei Referenztypen) zurückgibt. Wenn Equals() jetzt aber Wertvergleich macht, haben Objekte mit gleichem Wert, aber unterschiedlichen Referenzen, verschiedene Hashcodes – und die Collection erkennt sie nicht als gleich.

GetHashCode() für die Klasse Person überschreiben

Gute Praxis: Den Hashcode auf Basis der gleichen Felder generieren, die auch in Equals() benutzt werden. .NET bietet dafür die statische Methode HashCode.Combine() an – super praktisch!

class Person
{
    public string Name { get; set; }
    public int Alter { get; set; }

    public Person(string name, int alter)
    {
        Name = name;
        Alter = alter;
    }

    public override bool Equals(object? obj)
    {
        if (obj == null || obj.GetType() != this.GetType()) return false;
        Person other = (Person)obj;
        return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) && 
               Alter == other.Alter;
    }

    // GetHashCode() überschreiben
    public override int GetHashCode()
    {
        // HashCode.Combine für die Felder benutzen.
        return HashCode.Combine(Name.ToLowerInvariant(), Alter); 
    }
}

// Benutzung in einer Collection:
public class Program
{
    public static void Main(string[] args)
    {
        Person p1 = new Person("Alice", 30);
        Person p2 = new Person("Alice", 30);
        Person p3 = new Person("Bob", 25);

        HashSet
  
    uniquePeople = new HashSet
   
    (); uniquePeople.Add(p1); uniquePeople.Add(p2); // p2 gilt als gleich zu p1, wird nicht hinzugefügt Console.WriteLine($"Anzahl einzigartiger Personen: {uniquePeople.Count}"); // Ausgabe: 1 uniquePeople.Add(p3); Console.WriteLine($"Anzahl einzigartiger Personen: {uniquePeople.Count}"); // Ausgabe: 2 } } 
   
  

Wichtig: In GetHashCode() solltest du für String-Felder, die case-insensitive verglichen werden (StringComparison.OrdinalIgnoreCase in Equals), auch den Hash so berechnen, dass er nicht von Groß-/Kleinschreibung abhängt (also z.B. Name.ToLowerInvariant() vor dem Hashen). Sonst gibt Equals() true (Alice == alice), aber GetHashCode() liefert verschiedene Werte – das verletzt den Contract.

4. Operator == und != überladen

Für Klassen (Referenztypen) prüft der Operator == standardmäßig die Referenzgleichheit. Du kannst ihn überladen, damit er Wertgleichheit prüft – wie Equals().

Regeln für das Überladen von ==:

  • Wenn du == überlädst, musst du auch != überladen.
  • Empfohlen: Der überladene == sollte sich wie Equals() verhalten.
  • Du solltest auch GetHashCode() und Equals() überschreiben, wenn du == überlädst.
class Person
{
    public string Name { get; set; }
    public int Alter { get; set; }

    // Konstruktor, Equals, GetHashCode wie oben

    // Operator == überladen
    public static bool operator ==(Person? left, Person? right)
    {
        if (ReferenceEquals(left, null)) // Check auf null
        {
            return ReferenceEquals(right, null); // Beide null? Dann gleich
        }
        return left.Equals(right); // Sonst unser Equals() benutzen
    }

    // Operator != überladen (Pflicht bei ==)
    public static bool operator !=(Person? left, Person? right)
    {
        return !(left == right);
    }
}

// Benutzung:
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
Person p3 = null;
Person p4 = null;

Console.WriteLine(p1 == p2); // true (benutzt jetzt den überladenen ==)
Console.WriteLine(p1 == p3); // false
Console.WriteLine(p3 == p4); // true

5. record – automatisches Value-Equality

Seit C# 9 gibt es den Typ record. Das ist ein Referenztyp, aber er implementiert automatisch Wertgleichheit (und überschreibt Equals(), GetHashCode(), ToString() und die Operatoren ==/!=) auf Basis aller Felder/Properties. Das macht record perfekt für immutable Datenobjekte.

public record PersonRecord(string Name, int Alter);

// Benutzung:
PersonRecord r1 = new PersonRecord("Bob", 25);
PersonRecord r2 = new PersonRecord("Bob", 25);
PersonRecord r3 = new PersonRecord("Charlie", 40);

Console.WriteLine(r1 == r2); // true (automatischer Wertvergleich!)
Console.WriteLine(r1.Equals(r2)); // true
Console.WriteLine(r1.GetHashCode() == r2.GetHashCode()); // true
Console.WriteLine(r1 == r3); // false

record macht das Leben viel einfacher, wenn du Value-Type-Verhalten für Referenztypen brauchst.

6. Empfehlungen

Wenn du Equals() überschreibst, überschreibe immer auch GetHashCode()! Wenn du das nicht machst, gibt es unvorhersehbares Verhalten in Hash-Collections.

Equals() und GetHashCode() sollten die gleichen Felder benutzen. Die Felder, die Objekte "gleich" machen, sollten auch für den Hash benutzt werden.

Sei vorsichtig mit veränderbaren (mutable) Typen. Wenn Felder, die in Equals() und GetHashCode() benutzt werden, nach der Objekterstellung geändert werden können, kann sich der Hashcode ändern. Das ist richtig schlecht für Hash-Collections, weil das Objekt dann "verloren" gehen kann (sein Hashcode ändert sich und die Collection findet es nicht mehr im "Bucket"). Für Hash-Collections solltest du unveränderliche (immutable) Typen als Keys oder Elemente benutzen.

Für immutable Datenobjekte solltest du record verwenden. Das macht die Implementierung von Value-Equality viel einfacher.

Überlade == und != nur für Referenztypen, wenn es wirklich Sinn macht. Für Value-Types vergleicht == sowieso schon nach Wert. Wenn du überlädst, achte darauf, dass das Verhalten zu Equals() passt.

Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION