C#'ta Eşitlik ve Karşılaştırma: Equals, GetHashCode, IEquatable ve IComparable
Reference ve value equality, Equals ile GetHashCode contract'ı, IEquatable<T> ve == operatörü, struct'larda eşitlik, IComparable<T> ile sıralama ve dışarıdan karar veren comparer'lar.
Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.
Önce Tahmin Et
Bir HashSet<Book>'e aynı ISBN'li iki kitap ekliyoruz. HashSet'in işi tekrarları engellemek. Peki Count kaç olur, Contains ne döner?
var books = new HashSet<Book>
{
new Book("978-0441013593", "Dune"),
new Book("978-0441013593", "Dune")
};
Console.WriteLine(books.Count);
Console.WriteLine(books.Contains(new Book("978-0441013593", "Dune")));
public class Book
{
public Book(string isbn, string title)
{
Isbn = isbn;
Title = title;
}
public string Isbn { get; }
public string Title { get; }
}
İki Tür Eşitlik
Çıktı:
2
False
HashSet iki kitabı da kabul etti ve üçüncü bir Book ile arama yapınca hiçbirini bulamadı. Çünkü Book bir class ve class'larda eşitliğin varsayılan anlamı reference equality: iki değişken bellekte aynı object'i gösteriyorsa eşittirler. Üç ayrı new üç ayrı object demek; değerlerinin aynı olması hiçbir şeyi değiştirmiyor.
Bunun karşısında value equality var: içerikleri aynıysa eşittirler. Önceki yazıda record'un bunu bize hazır verdiğini gördük. string de bir reference type olmasına rağmen value equality kullanır; "Dune" == "Du" + "ne" true'dur, çünkü .NET string için Equals'ı ve =='yu override etmiştir.
Hangi eşitliğin doğru olduğu bir iş kararıdır. Bir kitapçı için iki kitabı "aynı" yapan ne? Bu yazıda cevabımız ISBN: aynı ISBN aynı kitap demek, başlıktaki yazım farkı ya da fiyat değişikliği kitabın kimliğini değiştirmez. Şimdi bunu koda dökelim.
Equals ve GetHashCode Birlikte Gelir
İlk akla gelen Equals(object?)'ı override etmek:
var a = new Book("978-0441013593", "Dune");
var b = new Book("978-0441013593", "Dune");
Console.WriteLine(a.Equals(b));
public class Book
{
public Book(string isbn, string title)
{
Isbn = isbn;
Title = title;
}
public string Isbn { get; }
public string Title { get; }
public override bool Equals(object? obj) => obj is Book other && Isbn == other.Isbn;
}
Program.cs(5,14): warning CS0659: 'Book' overrides Object.Equals(object o) but does not override Object.GetHashCode()
Program True yazar ama compiler haklı olarak uyarıyor. HashSet ve Dictionary bir object'i ararken önce GetHashCode() ile hangi "kovaya" bakacağını bulur, sonra sadece o kovadaki element'ler için Equals çağırır. Bunu bir kütüphanedeki raf numarası gibi düşün: önce kitabın hangi rafta olduğuna bakarsın, sonra o raftaki kitapları tek tek karşılaştırırsın. Eşit iki object farklı hash code üretirse farklı raflara gider ve Equals hiç çağrılmaz.

Buradan contract'ın en önemli kuralı çıkıyor: Equals ile eşit olan iki object aynı GetHashCode() değerini döndürmek zorunda. Tersi gerekmez; eşit olmayan object'lerin hash code'u da çakışabilir, sadece performans biraz düşer. Bu yüzden hash code'u hep eşitlikte kullandığın field'lardan üretirsin, başka field'lardan değil. Equals'ın kendi kuralları da var: x.Equals(x) her zaman true, x.Equals(y) ile y.Equals(x) aynı sonucu verir, x.Equals(null) her zaman false olur ve exception fırlatmaz.
IEquatable<T> ve == Operatörü
Eksiksiz bir hâli şöyle:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var books = new HashSet<Book>
{
new Book("978-0441013593", "Dune", 12.99m),
new Book("978-0441013593", "Dune", 12.99m),
new Book("978-0141439518", "Emma", 7.50m)
};
Console.WriteLine(books.Count);
Console.WriteLine(books.Contains(new Book("978-0441013593", "Dune (Deluxe)", 30m)));
var a = new Book("978-0441013593", "Dune", 12.99m);
var b = new Book("978-0441013593", "Dune", 12.99m);
Console.WriteLine(a == b);
Console.WriteLine(ReferenceEquals(a, b));
public sealed class Book : IEquatable<Book>
{
public Book(string isbn, string title, decimal price)
{
Isbn = isbn;
Title = title;
Price = price;
}
public string Isbn { get; }
public string Title { get; }
public decimal Price { get; }
public bool Equals(Book? other) => other is not null && Isbn == other.Isbn;
public override bool Equals(object? obj) => Equals(obj as Book);
public override int GetHashCode() => Isbn.GetHashCode();
public static bool operator ==(Book? left, Book? right) =>
left is null ? right is null : left.Equals(right);
public static bool operator !=(Book? left, Book? right) => !(left == right);
}
2
True
True
False
Artık HashSet'te iki kitap var ve "Dune (Deluxe)" başlıklı, 30'luk bir arama bile True döndü; çünkü eşitliği sadece ISBN'e bağladık. Bu bir hata değil, tam olarak verdiğimiz karar. Ama kararın sonuçlarını bilmek gerek.
Parçaları tek tek konuşalım. IEquatable<Book>, tip güvenli bir Equals(Book?) sunar. HashSet, Dictionary ve List.Contains karşılaştırmayı EqualityComparer<T>.Default ile yapar; o da tip IEquatable<T>'yi implement ediyorsa bu method'u kullanır, etmiyorsa Equals(object?)'a düşer. Asıl mantığı tek yere yazıp Equals(object?)'ı ona yönlendirmek iki method'un birbirinden kopmasını engeller.
== ise ayrı bir konu. Equals'ı override etmek =='yu değiştirmez; operatörü kendin tanımlamazsan class'ta == referans karşılaştırmaya devam eder. Tanımladığında == ve !='yi birlikte tanımlaman zorunlu. Operatör tanımlamanın kuralları bir sonraki yazının konusu. Burada dikkat edilecek nokta, null kontrolünü left == null ile değil is null ile yapmak; aksi hâlde operatör kendini çağırır ve sonsuz recursion'a girer. Gerçekten aynı object mi diye bakman gerektiğinde ReferenceEquals her zaman elinde.
Class'ı sealed yaptık, çünkü inheritance eşitliği zorlaştırır. Bir EBook : Book kendi field'larını da eşitliğe katarsa book.Equals(ebook) ile ebook.Equals(book) farklı sonuç verebilir. Record'lar bunu EqualityContract ile çözüyordu; elle yazarken en temiz yol, sealed bir class.
Struct'larda Eşitlik
Struct'lar varsayılan olarak value equality kullanır, ama bedeli var:
var a = new Money(10m, "EUR");
var b = new Money(10m, "EUR");
Console.WriteLine(a.Equals(b));
Console.WriteLine(a == b);
public struct Money
{
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public decimal Amount { get; }
public string Currency { get; }
}
Program.cs(4,19): error CS0019: Operator '==' cannot be applied to operands of type 'Money' and 'Money'
Struct'larda == hazır gelmez, bu satır derlenmez. Equals ise çalışır ve True döner; ama işi ValueType.Equals yapar. Dokümana göre bu method, field'lardan biri reference type ise karşılaştırmayı reflection ile yapar; üstelik Equals(object) signature'ı yüzünden argüman box'lanır. Sık karşılaştırılan ya da Dictionary'de key olan struct'larda eşitliği kendin yazman iyi bir alışkanlık:
var a = new Money(10m, "EUR");
var b = new Money(10m, "EUR");
var c = new Money(10m, "USD");
Console.WriteLine(a == b);
Console.WriteLine(a == c);
Console.WriteLine(a.GetHashCode() == b.GetHashCode());
public readonly struct Money : IEquatable<Money>
{
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public decimal Amount { get; }
public string Currency { get; }
public bool Equals(Money other) => Amount == other.Amount && Currency == other.Currency;
public override bool Equals(object? obj) => obj is Money other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
public static bool operator ==(Money left, Money right) => left.Equals(right);
public static bool operator !=(Money left, Money right) => !left.Equals(right);
}
True
False
True
Birden fazla field'ı hash'e katmanın kolay yolu HashCode.Combine. Elle ^ ile XOR'lamak iki field aynı değeri aldığında sıfır üretmek gibi zayıflıklar taşır. Bir uyarı: string.GetHashCode() her process'te farklı bir değer üretir (hash randomization). Hash code'u dosyaya yazma, veritabanına kaydetme; sadece bellekteki collection'lar için.
Sıralama: IComparable<T>
Eşitlik "aynı mı?" sorusuna cevap verir, karşılaştırma "hangisi önce?" sorusuna. Bir List<Book>'u hiçbir hazırlık yapmadan sıralamaya çalışırsan:
var books = new List<Book> { new Book("Dune"), new Book("Emma") };
books.Sort();
public class Book
{
public Book(string title) => Title = title;
public string Title { get; }
}
Unhandled exception. System.InvalidOperationException: Failed to compare two elements in the array.
---> System.ArgumentException: At least one object must implement IComparable.
Stack trace'i çıkardım; kalan iki satır meseleyi anlatıyor. Sort()'a parametre vermezsen tipin kendisinin "doğal sıralamasını" bilmesi gerekir. Bunu IComparable<T> ile söylersin. CompareTo negatif bir sayı dönerse bu object önce gelir, sıfır dönerse ikisi sıralamada eşittir, pozitif dönerse sonra gelir:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var books = new List<Book>
{
new Book("978-0441013593", "Dune", 12.99m),
new Book("978-0547928227", "The Hobbit", 7.50m),
new Book("978-0141439518", "Emma", 7.50m)
};
books.Sort();
foreach (var book in books)
Console.WriteLine($"{book.Price} {book.Title}");
public sealed class Book : IComparable<Book>
{
public Book(string isbn, string title, decimal price)
{
Isbn = isbn;
Title = title;
Price = price;
}
public string Isbn { get; }
public string Title { get; }
public decimal Price { get; }
public int CompareTo(Book? other)
{
if (other is null)
return 1;
int byPrice = Price.CompareTo(other.Price);
return byPrice != 0 ? byPrice : string.CompareOrdinal(Title, other.Title);
}
}
7.50 Emma
7.50 The Hobbit
12.99 Dune
Önce fiyata, fiyat eşitse başlığa bakıyoruz. Karşılaştırmayı her seferinde field'ın kendi CompareTo'suna bırakmak en güvenli yol. null her şeyden küçük kabul edilir; bu .NET'in genel kuralı, bu yüzden null gelirse 1 döndük. Başlıkta string.CompareOrdinal kullandık, çünkü culture'a bağlı karşılaştırma makineden makineye farklı sonuç verebilir. Temeller serisinde string karşılaştırma ve Türkçe "I" tuzağını görmüştük.
Kararı Dışarıda Vermek: IComparer<T> ve IEqualityComparer<T>
Bir tipin tek bir doğal sıralaması olur, ama ekranda bazen başlığa, bazen stoğa göre sıralamak istersin. Eşitlik için de aynı şey geçerli: ISBN bazlı eşitlik çoğu yer için doğru olsa da bir raporda kitapları başlığa göre tekilleştirmen gerekebilir. Bu kararları tipin dışına taşımanın yolu comparer'lar:
var books = new List<Book>
{
new Book("978-0441013593", "Dune"),
new Book("978-0141439518", "emma"),
new Book("978-0000000001", "Emma")
};
var byTitle = Comparer<Book>.Create((x, y) => string.Compare(x.Title, y.Title, StringComparison.OrdinalIgnoreCase));
books.Sort(byTitle);
Console.WriteLine(string.Join(", ", books.Select(b => b.Title)));
var uniqueTitles = new HashSet<Book>(books, new TitleComparer());
Console.WriteLine(uniqueTitles.Count);
var tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "sci-fi", "SCI-FI", "classic" };
Console.WriteLine(tags.Count);
public sealed class Book
{
public Book(string isbn, string title)
{
Isbn = isbn;
Title = title;
}
public string Isbn { get; }
public string Title { get; }
}
public sealed class TitleComparer : IEqualityComparer<Book>
{
public bool Equals(Book? x, Book? y) =>
string.Equals(x?.Title, y?.Title, StringComparison.OrdinalIgnoreCase);
public int GetHashCode(Book obj) => StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Title);
}
Dune, emma, Emma
2
2
Comparer<T>.Create bir lambda'dan IComparer<T> üretir; List.Sort, Array.Sort ve SortedSet gibi yerler bunu kabul eder. IEqualityComparer<T>'ı ise HashSet'in ve Dictionary'nin constructor'ına verirsin. Hash kuralı burada da geçerli: büyük-küçük harf duyarsız eşitlik yapıyorsan hash code da büyük-küçük harf duyarsız olmalı, o yüzden StringComparer.OrdinalIgnoreCase.GetHashCode kullandık. String key'ler için hiçbir şey yazmana gerek yok; StringComparer'ın hazır instance'ları her iki interface'i de implement eder. Bu comparer'ları koleksiyon serisinde Dictionary ve sıralı collection'larla tekrar kullanacağız.
Sık Düşülen Tuzaklar
Hash code'u değişebilen bir field'dan üretmek. Bir object HashSet'e girdikten sonra hash code'u değişirse kendi kovasında kaybolur:
var book = new MutableBook { Isbn = "978-0441013593" };
var set = new HashSet<MutableBook> { book };
book.Isbn = "978-0141439518";
Console.WriteLine(set.Contains(book));
Console.WriteLine(set.Remove(book));
Console.WriteLine(set.Count);
public sealed class MutableBook : IEquatable<MutableBook>
{
public string Isbn { get; set; } = "";
public bool Equals(MutableBook? other) => other is not null && Isbn == other.Isbn;
public override bool Equals(object? obj) => Equals(obj as MutableBook);
public override int GetHashCode() => Isbn.GetHashCode();
}
False
False
1
Object hâlâ set'in içinde ama artık ne bulunabiliyor ne silinebiliyor. Eşitlikte kullandığın field'ları immutable yap; yapamıyorsan o object'i key olarak kullanma.
CompareTo'yu çıkarma ile yazmak. return Stock - other.Stock; kısa görünür ama büyük değerlerde taşar:
int x = 2_000_000_000;
int y = -2_000_000_000;
Console.WriteLine(x - y);
Console.WriteLine(x.CompareTo(y));
-294967296
1
x büyük olmasına rağmen fark negatif çıktı, yani sıralama ters döner. CompareTo kullan.
Eşitlik ile sıralamayı birbirinden koparmak. SortedSet tekrarları Equals ile değil CompareTo ile belirler. Yukarıdaki fiyat ve başlığa göre sıralayan, ISBN'e göre eşitlik yapan Book ile aynı fiyat ve başlığa sahip ama ISBN'i farklı bir kitap eklediğimde SortedSet'in sayısı 3'te kaldı, HashSet'inki 4'e çıktı. CompareTo sıfır döndüğünde mümkünse Equals da true dönsün; tutarlı kalmanın kolay yolu son kademede eşitlik field'ını (burada ISBN) da karşılaştırmak.
Equals'ı override edip =='yu unutmak. İkisi ayrı mekanizmalar; class'ta == tanımlamazsan a.Equals(b) true iken a == b false kalır. Ekipteki herkes hangisinin ne yaptığını bilmiyorsa bu fark sinsi hatalar üretir.
Küçük Bir Deneme
★☆☆ Author adında, Name ve BirthYear property'leri olan bir sealed class yaz. IEquatable<Author>'ı ve GetHashCode'u iki field'ı da kullanacak şekilde implement et ve bir HashSet<Author>'te tekrarların engellendiğini gör.
★★☆ Yukarıdaki Book'un CompareTo'suna üçüncü kademe olarak ISBN karşılaştırmasını ekle ve SortedSet deneyini tekrarla. Sayı artık kaç?
★★★ Book'a bir int Stock property'si ekle; ardından stoğa göre azalan, stok eşitse başlığa göre artan sıralayan bir IComparer<Book> class'ı yaz. Sonra aynı sıralamayı Comparer<Book>.Create ile tek satırda kur ve ikisinin aynı sonucu verdiğini kontrol et.
Bu yazıda == ve != operatörlerini tanımladık ama kurallarına girmedik. Bir sonraki yazıda operator overloading'i ayrıntısıyla açıyor, Money için + ve > gibi operatörler ve dönüşüm operatörleri yazıyor, ardından bir class'ı array gibi kullanmamızı sağlayan indexer'lara geçiyoruz.