Effective Java Madde 10: equals() Metodunu Geçersiz Kılarken Sözleşmeye Uyun

Java dilinde bütün sınıfların atası olan Object içerisinde, eşitlik kontrolü amacıyla kullanılan equals() metodunu geçersiz kılmak (override) çok zor bir iş gibi görünmese de bunu yanlış yapmak aslında çok daha kolay. Yanlış şekilde geçersiz kılınmış bir equals() metodu uygulamada ciddi sorunlar çıkmasına yol açabilir. Bu tarz problemlerden kaçınmanın en kolay yolu bu metodu geçersiz kılmamaktır. Bu durumda her nesne sadece kendisine eşit olacaktır. Object içerisindeki equals() metodu çok basit bir şekilde aşağıdaki gibi tanımlanmıştır:

public boolean equals(Object obj) {
    return (this == obj);
}

Gördüğünüz gibi Object sınıfında tanımlanan equals() metoduna göre her nesne sadece kendisine eşittir. Aşağıdaki durumlardan birisi söz konusuysa bu metoda dokunmamak en doğrusudur:

  • Bir sınıfın her nesnesi doğası gerekir tektir. Bu durum Thread gibi aktif varlıkları temsil eden sınıflar için doğrudur. Object sınıfından gelen equals() metodu bu tür sınıflar için doğru davranışı yansıtmaktadır.
  • Sınıfın mantıksal eşitlik testi sağlamak gibi bir amacı yoktur. Mesela, java.util.Random metodu equals() metodunu geçersiz kılarak, iki Random nesnesinin aynı değeri üretip üretmeyeceğini test edebilirdi. Ancak bu sınıfı yazanlar böyle bir teste kimsenin ihtiyacı olmayacağını düşünmüşler ve dolayısıyla equals() metoduna dokunmamışlardır. Sizin sınıfınız için de böyle bir durum geçerliyse dokunmamak en iyisi.
  • Bir ata sınıf equals() metodunu zaten geçersiz kılmıştır ve o davranış sizin için de uygundur. Mesela, birçok Set gerçekleştirimi equals() metodunu AbstractSet sınıfından alırlar, aynı şekilde List gerçekleştirimleri AbstractList, Map gerçekleştirimleri de AbstractMap sınıfından gelen equals() metodunu kullanırlar.

Peki o zaman equals() metodunu geçersiz kılmamız gereken durumlar nelerdir? Basit olarak söylemek gerekirse, nesneleri arasında mantıksal eşitlik uygulanabilen sınıflar için equals() metodu geçersiz kılınabilir. Bu tür sınıflar genellikle Date, Integer gibi değer sınıflarıdır. Bir programcı equals() metodunu kullanarak iki Integer nesnesini karşılaştırıyorsa, o iki nesnenin aynı nesne olup olmadığından ziyade aynı değeri taşıyıp taşımadığını test ediyordur. Bu durumlarda equals() metodunu geçersiz kılmak sadece programcının beklentisini karşılamakla kalmaz, aynı zamanda bu nesnelerin Map için anahtar (key) veya Set elemanı olarak doğru bir biçimde kullanılabilmesini sağlar.

Singleton gibi nesne sayısının sınırlandırıldığı sınıflar söz konusu olduğunda (Madde 1) equals() metodunu geçersiz kılmaya gerek yoktur, çünkü aynı mantıksal değere sahip birden fazla nesnenin yaratılamayacağı bellidir. Enum türü (Madde 34) bu kategoriye girmektedir, aynı değere sahip birden fazla Enum nesnesi oluşturulması mümkün değildir. Bu durumda Object sınıfından gelen equals() metodu mantıksal eşitliği karşılayacaktır.

Object sınıfı içerisinde equals() metodunu geçersiz kılarken uyulması gereken bir “sözleşme” belirtilmiştir. Bu sözleşme aşağıdaki şekildedir:

equals() metodu bir eşitlik ilişkisi tanımlar. Bu ilişki aşağıdaki şartları sağlamalıdır:

  • Dönüşlülük: Null olmayan bir x referansı için, x.equals(x) true değerini üretmelidir.
  • Simetriklik: Null olmayan x ve y referansları için, y.equals(x) true değerini üretiyor ise x.equals(y) de mutlaka true üretmelidir.
  • Geçişlilik: Null olmayan x, y ve z referansları için, x.equals(y) ve y.equals(z) true üretiyorsa, x.equals(z) de mutlaka true üretmelidir.
  • Tutarlılık: Null olmayan x ve y referansları için, equals() metodu içerisinde kullanılan alanlar değiştirilmediği sürece, x.equals(y) tutarlı bir biçimde her zaman true veya false değerini üretmelidir.
  • Null olmayan bir x referansı için, x.equals(null) false üretmelidir.

Matematiğe çok yatkın değilseniz bu kurallar biraz korkutucu görünebilir ancak asla gözardı etmeyin! Bunları çiğnediğiniz taktirde uygulamanız garip davranıp çökebilir, sorunun kaynağını bulmak da çok zor olabilir. Nesneler sürekli olarak diğer nesnelere geçilirler. Collection sınıfları da dahil olmak üzere birçok sınıf kendisine geçilen nesnelerin equals() metodunun kurallara uygun biçimde geçersiz kılındığını varsayarak işlem yaparlar.

equals() sözleşmesini ihlal etmenin tehlikeli olduğunu anladığımıza göre sözleşmenin maddelerine daha detaylı göz atalım. Tek tek bakıldığı zaman hiç de karmaşık olmayan bu kuralları bir kere anladığınız zaman uygulamak hiç de zor olmayacaktır. Şimdi bu kurallara tek tek bakalım:

Dönüşlülük

Bu kurala göre her nesne kendisine eşit olmalıdır. Bu kuralı ihlal etmek zor gibi görünse de ihlal edildiği taktirde, nesneyi bir listeye ekleyip daha sonra contains() metodunu çağırırsanız eklediğiniz nesneyi bulamayabilirsiniz.

Simetriklik

İkinci kural iki nesnenin birbirleri arasındaki eşitlik ilişkisi konusunda aynı fikirde olmaları gerektiğini söyler. İlk kuralın aksine bu kuralı ihlal etmek çok da zor değildir. Örnek olarak, aşağıdaki büyük/küçük harflere duyarsız stringleri ele alan CaseInsensitiveString sınıfını inceleyelim:

// Bozuk - simetriyi ihlal ediyor!
public final class CaseInsensitiveString {

    private final String s;

    public CaseInsensitiveString(String s) {
        if (s == null)
            throw new NullPointerException();
        this.s = s;
    }

    // Bozuk - simetriyi ihlal ediyor!
    @Override
    public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
        if (o instanceof String)
            return s.equalsIgnoreCase((String) o);
        return false;
    }
    ... // Sınıfın geri kalanı ihmal edilmiştir..
}

Yukarıdaki sınıf iyi niyetli bir biçimde normal String sınıfıyla da uyumlu çalışmayı amaçlamaktadır. Varsayalım ki aşağıdaki gibi bir normal string bir de CaseInsensitiveString nesnemiz olsun:

CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";

Beklendiği gibi, cis.equals(s) true döndürecektir. Buradaki problem, CaseInsensitiveString sınıfı normal string nesneleriye çalışabilmesine rağmen, String sınıfının bu durumdan haberinin olmamasıdır. Bu sebeple, s.equals(cis) false döndürecektir ve bu da açık bir biçimde simetri kuralının ihlali demektir. Bir CaseInsensitiveString nesnesini bir listeye eklediğimizi düşünelim:

List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);

Bu durumda list.contains(s) ne döndürecektir? Kim bilir? Şu anda güncel olan OpenJDK, bu durumda false döndürmektedir ancak bu tamamen bir tercihtir. Farklı bir JVM true döndürebilir veya bir istisna fırlatabilir. equals() sözleşmesini bir kere ihlal ettiğiniz zaman, diğer nesnelerin sizin nesnenizi nasıl ele alacağını bilemezsiniz.

Bu problemden kurtulmak için, equals() metodu içerisinde normal String nesneleriyle karşılaştırma yapan bölümü çıkarmanız yeterli olacaktır. Bunu yapınca metod aşağıdaki gibi kısalacaktır:

@Override
public boolean equals(Object o) {
    return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

Geçişlilik

equals() sözleşmesinin üçüncü maddesine göre bir nesne ikinci bir nesneye eşitse ve ikinci nesne de üçüncü bir nesneye eşitse, birinci nesne de üçüncü nesneye eşit olmalıdır. Bu kuralı da istemeden ihlal etmek son derece mümkündür. Bir çocuk sınıfın ata sınıfa başka bir alan eklediği durumu düşünelim. İki boyutlu bir noktayı temsil eden basit bir Point sınıfı ile çalışmaya başlayalım:

public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point))
            return false;
        Point p = (Point)o;
        return p.x == x && p.y == y;
    }
    ... // Sınıfın geri kalanı ihmal edilmiştir..
}

Şimdi bu sınıfı kalıtıp renk alanı eklediğimizi düşünelim:

public class ColorPoint extends Point {

    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        super(x, y);
        this.color = color;
    }
    ... // Sınıfın geri kalanı ihmal edilmiştir..
}

Burada equals() metodu nasıl yazılmalıdır? Hiç yazmazsanız metod Point sınıfından alınacaktır ve renk (color) bilgisi eşitlik karşılaştırmasında gözardı edilecektir. Bu davranış equals() sözleşmesine aykırı olmasa da doğru davranış değildir. Aşağıdaki gibi bir equals() metodu yazdığımızı düşünelim:

// Bozuk - simetriyi ihlal ediyor!
@Override
public boolean equals(Object o) {
    if (!(o instanceof ColorPoint))
        return false;
     return super.equals(o) && ((ColorPoint) o).color == color;
}

Bu gerçekleştirime göre Point ve ColorPoint nesnelerini karşılaştırırken farklı sonuçlar görmeniz mümkün. Örnekle inceleyelim:

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

Burada p.equals(cp) true döndürecektir çünkü Point içerisindeki equals() metodu renk değerini yok saymaktadır. cp.equals(p) ise false döndürecektir çünkü argüman olarak geçilen Point nesnesi ColorPoint türünde değildir. Bu sorunu çözmek için ColorPoint içerisindeki equals() metodunu aşağıdaki gibi yazmayı deneyebilirsiniz:

// Bozuk - geçişliliği ihlal ediyor!
@Override
public boolean equals(Object o) {

    if (!(o instanceof Point))
        return false;

    // Eğer 'o' normal Point ise, renk değerini ihmal et
    if (!(o instanceof ColorPoint))
        return o.equals(this);

    // 'o' ColorPoint türünde; tam karşılaştırma yap
    return super.equals(o) && ((ColorPoint)o).color == color;
}

Bu yaklaşım simetri kuralını sağlasa da geçişlilik kuralını ihlal ediyor:

ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

Burada p1.equals(p2) ve p2.equals(p3) true değerini üretirken p1.equals(p3) false döndürecektir ve bu da geçişlilik kuralını açıkça ihlal anlamına gelir. İlk iki karşılaştırma renk değerini görmezden gelirken üçüncüsü rengi de hesaba katmaktadır.

Peki çözüm nedir? Bu aslında nesne tabanlı dillerde eşitlik ilişkisinin temel bir problemidir. Somut (abstract olmayan) bir sınıfı kalıtıp yeni bir alan ekleyerek equals() sözleşmesini muhafaza etmek mümkün değildir. Kalıtım yerine komposizyon kullanarak bu dertten kurtulabilirsiniz. (Madde 18) Bu durumda ColorPoint, Point sınıfını kalıtmak yerine private bir Point referansı tutacaktır.

// equals() sözleşmesini ihlal etmeden sınıfa alan ekleme
public class ColorPoint {

    private final Point point;
    private final Color color;

    public ColorPoint(int x, int y, Color color) {
        if (color == null)
            throw new NullPointerException();
        point = new Point(x, y);
        this.color = color;
    }

    public Point asPoint() {
        return point;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
}

Java kütüphanelerindeki bazı sınıflar somut bir sınıfı kalıtıp alan eklemektedirler. Örneğin, java.sql.Timestamp sınıfı java.util.Date sınıfını kalıtır ve nanoseconds alanını ekler. Timestamp sınıfındaki equals() metodu simetriklik ilkesini ihlal etmektedir ve Timestamp ile Date sınıfları aynı Collection içerisinde veya başka bir şekilde birlikte kullanıldıklarında hatalara yol açmaktadır. Timestamp sınıfı programcıları bu konuda uyaran uyarılar içermektedir. Date ve Timestamp nesnelerini birbirlerinden ayrı tuttuğunuz sürece sorun yaşamazsınız ancak bunları birlikte kullanmanızı engelleyen bir mekanizma da yok. Timestamp sınıfının bu davranışı hatalıdır ve örnek alınmamalıdır.

Şunu da unutmayın ki soyut (abstract) sınıfları kalıtarak equals() kurallarını ihlal etmeden alan eklemek mümkündür. Örneğin, hiçbir alan içermeyen Shape isimli bir soyut sınıfınız varsa, bunu kalıtan Circle adlı bir çocuk sınıf yaratıp radius gibi bir alan ekleyebilirsiniz. Aynı şekilde Rectangle diye bir çocuk sınıf yaratıp width and height gibi alanlar ekleyebilirsiniz. Bu durumda yukarıdaki gibi problemler karşınıza çıkmayacaktır çünkü ata sınıf soyut olduğu için direk olarak ata sınıftan nesne yaratmanız mümkün olmayacaktır.

Tutarlılık

Dördüncü kurala göre eğer iki nesne birbirine eşitse, bu nesnelerden en az birisi değişmediği sürece hep eşit kalmalıdırlar. Başka bir deyişle, kararsız (mutable) nesneler değişik zamanlarda değişik nesnelere eşit olabilirken, kararlı (immutable) nesneler olamazlar. Bu yüzden bir sınıf yazarken kararlı olup olmaması gerektiğini iyi düşünün, kararlı olması gerektiğine karar verirseniz equals() metodunun bu kuralı sağladığından emin olun.

Bir sınıf kararlı da kararsız da olsa, equals() metodunun sonucu güvenilir olmayan kaynaklara asla bağlı olmamalıdır. Bu kuralı ihlal ettiğiniz zaman tutarlılık prensibini sağlamamanız çok zorlaşacaktır. Mesela, java.net.URL sınıfındaki equals() metodu, URL ile ilişkili olan IP adreslerini de dikkate alarak bir sonuç üretmektedir. Bu IP adreslerini üretmek ağ bağlantısı gerektirebilir ve üstelik zaman içerisinde aynı sonucu üreteceğinin de bir garantisi yoktur. Maalesef bu durum URL sınıfının equals() metodunun tutarsız çalışmasına sebep olmaktadır ve pratikte bir takım problemlere yol açmıştır. Çok ekstrem durumlar haricinde equals() metodu dış kaynaklara bağımlı olmadan her zaman aynı sonucu üretebilecek bir durumda olmalıdır.

Null Değerler

equals() sözleşmesinin son maddesine göre hiçbir nesne null değerine eşit değildir. o.equals(null) gibi bir metod çağrısından yanlışlıkla true döndürmek zor bir ihtimal gibi görünse de NullPointerException fırlatmak gibi bir hata yapabiliriz. Birçok sınıf bu hatayı önlemek için açık bir biçimde null testi yapmaktadır.

@Override
public boolean equals(Object o) {
    if (o == null)
        return false;
    ...
}

Bu test gereksizdir. Geçilen argüman eşitlik için kontrol edilmesi gerektiğinde ilk başta doğru türe dönüştürülmelidir ki o nesnenin alanlarına erişilebilsin. Tür dönüştürme yapılmadan önce ise instanceof operatörü kullanılarak doğru türde bir nesnenin geçildiğinden emin olunması gerekmektedir.

@Override
public boolean equals(Object o) {
    if (!(o instanceof MyType))
        return false;
    MyType mt = (MyType) o;
    ...
}

Eğer instanceof operatörüyle tür kontrolü yapmasaydık 5. satırdaki tür dönüştürme (cast) işlemi ClassCastException ile sonuçlanırdı ve bu da equals() sözleşmesine aykırı olurdu. instanceof operatörü eğer solundaki değer null ise false döndürecek şekilde tasarlanmıştır, bu durumda sağındaki tür değerinin bir önemi yoktur. Dolayısıyla metoda null geçildiği taktirde zaten false sonuç üretecektir, fazladan bir null kontrolü yapmaya gerek yoktur.

Bütün bu söylediklerimizi toparlayacak olursak, doğru bir equals() metodunun tarifi şu şekildedir:

1. == operatörünü kullanarak geçilen argümanın bu nesneye bir referans olup olmadığını kontrol edin. Eğer öyleyse true döndürün. Bu iyileştirme, eğer karşılaştırma işlemi pahalı bir işlemse performans artışı sağlayabilir.
2. instanceof operatörünü kullanarak geçilen argümanın doğru türde olup olmadığını kontrol edin. Eğer aynı türden değilse false döndürün. Tipik olarak doğru tür, equals() metodunun yazılı olduğu sınıftır. Ancak bazı durumlarda sınıfın gerçekteştirdiği bir arayüz türü de olabilir. Set, List, Map gibi sınıflarda bu şekilde bir kullanım görmek mümkündür.
3. Argümanı doğru türe dönüştürün. Tür dönüşümü instanceof testinden sonra geleceği için her zaman başarılı olacaktır.
4. Eşitlik kontrolü yaparken sınıf içerisindeki bütün anlamlı alanları karşılaştırın. Eğer bütün alanlar eşitse o zaman true, değilse false döndürün. Karşılaştırma yaparken float veya double olmayan ilkel türler için == operatörünü, nesne referansları için ise özyineli olarak equals() metodunu kullanın. float için Float.compare(float, float) ve double için Double.compare(double, double) metotlarını kullanın. float ve double türleri için yapılan bu özel karşılaştırmanın sebebi Float.NaN, -0.0f ve bunlara karşılık gelen double değerleridir. Float.equals dokümantasyonuna bakarak bu konuda daha detaylı bilgi alabilirsiniz. float ve double türleri karşılaştırmak için Float.equals ve Double.equals kullanmak mümkün olsa da, autoboxing devreye gireceği için performansı düşük olacaktır. Dizi türleri için (array), buradaki önerileri her bir elemana ayrı ayrı uygulayın. Eğer dizideki bütün alanlar anlamlıysa, Arrays.equals metotlarından birini kullanabilirsiniz.

Bazı nesne referansları null değerini meşru bir biçimde alabilirler, bu durumda olası bir NullPointerException hatasını engellemek için Objects.equals(Object, Object) kullanabilirsiniz:

equals() metonunun performansı yapılan karşılaştırmaların sırasına göre değişebilir. En iyi performans için, farklı olma ihtimali yüksek olan alanlar ve karşılaştırması ucuz olan alanları tercih edin. Nesnenin o anki mantıksal durumunu yansıtmayan, Lock gibi senkronizasyon işlemleri için kullanılan alanları karşılaştırmayın. Sınıfın içerisinde başka alanlar kullanılarak türetilen alanlar varsa bunları da karşılaştırmanın bir gereği yoktur.

equals() metodunuzu yazmayı bitirdikten sonra kendinize 3 soru sorun: Simetrik oldu mu? Geçişli oldu mu? Tutarlı oldu mu? Bu soruları sadece kendinize de sormayın, birim testler yazarak gerçekten bu kuralları sağladınızı garanti altına alın. Eğer AutoValue kullanarak equals() metodunu otomatik oluşturduysanız, birim testleri yazmanıza gerek olmayabilir. Tabiki de diğer iki kuralı da (dönüşlülük ve null değerler) sağlamanız gerekmekte, ancak diğer üçünü sağladığınız zaman geri kalanlar da genellikle sağlanmış oluyor.

Bütün bu kuralların nasıl uygulandığını gösteren somut bir örnek aşağıda verilmektedir:

public final class PhoneNumber {
    
    private final short areaCode, prefix, lineNum;
    public PhoneNumber(int areaCode, int prefix, int lineNum) {
        this.areaCode = rangeCheck(areaCode, 999, "area code");
        this.prefix   = rangeCheck(prefix,   999, "prefix");
        this.lineNum  = rangeCheck(lineNum, 9999, "line num");
    }

    private static short rangeCheck(int val, int max, String arg) { 
        if (val < 0 || val > max)
            throw new IllegalArgumentException(arg + ": " + val); 
        return (short) val;
    }

    @Override 
    public boolean equals(Object o) 
    { 
        if (o == this)
            return true;
        if (!(o instanceof PhoneNumber))
            return false;
        PhoneNumber pn = (PhoneNumber)o;
        return pn.lineNum == lineNum &amp;&amp; pn.prefix == prefix
                   &amp;&amp; pn.areaCode == areaCode;
    }
       ... // Sınıfın geri kalanı çıkartılmıştır
}
  • Zekanıza çok güvenmeyin: Sınıfın alanlarını test ederek eşitlik testi yaptığınız sürece equals() sözleşmesine uymak son derece basit. Olayı abartıp eşitliği başka yerlerde ararsanız başınızı belaya sokma ihtimaliniz çok yüksek.
  • equals() metodunu geçersiz kılarken Object sınıfını başka bir türle değiştirmeyin. Aşağıdaki gibi yazılmış bir equals() metodunu sıklıkla görebilirsiniz:
    public boolean equals(MyClass o) { ... }

    Buradaki problem, bu metot Object sınıfındaki equals() metodunu geçersiz kılmaktan (override) ziyade aşırı yüklemektedir (overload) ve bu ikisi tamamen farklı şeylerdir. Geçersiz kılınmış bir equals() metodunun yanında ek olarak aşırı yüklenmiş bir versiyon yazılması, her ne kadar çok faydalı olmasa da kabul edilebilir.

equals() metoduyla birlikte @Override notasyonunun (annotation) kullanılması sizin bu hatayı yapmanıza engel olacaktır. Örneğin, aşağıdaki metod derleme aşamasında hata verecektir ve size neyin yanlış olduğunu söyleyecektir:

@Override
public boolean equals(MyClass o) {
...
}

equals() (ve hashCode) metotlarını yazıp test etmek son derece meşakkatli bir iştir. Kendiniz bu işi yapmak yerine, Google tarafından geliştirilen AutoValue kütüphanesini kullanmak, bu işi çok basitleştirmektedir. Sınıf üzerinde tanımlayacağınız bir annotation ile bu metotların otomatik olarak üretilmesini sağlayabilirsiniz, otomatik olarak üretilen bu metotlar çoğu zaman sizin yazacağınız metotlarla birebir aynı olacaktır.

Kod düzenleyici programlar da (IDE) bu metotları otomatik üreten özelliklere sahiptirler. Ancak ürettikleri kod AutoValue kütüphanesine göre daha az okunabilir olmaktadır ve sınıftaki değişiklikleri otomatik olarak takip etmemektedir. Bu sebeple test edilmesi gerekmektedir. Ancak yine de, IDE tarafından üretilen metotları kullanmak kendiniz yazmaktan daha mantıklıdır, çünkü bu programlar insanlar gibi dikkatsizlik sonucu hata yapmazlar.

Özet olarak, equals() metodunu mecbur değilseniz geçersiz kılmayın. Çoğu durumda, Object sınıfından gelen davranış sizin ihtiyacınızı karşılayacaktır. Kendiniz yazıyorsanız da, yukarıda incelenen sözleşmenin bütün kurallarını sağladığınıza emin olun.

Share

Bir Cevap Yazın