Effective Java Madde 4: Nesne Yaratılmasını İstemediğiniz Sınıfları Private Yapıcı Metot İle Güçlendirin

Bazen sadece statik metotları ve değişkenleri bir arada tutmak için bir sınıf yazmak isteyebilirsiniz. Bu tür sınıflar, bazı geliştiriciler tarafından nesne bazlı düşünmekten kaçınmak için suistimal edildiklerinden ötürü kötü bir üne kavuşmuşlardır. Buna rağmen, geçerli kullanım alanları bulunmaktadır. İlkel türler veya diziler üzerinde işlem yapan, birbiriyle alakalı metotları gruplamak için kullanılabilirler. java.lang.Math ve java.util.Arrays bu kullanıma örnek gösterilebilir. Bunun yanında java.util.Collections örneğinde olduğu gibi, belli bir arayüzü gerçekleştiren nesneler için tasarlanmış statik metotları – statik fabrika metotları da dahil (Madde 1) – bir arada tutmak için de kullanılabilirler. (Java 8 ile birlikte artık bu metotları arayüz içerisine de yazabilirsiniz, tabi arayüze erişiminiz varsa.) Son olarak, bu sınıflar final bir sınıf içerisindeki metotları gruplamak için de kullanılabilir, çünkü bunlar bir çocuk sınıfta tanımlanamaz.

Bu tarz yardımcı sınıflardan (utility class) nesne oluşturulması istenmez. Ancak herhangi bir yapıcı metot tanımlanmadığı zaman, Java derleyicisi otomatik olarak parametre almayan public bir varsayılan yapıcı metot (default constructor) tanımlayacaktır. Bir istemci açısından bu varsayılan yapıcı metodun, açıkça tanımlanmış diğer yapıcı metotlardan hiçbir farkı yoktur. Bu sebeple, birçok API içerisinde istemeden de olsa nesne yaratılabilen sınıflar bulunmaktadır.

Bunu engellemek için bir sınıfı abstract tanımlamak işe yaramaz. Bu sınıf katılılarak çocuk sınıftan bir nesne yaratılabilir. Dahası, kullanıcı bu sınıfın kalıtım amaçlı tasarlandığı yanılgısına kapılabilir. (Madde 19) Aslında bir sınıftan nesne yaratılmasını engellemek için çok basit bir yöntem vardır, private bir yapıcı metot tanımlamak. Biz açıkça bir yapıcı metot tanımladığımız zaman, derleyici varsayılan public yapıcı metodu tanımlamaz.

// Nesne yaratılamayan yardımcı sınıf
public class UtilityClass {
    // Varsayılan yapıcı metodu engelle
    private UtilityClass() {
        throw new AssertionError();
    }
    
    ...  // diğer kısımlar çıkartılmıştır
}

Tanımladığımız yapıcı metot private olduğu için sınıfın dışından görülebilmesi veya çalıştırılabilmesi mümkün değildir. Fırlatılan AssertionError ise yapıcı metodun sınıfın içerisinden yanlışlıkla çağırılması ihtimaline karşı önlem almak içindir. Böylece bu sınıftan asla bir nesne oluşturulamayacağını garanti etmiş oluruz.

Bir yan etki olarak, bu private yapıcı metot aynı zamanda sınıfın kalıtılmasını da engeller. Bütün yapıcı metotlar ilk önce kalıttıkları ata sınıfın (açıkça belirtilen bir kalıtım yoksa Object sınıfının) yapıcı metodunu çağırmak zorundadır, ata sınıftaki yapıcı metot açıkça tanımlanmış veya derleyici tarafından eklenen varsayılan yapıcı metot olabilir. Biz yukarıdaki yardımcı sınıfta hem varsayılan yapıcı metodun tanımlanmasını engellediğimiz için hem de kendi yapıcı metodumuzu private tanımladığımız için, bu sınıfı kalıtacak bir çocuk sınıfın çağırabileceği bir yapıcı metot bulunmamaktadır.

Share

Effective Java Madde 3: Singleton Sınıfları Private Yapıcı Metot veya Enum Türüyle Güçlendirin

Singleton en basit anlamıyla sadece bir kez somutlaştırılabilen (instantiate) sınıf anlamına gelir. Diğer bir değişle, singleton sınıflardan sadece bir kez nesne oluşturulabilir. Bu nesneler ya fonksiyon gibi durum içermeyen nesneleri, (Madde 24) ya da doğası itibariyle eşsiz olan bileşenleri temsil ederler. Bir sınıfı singleton yapmak, onu kullanan istemcileri test etmeyi zorlaştırır çünkü singleton nesneyi bir mock gerçekleştirim ile değiştirmek eğer bu nesne bir arayüzü gerçekleştirmiyorsa imkansızdır.

Singleton nesne oluşturmak için iki yöntem vardır. Her ikisi de yapıcı metotları private tutarak, singleton nesneyi public static bir üye olarak dışarı açma esasına dayanmaktadır. Bu yöntemlerden ilkinde, singleton nesne final olarak tanımlanmaktadır:

// public final olarak tanımlanmış singleton alan
public class Elvis {

public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}

>> DEVAMINI OKU
Share

Effective Java Madde 18: Kalıtım Yerine Komposizyonu Tercih Edin

Kalıtım kodların yeniden kullanımını sağlayabilen güçlü bir araçtır, ancak her zaman yapacağınız iş için en doğru araç olmayabilir. Yanlış kullanıldığında kırılgan yazılımlara yol açar. Kalıtımı, aynı paket içerisinde, ata sınıf ve çocuk sınıfın aynı programcının kontrolünde olduğu durumlarda kullanmak güvenlidir. Bunun yanında, özellikle kalıtılmak için tasarlanmış ve belgelenmiş sınıfları kalıtmak da güvenlidir (Madde 19). Ancak, farklı paketlerdeki sıradan somut sınıfları kalıtmak tehlikelidir. Hatırlatma olarak söyleyelim, burada kalıtım dediğimiz zaman bir sınıfın başka bir sınıfı kalıtmasından bahsetmekteyiz. Bahsedilen problemler arayüz (interface) kalıtırken geçerli değildir.

Metot çağırmanın aksine, kalıtım yapmak sarmalamayı (encapsulation) ihlal eder. Başka bir deyişle, bir çocuk sınıf işlevini gerçekleştirebilmek için ata sınıfın gerçekleştirim detaylarına bağımlıdır. Ata sınıfın içeriği zaman içerisinde yeni sürümlerle birlikte değişebilir, ve bu durumda çocuk sınıfın kodu hiç değişmemiş olsa bile çalışmayabilir. Sonuç olarak, eğer ata sınıf özel olarak kalıtılmak için tasarlanmamışsa, çocuk sınıf değişikliğe ihtiyacı olmasa bile ata sınıfla birlikte gelen değişikliklere uyum sağlayacak şekilde değiştirilmelidir.

Bunu somutlaştırmak için, farzedelim ki HashSet kullanan bir programımız olsun ve uygulamaya bu HashSet yaratıldığından beri kaç kere eleman eklendiğini döndüren bir metot eklemek istediğimizi düşünelim. (HashSet’in o andaki eleman sayısı ile karıştırmayın, eleman sayısı eleman sildikçe azalır.) Bunu sağlamak için HashSet sınıfını kalıtıp, her eleman eklendiğinde bir sayacı artıralım ve bu sayaç değerini döndüren bir metot ekleyelim. HashSet sınıfında add() ve addAll() olmak üzere iki tane eleman ekleyen metot vardır, dolayısıyla bunları geçersiz kılmalıyız.

// Bozuk - Yanlış kalıtım kullanımı!
public class InstrumentedHashSet<E> extends HashSet<E> {

    // Eklenen eleman sayısı
    private int addCount = 0;

    public InstrumentedHashSet() { }

    public InstrumentedHashSet(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

Yukarıdaki sınıf akla yatkın görünse de aslında bozuktur. Diyelim ki bu sınıftan bir nesne yarattık ve addAll() metodunu kullanarak 3 eleman ekledik. Burada, Java 9 ile eklenen List.of statik metodunu kullanıyoruz, daha önceki versiyonlar için Arrays.asList kullanın.

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));

Bu noktada getAddCount() metodunun 3 değeri döndürmesini bekleriz ama aslında 6 döndürecektir. Yanlış giden nedir? HashSet sınıfındaki addAll() metodu aslında kendi içerisinde add() metodunu çağıracak şekilde gerçekleştirilmiştir. Dolayısıyla, InstrumentedHashSet çocuk sınıfındaki addAll() metodu addCount değerine 3 ekledikten sonra ata sınıftaki aynı isimli metodu super.addAll() ile çağırmaktadır. Ata sınıf kendi gerçekleştirimi gereği addAll() içerisinde add() metodunu çağırmakta, bu metot da çocuk sınıfta geçersiz kılındığı için çocuk sınıftaki add() metodu işletilmektedir, bu yüzden de her bir eleman için 2 defa addCount sayacına ekleme yapılmaktadır.

Bu problemi addAll() metodunu geçersiz kılmayarak (override etmeyerek) çözmek mümkündür. Sonuçta elde edeceğimiz sınıf çalışacaktır ancak bu sefer de sınıfın doğru çalışması ata sınıfın addAll() içerisinde add() metodunu çağırmasına bağımlı olacaktır. Bu tamamen HashSet sınıfını yazanların tercihidir ve ileriki Java sürümlerinde öyle kalacağının hiçbir garantisi yoktur. Bu sebeple de yazdığımız çocuk sınıf o an için çalışsa da ata sınıftaki değişikliklere karşı çok kırılgan olacaktır.

Alternatif olarak, addAll() metodunu ata sınıfı hiç çağırmadan verilen veri yapısı üzerinde dolaşıp her eleman için çocuk sınıftaki add() metodunu çağıracak şekilde geçersiz kılabiliriz. Bu durumda ata sınıfın addAll() içerisinde add() metodunu çağırdığı varsayımından kurtularak sınıfın her şekilde doğru çalışmasını sağlayabilir, kırılganlık problemini çözebiliriz. Çünkü ata sınıftaki addAll() metodu devreden tamamen çıkmış olacaktır. Ancak bu yöntem de bütün problemleri çözmez. Ata sınıf içerisindeki detaylar her zaman bu kadar açık olmayacaktır ve bulması zaman alacak, kolay hata yapmaya sebebiyet verecektir. Hatta bazen bu yöntemi uygulamak mümkün olmayacaktır çünkü bazı metotlar çocuk sınıfın erişemediği, ata sınıf içerisindeki private alanlara ihtiyaç duyacaktır.

Çocuk sınıflarda kırılganlığa sebebiyet veren bir başka unsur da ata sınıfların yeni sürümlerle birlikte yeni metotlar eklemesidir. Farzedelim ki bir uygulama güvenlik gerekçeleriyle bir veri yapısına eklenen her bir elemanı ön kontrolden geçiriyor olsun. Bunu sağlamak için veri yapısını temsil eden sınıf kalıtılarak eleman ekleyen her metot geçersiz kılınıp, eleman eklemeden önce güvenlik kontrolünü yapan kod parçası çalıştırılabilir. Bu çocuk sınıf o an için çalışacaktır ancak ata sınıf bir sonraki sürümde eleman ekleyen yeni bir metot tanımlarsa çocuk sınıfta var olmayan bu metot güvenlik kontrolü yapmadan veri yapısına eleman eklemeye başlayacaktır. Bu anlatılan teorik bir problemden ibaret değildir, Hashtable ve Vector sınıfları Collections çatısına eklenirken bu şekilde birkaç güvenlik açığı oluşmuş ve sonradan fark edilerek düzeltilmiştir.

Bu iki problem de ata sınıftaki metotları geçersiz kılmaktan dolayı çıkmaktadır. Bu yüzden bir sınıfı kalıtıp metotlarını geçersiz kılmadan, sadece çocuk sınıfa eklemeler yaptığınızda daha güvenli olacağını düşünebilirsiniz. Bu şekilde yapılan bir kalıtım daha güvenli olsa da, tamamen risksiz değildir. Ata sınıf ileriki bir sürümde, sizin çocuk sınıfa eklediğiniz bir metodu aynı imza ama farklı bir dönüş türüyle tanımlarsa çocuk sınıf derleme hatası verecektir. Eğer dönüş türü de aynı olursa bu sefer çocuk sınıf ata sınıftaki metodu siz farkında olmadan geçersiz kılmış olacaktır, dolayısıyla yukarıda bahsedilen iki problem tekrar karşınıza çıkacaktır. Dahası, çocuk sınıfta var olan metodun ata sınıftaki yeni metodun sözleşmesini karşılayacağını bilemezsiniz, çünkü siz çocuk sınıfı yazarken sözleşme belirlenmemişti bile.

Neyse ki, burada anlatılan bütün problemlerden sakınmanın bir yolu var. Var olan bir sınıfı kalıtmak yerine, yeni yazdığınız sınıfın içine ihtiyacınız olan sınıfın referansını private bir alan olarak ekleyin. Bu teknik kompozisyon olarak bilinmektedir çünkü bir sınıf diğerinin bileşeni durumuna gelmektedir. Burada yeni yazdığınız sınıf, referans olarak eklediğiniz sınıfın metotlarını çağırarak oradan gelen sonuçları döndürecektir. Buna “iletme” de denir çünkü yeni sınıfta tanımlı metotlar gelen isteği referans sınıfa iletmektedir. Sonuç olarak ortaya çıkan sınıf sapasağlam ve içerdiği sınıfın gerçekleştirim detaylarından tamamen bağımsız olacaktır. Referans olarak eklenen sınıfa yeni metotlar eklense bile sizi etkilemeyecektir. Bunu somutlaştırmak için InstrumentedHashSet sınıfını komposizyon ve iletme uygulayarak tekrar yazalım. Burada dikkat edilmesi gereken nokta, sınıfın iki parçaya bölünmüş olmasıdır: sınıfın kendisi ve sadece iletim yapan yardımcı bir gömülü sınıf.

// Kalıtım yerine komposizyon kullanılan çözüm
public class InstrumentedSet<E> extends ForwardingSet<E> {
    private int addCount = 0;

    public InstrumentedSet(Set<E> s) {
        super(s);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

// Yeniden kullanılabilir iletim sınıfı
public class ForwardingSet<E> implements Set<E> {

    private final Set<E> s;
    public ForwardingSet(Set<E> s) {
        this.s = s;
    }

    public void clear() {
        s.clear();
    }
    public boolean contains(Object o) {
        return s.contains(o);
    }
    public boolean isEmpty() {
        return s.isEmpty();
    }
    public int size() {
        return s.size();
    }
    public Iterator<E> iterator() {
        return s.iterator();
    }
    public boolean add(E e) {
        return s.add(e);
    }
    public boolean remove(Object o) {
        return s.remove(o);
    }
    public boolean containsAll(Collection<?> c) {
        return s.containsAll(c);
    }
    public boolean addAll(Collection<? extends E> c) {
        return s.addAll(c);
    }
    public boolean removeAll(Collection<?> c) {
        return s.removeAll(c);
    }
    public boolean retainAll(Collection<?> c) {
        return s.retainAll(c);
    }
    public Object[] toArray() {
        return s.toArray();
    }
    public <T> T[] toArray(T[] a) {
        return s.toArray(a);
    }

    @Override
    public boolean equals(Object o) {
        return s.equals(o);
    }

    @Override
    public int hashCode() {
        return s.hashCode();
    }

    @Override
    public String toString() {
        return s.toString();
    }
}

Yukarıdaki sınıfı yazabilmemize olanak sağlayan şey HashSet tarafından da uygulanan Set arayüzünün varlığıdır. Sağlam olmasının yanında bu sınıf aynı zamanda çok da esnektir. InstrumentedSet sınıfı Set arayüzünü uygulamaktadır ve yine Set türünde parametresi olan bir yapıcı metot içermektedir. Aslında bu sınıfın yaptığı iş, bir Set nesnesini başka bir Set nesnesine dönüştürüp çeşitli eklemeler yapmaktan ibarettir. Tek bir somut sınıf ile kullanılabilen kalıtım tabanlı yaklaşımın aksine, bu çözüm daha esnektir çünkü Set arayüzünü gerçekleştiren herhangi bir sınıf ile kullanılabilir.

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));

Hatta daha önceden yaratılmış ve kullanılmış olan Set türünden bir nesneyi bile dönüştürüp yeni haliyle kullanmaya devam edebilirsiniz.

static void walk(Set<Dog> dogs) {
    InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
    ... // bu metot içerisinde dogs yerine iDogs kullanabilirsiniz
}

Bu örnekteki InstrumentedSet sınıfı sarmalayan (wrapper) sınıf olarak tanımlanabilir çünkü içerisinde başka bir Set nesnesi barındırmakta, başka bir deyişle sarmalamaktadır. Bu yaklaşım aynı zamanda bu dekoratör tasarım deseni olarak da adlandırılmaktadır çünkü bizim örneğimizde InstrumentedSet bir Set nesnesini alıp onu dekore etmektedir.

İletim sınıfı içerisindeki bütün iletim metotlarını yazmak biraz zahmetli olsa da bir kere yazıldıktan sonra yeniden kullanılabilir olması avantajlıdır. Örneğin, Guava kütüphanesi bütün collections arayüzleri için iletim sınıfları tanımlamaktadır.

Kalıtım kullanmak sadece çocuk sınıf ata sınıfın bir alt türü ise mantıklıdır. Eğer B sınıfının A sınıfını kalıtması gerektiğini düşünüyorsanız, bunu yapmadan önce mutlaka “B gerçekten bir A mı?” sorusunu sorun. Eğer bu soruya gerçekten evet cevabını veremiyorsanız o zaman B sınıfının A’yı kalıtması yanlış olacaktır.
Eğer cevap hayır ise, B sınıfı A türünde private bir referans tanımlamalı ve dışarıya daha küçük ve basit bir API sunmalıdır. Bu durumda A sınıfı B’nin gerekli bir parçası değil gerçekleştirim detayı olarak kalacaktır.

Java kütüphaneleri içerisinde bu kurala aykırı olan birçok sınıf vardır.
Mesela stack (yığın) bir vektör olmamasına rağmen Stack sınıfı Vector sınıfını kalıtmaktadır. Benzer olarak Properties sınıfının Hashtable sınıfını kalıtması da yanlış bir tercihtir ve bu iki durumda da kompozisyon kullanmak daha yararlı olurdu diyebiliriz.

Eğer kompozisyonun uygun olduğu yerde kalıtım kullanıyorsanız sınıfın gerçekleştirim detaylarını gereksiz bir biçimde dışarıya açıyorsunuz demektir. Sonuçta ortaya çıkan API sınıfın gerçekleştirimine bağımlı kalacak ve ileride değişiklik yapılmasını kısıtlayacaktır. Daha da önemlisi, istemcilerin gerçekleştirim detaylarına direk olarak erişebilmesine neden olursunuz, bu da en iyi ihtimalle istemcilerin sınıfınızı yanlış kullanmasına sebep olabilir. Örneğin, p referansı Properties türünden bir nesneyi gösteriyorsa, p.getProperty(key) ile p.get(key) farklı sonuçlar döndürebilir çünkü birinci metot varsayılan değerleri de hesaba katarken Hashtable sınıfından kalıtılan ikinci metot bunu yapmamaktadır. Kötü ihtimali düşünecek olursak da istemcilerin gerçekleştirim detaylarına erişebiliyor olması sizin sınıfınızı bozmalarına bile neden olabilir. Properties sınıfını tasarlayanlar aslında sadece String türünden anahtarlar ve değerler kullanılmasını istemişlerdir ancak kalıtım kullanılması ile arka taraftaki Hashtable metotlarına da ulaşabilen istemciler bu kuralı kolaylıkla ihlal edebilirler. Bu kural bir kere ihlal edildiğinde ise Properties sınıfının diğer kısımları (load, store gibi) kullanılamaz hale gelmektedir. Bu problem fark edildiğinde düzeltmek için çok geçti çünkü istemciler String olmayan anahtarlar ve değerler kullanmaya çoktan başlamışlardı.

Kalıtım kullanmaya karar vermeden önce kendinize sormanız gereken son soru da şudur: Kalıtmak istediğim sınıfta bir API hatası var mı? Var ise benim aynı hatayı kendi sınıfıma yansıtmam kabul edilebilir mi?
Kalıtım kullandığınızda ata sınıfın API’ını da kalıtmış oluyorsunuz yani hatalar varsa onlar da beraberinde gelecektir, ancak kompozisyon kullanırsanız diğer sınıfın bütün detaylarını saklayıp sıfırdan bir API oluşturabilirsiniz.

Özetleyecek olursak, kalıtım güçlüdür ancak sarmalamayı (encapsulation) ihlal ettiği için problemlidir. Sadece çocuk sınıf ile ata sınıf arasında bir tür-alt tür ilişkisi varsa kullanılması uygun olur. Ancak o zaman bile, eğer ata sınıf ile çocuk sınıf farklı paketlerde tanımlanmışsa ve ata sınıf kalıtılmak için tasarlanmamışsa yazılımda kırılganlığa yol açar. Bu kırılganlığı önlemek için kompozisyon ve iletim sınıfı kullanmayı tercih edin. Bu şekilde tasarlanan sınıflar hem daha esnek hem de daha sağlam olacaktır.

Share

Effective Java Madde 17: Değişebilirliği (Mutability) Kısıtlayın

Basit olarak tanımlamak gerekirse değişmez (immutable) sınıflar, nesneleri üzerinde değişiklik yapılamayan sınıflardır. Her bir nesne içinde tanımlı olan değerler, nesne yaratılırken belirlenir ve nesnenin ömrü boyunca aynı kalır. Java platformu içerisinde tanımlı String, Integer, Boolean, BigInteger, BigDecimal gibi birçok değişmez sınıf bulunmaktadır. Bunun sebebi değişmez sınıfların daha kolay tasarlanabilmesi, yazılabilmesi ve kullanılabilmesidir. Ayrıca, değişmez sınıflar hata yapmaya daha az olanak verir ve daha güvenlidirler.

Bir sınıfı değişmez yapmak için aşağıdaki 5 kuralı uygulamalısınız:

1. Nesnenin durumunu değiştiren hiçbir metot tanımlamayın.
2. Sınıfı kalıtılamaz hale getirin. Böylece yazdığınız sınıfın değişmezliği, kötü niyetli veya dikkatsizce yazılmış çocuk sınıflar tarafından etkilenmemiş olur. Bunu yapmak için genellikle sınıf tanımlanırken final anahtar kelimesi kullanılır, ancak başka bir yol daha var ve buna ileride değineceğiz.
3. Sınıftaki bütün alanları (field) final olarak tanımlayın. Böylece alanlara ilk değerlerini atadıktan sonra değiştirilmesi mümkün olmayacaktır.
4. Sınıftaki bütün alanları private olarak tanımlayın. Böylece sınıf içerisinde değişebilir nesnelere referanslar varsa, bu nesnelerin değiştirilmesini engellemiş olursunuz. Her ne kadar değişmez sınıflar içerisinde temel değerler taşıyan (başka nesnelere referans olmayan) public final alanlar tanımlamak mümkün olsa da, ileride sınıfın iç yapısının değiştirilmesini kısıtlayacağı için önerilmez. (Madde 15 ve Madde 16)
5. Değişebilir alanlara olan erişimi kısıtlayın. Eğer değişmez sınıf içerisinde değişebilir nesnelere referanslar varsa, istemcilerinizin bu referanslara erişemediğinden emin olun. Bu tür referansları istemciye döndürmeniz gerekiyorsa, gösterdiği nesnenin bir kopyasını oluşturup onu döndürün. Aynı şekilde, örneğin bir yapıcı metot aracılığıyla istemciden gelen bir referansı değişmez sınıf içerisinde kullanmanız gerekiyorsa, yine nesnenin bir kopyasını oluşturup onu kullanın. Böylece istemci nesnede değişiklik yapsa bile değişmez sınıftaki nesneyi değil kopyasını değiştirmiş olacaktır. (Madde 50, Madde 88)

Daha önceki maddelerde yazdığımız sınıfların birçoğu değişmezdir. Buna örnek olarak Madde 11’de tanımladığımız PhoneNumber sınıfı verilebilir. PhoneNumber sınıfında her bir alan için erişim metodu olsa da, değer değişikliği yapabilen hiçbir metot yazılmamıştır. Aşağıda daha karmaşık bir örnek verilmiştir:

public final class Complex {
    private final double re;
    private final double im;

    public Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    // erişim sağlayan (getter) metotlar
    public double realPart() {
        return re;
    }

    public double imaginaryPart() {
        return im;
    } 

    public Complex add(Complex c) {
        return new Complex(re + c.re, im + c.im);
    }

    public Complex subtract(Complex c) {
        return new Complex(re - c.re, im - c.im);
    }

    public Complex multiply(Complex c) {
        return new Complex(re * c.re - im * c.im, 
                           re * c.im + im * c.re);
    }

    public Complex divide(Complex c) {
        double tmp = c.re * c.re + c.im * c.im;
        return new Complex((re * c.re + im * c.im) / tmp, 
                           (im * c.re - re * c.im) / tmp);
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }

        if (!(o instanceof Complex)) {
            return false;
        }

        Complex c = (Complex) o;
        return Double.compare(re, c.re) == 0 && 
               Double.compare(im, c.im) == 0;
    }

    @Override
    public int hashCode() {
        return 31 * Double.hashCode(re) + Double.hashCode(im);
    }

    @Override
    public String toString() {
        return "(" + re + " + " + im + "i)";
    }
}

Yukarıdaki sınıf sanal (imaginery) ve gerçel (real) olmak üzere iki parçadan oluşan bir karmaşık sayıyı ifade etmektedir. Bu sınıf Object ata sınıfından gelen metotlar haricinde, sanal ve gerçel değerleri döndüren erişim metotları ve toplama, çıkarma, çarpma ve bölme işlemlerini yapan 4 farklı metot tanımlamaktadır. Aritmetik işlemleri yapan metotların, var olan nesneyi değiştirmek yerine her seferinde yeni bir nesne yaratıp döndürmesi dikkat edilmesi gereken bir noktadır. Bu yöntem birçok değişmez sınıf içerisinde kullanılmaktadır ve fonksiyonel yaklaşım olarak bilinmektedir, çünkü metotlar değer değişikliği yapmadan bir fonksiyonun sonucunu direk olarak istemciye döndürmektedirler. Bunun tersi olan prosedürel yaklaşımda ise metotlar, gelen parametreler üzerinde değişiklik yaparak durumlarını değişmesine neden olurlar.

Eğer alışık değilseniz fonksiyonel yaklaşım ilk başta biraz garip görünebilir, ancak sınıflarda değişmezliği sağladığı için çok avantajlıdır. Değişmez nesneler çok basittir ve durumları yaratıldıktan sonra değiştirilemez, dolayısıyla sonradan nesne üzerinde değişiklik yapılıp yapılmadığıyla ilgilenmek zorunda kalmazsınız. Diğer taraftan değişebilir nesneler ise herhangi bir zamanda herhangi bir durumda bulunabilirler. Eğer sınıfta değişime neden olan metotlar nesnenin durumları arasındaki geçişleri iyi belgelemezse, bu nesneleri kullanmak güvenli bir biçimde kullanmak çok zorlaşır.

Değişmez nesneler doğası gereği thread güvenlidir (thread safe), senkronizasyon gerektirmezler. Bir başka deyişle birden fazla thread (iş parçacığı) aynı nesne üzerinde güvenli bir biçimde işlem yapabilir. Değişmez nesneler kullanmak thread güvenliğini sağlamak için açık ara en kolay yöntemdir. Nesne üzerinde değişiklik yapılamadığı için aynı nesne birçok thread arasında güvenli bir biçimde paylaşılabilir. Değişmez sınıflar bu özelliklerini kullanmaları için istemcileri teşvik etmelidirler. Bunu yapmanın bir yolu, sık kullanılan nesneleri public static final olarak aynı sınıf içerisinde tanımlamaktır. Örneğin, yukarıda tanımladığımız Complex sınıfı aşağıdaki nesneleri de içerisinde barındırabilir.

public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);

Bu yaklaşım bir adım daha ileriye götürülebilir. Değişmez sınıf statik fabrika metotları (Madde 1) tanımlayarak sık kullanılan nesneleri tekrar tekrar yaratmak yerine bir kere yaratıp her istemciye aynı nesneyi döndürebilir. Java’da Integer, Boolean gibi temel türleri temsil eden sınıflar (boxed primitive types) ve BigInteger sınıfı bunu yapmaktadır. Bu yöntemi kullanmak gereksiz nesne yaratılmasını önlediği için bellek kullanımını azaltır ve çöp toplayıcının (garbage collector) işini kolaylaştırır. Bir sınıfı tasarlarken public yapıcı metotlar (constructor) yerine statik fabrika metotları kullanmak, daha sonra istemcileri hiç etkilemeden önbellek (cache) eklemenize de olanak sağlar.

A consequence of the fact that immutable objects can be shared freely is that you never have to make defensive copies of them (Item 50). In fact, you never have to make any copies at all because the copies would be forever equivalent to the originals. Therefore, you need not and should not provide a clone method or copy constructor (Item 13) on an immutable class. This was not well understood in the early days of the Java platform, so the String class does have a copy constructor, but it should rarely, if ever, be used (Item 6).

Değişmez nesnelerin bir başka avantajı da kopyalamaya asla gerek kalmamasıdır. (Madde 50) Sonuç olarak kopyalanan nesne de orijinalinin aynısı olacak ve üzerinde değişiklik yapılamayacaktır. Bu sebeple, değişmez bir sınıf içerisinde clone metodu veya kopya yapıcı metot (copy constructor) tanımlamaya gerek yoktur (Madde 13). Bu durum Java’nın ilk zamanlarında çok iyi anlaşılmamış ve String sınıfı içerisinde bir kopya yapıcı metot tanımlanmıştır, bu yapıcı metodun kullanılması tavsiye edilmez. (Madde 6)

Değişmez nesneler başka nesnelerin oluşturulmasında yapı taşı görevi görürler.
Kendisi değişmez olsa da olmasa da, içerisinde değişmez bir nesne bulunduran karmaşık bir nesnenin durumu (state), değişebilir bir nesne bulunduran bir nesnenin durumuna göre çok daha tutarlı olacaktır. Örneğin, değişmez nesneler Map yapılarında harika bir anahtar görevi görürler ve Set yapılarına eleman olarak güvenle eklenebilirler. Bu değerler çalışma zamanında değişmeyeceği için, veri yapısına eklendikten sonra değer değişikliğinden kaynaklı bozulmalar yaşanmayacaktır.

Değişmez sınıfların tek dezavantajı, her bir değer için farklı bir nesne oluşturulması gerekmesidir. Bu yüzden büyük nesneler söz konusu ise değişmez nesneler kullanmak pahalı olabilir. Örneğin 1 milyon bitten oluşan bir BigInteger nesnesi üzerinde tek bir biti değiştirmek istediğimizi düşünelim:

BigInteger moby = ...;
moby = moby.flipBit(0);

2. satırdaki flipBit() metodu yine 1 milyon bit uzunluğunda başka bir BigInteger nesnesi yaratacaktır ve aradaki tek fark 1 bittir. BigSet sınıfı ise, BigInteger gibi istediğiniz uzunlukta bir bit kümesi tanımlamanızı sağlar ancak değişebilirdir. Bu sınıf, bit değiştirme işlemlerini aynı nesne üzerinde yaptığı için yukarıdaki aynı işlemi çok daha çabuk gerçekleştirir.

BitSet moby = ...;
moby.flip(0);

Bu performans problemi, her biri yeni bir nesne yaratılmasına neden olan birkaç farklı işlemi art arda yapıyorsanız daha da belirgin olacaktır. Çünkü en son yaptığınız işlem haricinde yaratılan bütün nesneler geçersiz kalacaktır. Bu durumla başa çıkmanın en pratik yolu değişebilir bir yardımcı sınıf yazmaktır. Bunun en güzel örneği Java platformu içerisinde bulunan String ve StringBuilder sınıflarıdır. String değişmez bir sınıf olarak tasarlanmış, ancak birden çok adımda oluşturulan String nesneleri performans kaybına yol açmasın diye değişebilir StringBuilder sınıfı da yazılımcılara sunulmuştur.

Artık bir sınıfı nasıl değişmez yapabileceğinizi ve bu yaklaşımın iyi ve kötü taraflarını öğrendiğinize göre, birkaç değişik tasarım üzerinde konuşabiliriz. Değişmezliği sağlayabilmek için sınıfın kalıtılmasını engellemek gerektiğini hatırlayın. Genel olarak bu sınıfı final tanımlayarak yapılabilir ancak ikinci ve daha esnek bir yol daha var. Sınıfı final tanımlamak yerine bütün yapıcı metotları private veya package private yazabilir, public yapıcı metotlar yerine de public statik fabrika metotları tanımlayabiliriz. (Madde 1)

Bu durumu örneklemek için aşağıdaki kodu verebiliriz:

// Yapıcı metot yerine statik fabrika metotu kullanılmış değişmez (immutable) sınıf
public class Complex {
    private final double re;
    private final double im;

    private Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    public static Complex valueOf(double re, double im) {
        return new Complex(re, im);
    }

    ... // Sınıfın geri kalanında değişiklik yok..
}

Bu yaklaşım çok sık kullanılmasa da aslında çoğu zaman en iyi seçenektir. Aynı zamanda esnektir çünkü yapıcı metodu package private tanımlarsanız, kendiniz paket içerisinde sınıfı kalıtabilir ancak istemcilerinizin kalıtmasını engelleyebilirsiniz. Paket dışında kalan istemciler açısından bakıldığında bu sınıf final tanımlanmış gibidir, çünkü başka bir paketten gelen sınıfı eğer public veya protected bir yapıcı metodu yoksa kalıtmak mümkün değildir. Bu avantajın yanında, bu yaklaşım sonraki versiyonlarda statik fabrikanın geliştirilerek, istemci koduna dokunmadan önbellek (cache) eklenebilmesine de olanak sağlar.

Statik fabrika metotları Madde 1’de anlatıldığı üzere yapıcı metotlara göre birçok fayda sağlarlar. Örneğin yukarıdaki Complex sınıfına, karmaşık sayıları kutup koordinatları kullanarak yaratma özelliğini eklemek istediğimizi düşünelim. Bunu yapıcı metot kullanarak yapmak işleri çok karıştıracaktır çünkü kullanacağımız yapıcı metot imzası sınıfta zaten var olan yapıcı metotla çakışacaktır: Complex(double, double). Statik fabrika kullandığımızda ise çok kolaydır, tek yapmamız gereken farklı bir isimle ikinci bir metot eklemek olacaktır.

public static Complex valueOfPolar(double r, double theta) {
    return new Complex(r * Math.cos(theta), r * Math.sin(theta));
}

Değişmez sınıfların kalıtılmaması kuralı malesef BigInteger ve BigDecimal sınıfları yazılırken ihmal edilmiştir ve bugün bu sınıfların bütün metotları kalıtılabilir durumdadır. Bu durumun düzeltilmesi geriye uyumluluğu (backwards compatibility) bozacağı için mümkün değildir. Dolayısıyla, güvenmediğiniz istemcilerden gelen BigInteger veya BigDecimal türünden argümanları, eğer değişmezlik kriteri sizin için önemliyse gerçekten BigInteger veya BigDecimal türünden mi yoksa kalıtılmış bir tür mü olduğunu kontrol etmelisiniz. Eğer argüman kalıtılmış bir tür ise kendinize ata sınıftan yeni bir kopya oluşturmalısınız.

public static BigInteger safeInstance(BigInteger val) {
    if (val.getClass() != BigInteger.class)
        return new BigInteger(val.toByteArray());
    return val;

Yukarıda bahsettiğimiz değişmezlik kurallarında sınıfın durum değişikliği yapan metot bulundurmaması ve bütün alanların final olması gerektiğinden bahsetmiştik. Bu kurallar performası artışı sağlayabileceğimiz yerlerde esnetilebilir. Değişmez sınıf, hiçbir koşulda istemcilerin görebileceği bir değişikliğe izin vermemelidir, ancak kendi içerisinde performansı artırmak için belli hesaplamalar yapıp saklayabilir. Aynı değer tekrar lazım olduğunda tekrar hesaplamak yerine sakladığı değeri döndürülebilir. Bu tekniğin işe yaramasının sebebi sınıfın değişmez olmasıdır, çünkü aynı hesaplama tekrar tekrar yapılsa bile her seferinde aynı sonucu üretecektir.

Örneğin Madde 11’da yazdığımız PhoneNumber sınıfının hashCode() metodu, ilk çağrıldığında hash kodunu hesaplamakta ve tekrar çağrıldığında daha önce hesapladığı değeri döndürmektedir. Bu teknik String sınıfı tarafından da kullanılmaktadır. (Madde 83)

Serileştirme ile ilgili bir uyarıdan da bahsedelim. Eğer değişmez sınıf değişebilir bir nesneye referans içeriyorsa, Serializable arayüzünü gerçekleştirirken bir readObject veya readResolve metodu tanımlamanız, veya ObjectOutputStream.writeUnshared ve ObjectInputStream.readUnshared metotlarını kullanmanız gerekmektedir. Aksi taktirde bir saldırgan sınıfınızdan değişebilir bir nesne yaratabilir. Bu konu Madde 88’de detaylı ele alınmaktadır.

Özetlemek gerekirse, alanların değerlerini değiştiren metotları mecbur değilseniz yazmayın. (setter metotlar) Sınıflar, değişebilir olması için mantıklı bir sebep yoksa değişmez yazılmalıdır. Değişmez sınıflar birçok yönden avantaj sağlarlar ve tek kötü tarafları bazı şartlar altında performans kaybına sebebiyet vermeleridir. Birkaç değer taşımaktan ibaret olan PhoneNumber ve Complex gibi sınıfları her zaman değişmez yapmalısınız. (Java platformu içerisinde java.util.Date ve java.awt.Point gibi değişmez olması gereken ama olmayan sınıflar vardır.) Büyük veriler taşıyabilen String ve BigInteger gibi sınıfları değişmez yapıp yapmama konusunda dikkatli karar vermelisiniz. Eğer performans kaybını engellemek için gerçekten gerekliyse, ek olarak bir de yardımcı değişebilir sınıf yazmalısınız. (Madde 67)

Bazı sınıfları değişmez yapmak pratik olarak mümkün olmayabilir. Böyle durumlarda değişebilirliği mümkün olduğunca kısıtlamalısınız. Dolayısıyla, nesnelerin alanlarını aksi bir sebep yoksa final tanımlamalısınız.

Bunun dışında, yapıcı metotların nesneleri bütünüyle yaratması gerekmektedir. Yapıcı metot veya statik fabrika dışında, nesneye ilk değer atamaları yapan başka bir metot daha eklemeyin. Aynı şekilde nesneleri yeniden kullanabilmek için içindeki değerleri sıfırlayıp sanki sıfırdan üretiliyormuş gibi davranan metotlar da yazmayın. Bu tarz metotlar çok az bir performans katkısı verip karmaşıklığı çok artırmaktadırlar.

CountDownLatch sınıfı bu prensipleri başarıyla uygulamaktadır. Değişebilir bir sınıf olmasına rağmen, durum uzayı kasten küçük tutulmuştur. Nesne bir kere yaratılır, kullanılır ve işe yaramaz hale gelir. Değeri 0’a ulaştığında tekrar aynı nesneyi kullanmak mümkün değildir.

Son olarak, yukarıda yazdığımız Complex sınıfı sadece değişmezliği örneklemek için kabataslak yazılmıştır. Gerçek uygulamalarda kullanılabilecek sağlamlıkta ve güvenilebilirlikte değildir.

Share

Effective Java Madde 14: public sınıflarda erişim metotları kullanın, public alanlar değil

Zaman zaman birkaç tane alanı bir arada tutmaktan başka bir iş yapmayan sınıflar yazma eğiliminde olabilirsiniz:

// Bunun gibi sınıflar public olmamalıdır!
class Point {
    public double x;
    public double y;
}

Bu tür sınıfların veri alanları direk erişime açık olduğu için, kapsülleme prensibinin (encapsulation) faydalarını kullanamazlar. UPA’yı (API) değiştirmeden kod içerisinde değişiklik yapmak zorlaşır, sabit değerler tanımlayamazsınız ve bir alana erişildiğinde müdahale etme şansınız olmaz. Katı görüşlü programcılar bu tür sınıfları lanetli olarak görürler ve bunların yerine private alanları ve public erişim metotları olan sınıflar yazılması gerektiğini savunurlar.

Read more “Effective Java Madde 14: public sınıflarda erişim metotları kullanın, public alanlar değil”

Share

Effective Java Madde 13: Sınıfların ve Üyelerinin Erişilebilirliğini Kısıtlayın

İyi tasarlanmış bir yazılım modülünü kötü tasarlanmış bir modülden ayıran en önemli faktör, içindeki verileri ve gerçekleştirim (implementation) detaylarını diğer modüllerden ne kadar iyi saklayabildiğidir. İyi tasarlanmış bir modül, gerçekleştirim detaylarını net bir biçimde diğer modüllerden gizler. Modüller birbirleriyle tanımladıkları API (Uygulama Programlama Arabirimi, UPA) üzerinden konuşurlar ve birbirlerinin iç dünyasından habersiz olmaları gerekir. Bu konsept, çok temel bir yazılım tasarım ilkesidir ve bilgi saklama yada kapsülleme (encapsulation) olarak bilinir.

Bilgi saklama konsepti birçok açıdan önemlidir. En önemli faydası ise yazılım modüllerini birbirinden ayrıştırarak (decoupling) birbirinden bağımsız bir şekilde geliştirilebilmesini, test edilebilmesini ve kullanılabilmesini sağlamaktır. Bu durum, bir sistemin geliştirilmesini hızlandırır çünkü farklı modüller paralel bir biçimde aynı anda geliştirilebilir. Ayrıca bakım yapmayı ve modülün anlaşılmasını kolaylaştırır, bir problem olduğunda diğer modülleri bozma riski olmadan hata araştırılıp, çeşitli çözümler denenebilir. Bilgi saklama kendi başına bir sistemin yüksek performansla çalışmasını sağlamasa da, birbirinden bağımsız modüllerin diğerlerini etkilemeden optimize edilebilmesini sağlar. Dolayısıyla sistemi test ederek hangi modülün yavaş çalıştığını tespit edebilir, sistemin diğer parçalarına zarar vermeden yavaş çalışan modülü optimize edebilirsiniz. Birbirinden bağımsız modüller aynı zamanda tekrar kullanılabilirliği (reusability) yüksek olan modüllerdir. Bilgi saklama tekniği büyük sistemler geliştirmenin riskini önemli ölçüde azaltır, çünkü sistem bir bütün olarak doğru çalışmasa da bağımsız modüllerin doğru çalıştığı kanıtlanabilir.

Read more “Effective Java Madde 13: Sınıfların ve Üyelerinin Erişilebilirliğini Kısıtlayın”

Share

Effective Java Madde 12: Comparable Arayüzünü Gerektiğinde Uygulayın

Bu bölümde gördüğümüz diğer metotların aksine, compareTo() metodu Object içerisinde tanımlanmış değildir. Bunun yerine Comparable arayüzünün tek metodu olarak karşımıza çıkmaktadır. Karakter olarak Object sınıfındaki equals() metoduna benzer ancak eşitlik karşılaştırması yanında sıralama da yapabilir ve üreyseldir (generic). Comparable arayüzünü uygulayarak, sınıfınızın nesneleri arasında sıralama yapılabileceğini belirtmiş olursunuz. Bu arayüzü uygulayan nesnelerden oluşan bir diziyi (array) sıralamak aşağıdaki gibi son derece kolaydır:

Arrays.sort(a);

Benzer şekilde, Comparable arayüzü uygulandığında arama yapma, uç değerleri bulma, kendiliğinden sıralı veri yapılarını yönetme gibi problemler de çok kolaylaşır. Örneğin, aşağıdaki program komut satırından girilen parametreleri alfabetik sırada yazdırmakta ve tekrar edilen değerleri elemektedir. Bu kodun doğru çalışması String sınıfının Comparable arayüzünü uygulamasıyla mümkün olmaktadır.

public class WordList {
        public static void main(String[] args) {
        Set<String> s = new TreeSet<String>();
        Collections.addAll(s, args);
        System.out.println(s);
    }
}

Read more “Effective Java Madde 12: Comparable Arayüzünü Gerektiğinde Uygulayın”

Share

Effective Java Madde 10: toString() Metodunu Her Zaman Geçersiz Kılın

Her ne kadar java.lang.Object bir toString() gerçekleştirimi sunsa da, geri döndürdüğü karakter dizisi genellikle sizin sınıfınızı kullanmak isteyen bir yazılımcının görmek istediği şey değildir. Bu değer sınıfın adı, ardından gelen ‘@’ karakteri ve nesnenin hash kodunun onaltılık sistemde (hexadecimal) ifadesini içermektedir, örneğin “PhoneNumber@163b91”. toString sözleşmesi, bu metottan döndürülen değerin “kolay okunup anlaşılabilen, kısa ama bilgi verici” olması gerektiğini söyler. “PhoneNumber@163b91” değerinin kısa ve okuması kolay olduğunu savunabilirsiniz ancak “(707) 867-5309” ile karşılaştırıldığında bilgi verici olmadığı açıktır. toString sözleşmesi bunların haricinde “Bu metodu bütün alt sınıfların geçersiz kılması tavsiye edilir” demektedir. Kesinlikle uyulması gereken bir tavsiye!

Her ne kadar equals (Madde 8) ve hashCode (Madde 9) sözleşmeleri kadar kritik olmasa da, güzel bir toString gerçekleştirimi sağlamak sınıfınızı çok daha kolay kullanılabilir hale getirir. toString metodu, bir nesne printf, println gibi metotlara parametre olarak geçildiğinde, karakter dizilerine eklendiğinde veya assert ile birlikte kullanıldığında otomatik olarak işletilir.

Read more “Effective Java Madde 10: toString() Metodunu Her Zaman Geçersiz Kılın”

Share

Effective Java Madde 11: equals() ile Birlikte Mutlaka hashCode() Metodunu da Geçersiz Kılın

Önemli Not: Hash tabanlı veri yapılarının Java’da nasıl çalıştığını bilmiyorsanız bu yazıyı okumadan önce araştırmanızı şiddetle tavsiye ederim.

Object sınıfından gelen hashCode metodunu gerektiği yerde geçersiz kılmamak (override) birçok hatanın kaynağını oluşturur. equals metodunu geçersiz kıldığınız her sınıfta hashCode metodunu da geçersiz kılmanız gerekir. Bunu yapmamak Object.hashCode metodunun sözleşmesini ihlal etmek demektir, bu da sınıfınızın hash tabanlı HashMap, HashSet, HashTable gibi veri yapılarıyla birlikte kullandığında yanlış çalışmasına yol açar. Bunun sebebi ise Object sınıfından gelen hashCode metodunun hash kodunu hesaplarken nesnenin o anda bulunduğu bellek adresini kullanmasıdır. Her nesnenin bellek adresi farklı olacağı için hesaplanan hash kodu da farklı olacaktır. Object sınıfı belirtiminde tarif edildiği üzere sözleşme genel olarak şu şekildedir:

  • equals karşılaştırmasında kullanılan alanlar sabit kaldığı sürece, hashCode metodu aynı uygulama içerisinde üst üste çağrıldığında her zaman aynı sonucu üretmelidir.
  • Eğer iki nesne equals metoduna göre birbirine eşitse, bu iki nesnenin hashCode metotları da aynı integer değerini üretmelidir.
  • Eğer iki nesne equals metoduna göre eşit değilse, hashCode metodu bu iki nesne için farklı integer sonuçları üretmek zorunda değildir. Ancak yazılımcı bilmelidir ki eşit olmayan nesneler için farklı hash kodları üretmek hash table performansını artırabilir.

equals ile birlikte hashCode metodunu geçersiz kılmadığınız zaman 2. koşulu ihlal etmiş olursunuz: eşit nesneler eşit hash kodu üretmelidir. Bu durumda birbirinden farklı iki nesne equals metoduna göre eşit olsa da, hashCode metoduna göre aralarında pek de bir benzerlik olmayacaktır. Bu yüzden de hashCode metodu sözleşmenin aksine bu iki eşit nesne için çok farklı değerler üretecektir.

Aşağıda equals metodu sözleşmeye uygun bir biçimde yazılmış (Madde 10) PhoneNumber sınıfını düşünecek olursak:

 public final class PhoneNumber {

    private final short areaCode;
    private final short prefix;
    private final short lineNumber;

    public PhoneNumber(int areaCode, int prefix, int lineNumber) {
        rangeCheck(areaCode,    999, "area code");
        rangeCheck(prefix,      999, "prefix");
        rangeCheck(lineNumber, 9999, "line number");

        this.areaCode  = (short) areaCode;
        this.prefix  = (short) prefix;
        this.lineNumber = (short) lineNumber;
    }

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

    @Override
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof PhoneNumber))
            return false;

        PhoneNumber pn = (PhoneNumber)o;
        return pn.lineNumber == lineNumber 
            &amp;&amp; pn.prefix  == prefix 
            &amp;&amp; pn.areaCode == areaCode;
    }

    // Bozuk - hashCode() metodu yok!
}

Şimdi bu sınıfı bir HashMap ile kullandığımızı düşünelim:

Map<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(707, 867, 5309), "Jenny");

Bu noktada m.get(new PhoneNumber(707, 867, 5309)) çağrısının “Jenny” değerini döndürmesini bekleyebilirsiniz ancak null dönecektir. Burada iki PhoneNumber nesnesinin olduğuna dikkat edin, bir tanesi HashMap içerisine eklenirken ikinci ve eşit olan nesne HashMap üzerinden değeri geri almak için kullanılıyor. PhoneNumber sınıfının hashCode metodunu geçersiz kılmaması, eşit iki nesnenin farklı hash kodları üretmesine neden olmaktadır. Bu yüzden de get() metodu, aradığımız değeri put() metodunun kullandığı hash bölmesinde (bucket) değil başka bir bölmede arayacaktır ve bulamayacaktır. Tesadüfen bu iki nesnenin hash kodları aynı bölmelere denk gelse bile yine de null değeri dönecektir çünkü Java’da hash tabanlı veri yapıları hash kodları farklı olan nesnelerin eşit olmadığını varsayar ve equals metoduna bakmadan null döndürürler.

Bu problemi çözmek için PhoneNumber sınıfı içerisine uygun bir hashCode metodu yazmak gereklidir. Peki hashCode metodu nasıl olmalıdır? Sözleşmeye uyan ama doğru olmayan bir hashCode metodu yazmak çok basittir. Aşağıdaki örnek hashCode sözleşmesine uysa da asla kullanılmamalıdır:

// Sözleşmeye uyarak yazılabilecek en kötü hashCode(), asla kullanmayın!
@Override
public int hashCode() {
    return 42;
}

Yukarıdaki metod hashCode sözleşmesine uygundur çünkü eşit nesneler için aynı değeri üretecektir. Ancak yine de çok kötü yazılmıştır çünkü sadece eşit nesneler için değil bütün nesneler için aynı değeri üretecektir. Bu yüzden de hash tabanlı veri yapılarıyla birlikte kullanıldığında bütün nesneler aynı hash bölmesine (bucket) eklenecek ve örneğin bir HashTable bağlı liste (linked list) gibi çalışacaktır. Bu da erişim performansını çok büyük oranda olumsuz etkileyecektir. Büyük veri yapıları için bu performans farkı hiç çalışmamakla eşdeğerdir.

İyi bir hashCode metodu eşit olmayan nesneler için büyük oranda farklı değerler üretmelidir. hashCode sözleşmesinde bulunan 3. madde ile anlatılmak istenen tam olarak budur. İdeal bir hash fonksiyonu, makul sayıdaki eşit olmayan bir nesne yığınını, mümkün olan bütün hash kod değerleri üzerinde eşit olarak dağıtabilmelidir. Bu ideal fonksiyonu yazabilmek çok zor olabilir ama yaklaşmak mümkündür. İşte sizlere bir yöntem:

  1. result adında bir int değişken tanımlayın ve ilk değer olarak sınıftaki ilk anlamlı alanın hash kodunu 2.a adımında belirdiği gibi hesaplayarak atayın. (Madde 10’dan hatırlayacağınız üzere, anlamlı alanlar equals hesaplamasında kullanılan, mantıksal eşitlik hesabında kullanılan alanlardır.)
  2. Geriye kalan her anlamlı f alanı için, aşağıdakileri yapın
    1. Bu alan için değeri c olacak şekilde hash kodunu hesaplayın
      1. Eğer anlamlı alan bir ilkel tür ise, IlkelTur.hashCode(f) kullanın, burada tür anlamlı alanın ilkel türüne göre değişecektir, örneğin Short.hashCode(f), Integer.hashCode(f)
      2. Alan bir nesne referansı ise ve sınıfın equals metodu bu alanı karşılaştırırken özyineli olarak referans üzerinden equals metodunu çağırıyorsa, siz de referans nesnenin hashCode metodunu çağırın. Eğer referans null ise 0 değerini döndürün.
      3. Eğer anlamlı alan bir dizi ise, dizinin her elemanı ayrı bir alanmış gibi hesaplama yapın. Yani, dizideki her anlamlı alan için yukarıdaki kuralları uygulayın ve 2.b’de anlatıldığı gibi bunları birleştirin. Eğer dizide anlamlı eleman yoksa 0 haricinde herhangi bir sabit değer, bütün alanları anlamlı ise, Arrays.hashCode() metotlarından birini kullananın.
    2. Yukarıda hesaplanan c hash kodunu, aşağıdaki formülü kullanarak result değişkenine ekleyin
      result = 31 * result + c;
  3. result değerini döndürün.

Yazmayı tamamladığınızda kendinize şu soruyu sorun: Eşit nesneler aynı hash kodunu üretiyor mu? Bunu doğrulamak için birim testler yazın. AutoValue kütüphanesini kullanarak equals ve hashCode metotlarını otomatik ürettiyseniz bu testlere gerek kalmayabilir. Eğer eşit nesneler farklı hash kodları üretiyorsa, problemi bulun ve giderin.

Eşitlik hesaplamasında kullanmadığınız anlamlı alanları hash kod hesabından da mutlaka çıkartmalısınız, aksi taktirde birbirine eşit nesnelerin farklı hash kodları üretmesine sebep olabilirsiniz. Değeri başka anlamlı alanlar kullanılarak hesaplanabilen alanları da isterseniz hash kod hesaplamasından çıkartabilirsiniz.

2.b’de yapılan çarpma işlemi ise anlamlı alanları sırasının da üretilen hash koduna etki etmesini sağlar, bu da eğer sınıf içerisinde çok sayıda benzer alan varsa hash metodunun çok daha iyi çalışmasını sağlar. Örneğin, bu çarpma işlemi String sınıfının hashCode metodunda olmasaydı, bütün anagramlar aynı hash koduna sahip olurlardı. Çarpma işleminde 31 sayısının kullanılmasının sebebi ise asal bir tek sayı olmasıdır. Çift bir sayı seçersek ve çarpma işleminin sonucu maksimum saklayabileceğimiz değeri geçerse veri kaybetmiş oluruz, çünkü sayıyı 2 ile çarpmak bitlerini kaydırmakla eşdeğerdir. Burada asal bir sayı kullanmanın avantajı çok belirgin olmasa da, geleneksel olarak asal tek sayılar kullanılır. 31 sayısının güzel bir özelliği ise, daha iyi performans alabilmek için kaydırma ve çıkarma işlemleri ile ifade edilebilmesidir: 31 * i == (i << 5) - i Bu dönüştürme işlemi çoğu zaman sanal makina tarafından otomatik olarak yapılmaktadır.

Şimdi yukarıdaki tarifi PhoneNumber sınıfına uygulayalım. Bu sınıfta 3 adet anlamlı alan var ve hepsi de short türünde:

@Override 
public int hashCode() {
    int result = Short.hashCode(areaCode);
    result = 31 * result + Short.hashCode(prefix);
    result = 31 * result + Short.hashCode(lineNum);
    return result;
}

Bu metod 3 anlamlı alanı da hesaba katarak basit bir hesaplama ile hash kodu ürettiği için, birbirine eşit PhoneNumber nesnelerinin aynı hash kodunu üreteceği de gayet açıktır. Aslına bakarsanız bu metod PhoneNumber sınıfı için harika bir hashCode metodudur. Basit, hızlı ve eşit olmayan nesneleri farklı hash bölmelerine atamak konusunda gayet başarılı bir iş çıkarmaktadır.

Bu yazıda tarif edilen yöntemle, çoğu durumda yeterli olacak çok iyi hash fonksiyonları yazılabilir, ancak her zaman kusursuz sonuçlar alamayabilirsiniz. Daha üst düzeyde hash fonksiyonları üretebilmek için Guava kütüphanesinin com.google.common.hash.Hashing sınıfına bakabilirsiniz.

Objects sınıfı, istediğiniz sayıda nesneyi geçip bir hash kodu üretebileceğiniz, statik Objects.hash() metodunu barındırmaktadır. Bu metodu kullanarak, tek satırdan oluşan ve bu yazıdaki önerileri uygulayan bir hashCode metodunu kolayca yazabilirsiniz. Dezavantajı ise, geçilen argümanları diziye çevirdiği için ve ilkel türler kullanıldığında boxing ve unboxing yaptığı için biraz daha yavaş çalışmasıdır. Eğer performans uygulamanız için çok kritik değilse bu yolu deneyebilirsiniz. Aşağıda, PhoneNumber sınıfı için bu yöntemle yazılmış bir hash metodu görebilirsiniz:

// Tek satırlık hashCode metodu, vasat performans
@Override 
public int hashCode() {
    return Objects.hash(lineNum, prefix, areaCode);
} 

Eğer bir sınıf değişmez (immutable) ise ve hash kodunu hesaplamak zaman alıyorsa, hash kodunu nesnenin içerisinde saklayıp her defasında hesaplamadan kolayca döndürebilirsiniz. Eğer bu türdeki nesnelerin hash anahtarı olarak kullanılacağını öngörüyorsanız, nesne yaratıldığında hash kodunu hesaplayın, aksi taktirde hashCode metodu ilk defa çağrıldığında hesaplayın. PhoneNumber sınıfı için bu çok gerekli değildir ancak nasıl yapılacağını göstermek için aşağıdaki kodu yazabiliriz:

// Tembel ilklendirme (lazy initialization), ön bellekte saklanan hashCode
private int hashCode;  // ilk değer 0 olacaktır

@Override
public int hashCode() {
    int result = hashCode;
    if (result == 0) {
        result = 17;
        result = 31 * result + areaCode;
        result = 31 * result + prefix;
        result = 31 * result + lineNumber;
        hashCode = result;
    }
    return result;
}

Performansı artırmak için sınıfınızdaki anlamlı alanları hash kod hesaplamasından çıkarmaya çalışmayın. Hash fonksiyonunun daha hızlı çalışmasını sağlayabilirsiniz ancak hash tabanlı veri yapılarınız kullanılamayacak kadar yavaş çalışabilir. Hash fonksiyonu, sizin ihmal ettiğiniz alanlarda farklılaşan nesnelerle karşı karşıya kalırsa, bütün bu nesneleri çok küçük bir hash kod kümesine eşleştirebilir ve bu da hash tabanlı veri yapılarının performansını kötü etkiler. Bu, teoride kalmış bir problem değildir. Java 1.2 öncesinde yazılmış olan String sınıfının hashCode metodu, ilk karakterden başlayıp eşit aralıklarla devam ederek en fazla 16 karakteri dikkate alıyordu. Bu hesaplama yöntemi URL gibi hiyerarşik yapıya sahip karakter dizilerinin için kullanıldığında tam olarak burada anlatılan probleme yol açmıştır.

Özet olarak, equals metodunu geçersiz kıldığınız her durumda hashCode metodunu da geçersiz kılmanız şarttır. Bunu yapmadığınız taktirde uygulamanız yanlış çalışacaktır. Bunu yaparken de, yukarıda anlatıldığı gibi belirtilen kurallara uymanız gerekmektedir. Ancak bunu kendimiz yazmaktansa, Madde 10‘da belirtildiği üzere AutoValue kütüphanesini veya IDE’lerin otomatik kod üretme özelliklerini kullanmak, iyi bir alternatif olacaktır.

Share

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