Press "Enter" to skip to content

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

Last updated on September 22, 2019

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

One Comment

Leave a Reply

%d bloggers like this: