Effective Java Madde 20: Arayüzleri Soyut Sınıflara Tercih Edin

Java dilinde birden fazla gerçekleştirime (implementation) izin veren türleri tanımlamak için iki mekanizma bulunmaktadır: arayüzler ve soyut sınıflar (abstract class). Java 8 öncesinde arayüzlerde sadece metot imzalarını tanımlayabilirken, Java 8 ile eklenen varsayılan metotlar (default method) sayesinde, arayüzler içerisinde de soyut sınıflarda olduğu gibi somut metotlar yazabilir hale geldik. Ancak bu ikisi arasında önemli bir fark var, soyut sınıfı bir ”tür” olarak kullanmak istediğinizde onu bir çocuk sınıf yaratıp kalıtmanız gerekir. Java’da sadece tekli kalıtıma izin verilmesi (single inheritance), soyut sınıfların ”tür” tanımı olarak kullanılması noktasında ciddi kısıtlara yol açar. Bir arayüzü uygulamak içinse, sınıfın arayüzdeki metotları tanımlaması ve arayüzün sözleşmesine uyması yeterlidir. Burada arayüzü uygulayan sınıfın, sınıf hiyerarşisinde nerede bulunduğunun bir önemi yoktur.

Mevcut sınıflara uyarlama yapılarak yeni bir arayüzü uygulaması kolaylıkla sağlanabilir. Bütün yapmanız gereken implements anahtar kelimesi ile sınıf tanımında hangi arayüzü uygulamak istediğinizi bildirmek ve zaten ekli değilse gerekli metotları sınıfa eklemektir. Örneğin, Java kütüphanelerindeki birçok sınıf sonradan Comparable, Iterable ve AutoCloseable gibi arayüzleri uygulayacak şekilde uyarlanmıştır. Bunun tersine, var olan sınıfları fazladan bir soyut sınıfı daha kalıtacak şekilde değiştiremezsiniz.

Not: Aşağıdaki paragrafta kullanılan mixin ifadesinin Türkçesi olmadığı için olduğu şekliyle kullanmak zorunda kaldım. Ne anlama geldiğini aktarmaya çalıştım ama anlaşılmazsa buradan bakabilirsiniz.

Arayüzler ”mixin” tanımlamak için idealdir. Basitçe söylemek gerekirse, mixin bir sınıfın kendi türüne ek olarak uygulayabildiği, opsiyonel bir davranış belirten türlere verilen isimdir. Örneğin, Comparable mixin bir arayüzdür. Bunun böyle adlandırılmasının sebebi, bu arayüzü uygulayan sınıfların arayüzde tanımlı ek işlevleri kendi ana işlevlerinin arasına katmasından dolayıdır. (Buradaki ”katmak” fiili önemlidir çünkü mixin terimi İngilizce’de katmak/karışmak anlamına gelen ”mix in” fiilinden türetilmiştir.) Bunu tekli kalıtımın getirdiği kısıtlar dolayısıyla soyut sınıflarla yapamayız çünkü mevcut bir sınıfı ikinci bir soyut sınıfı kalıtacak şekilde değiştiremeyiz.

Arayüzler tür hiyerarşisi olmayan sistemlerin inşa edilmesine de olanak sağlarlar. Tür hiyerarşileri bir takım şeyleri organize etmek için ideal olsa da birçok sınıf bu kategoriye girmez. Varsayalım ki, bir şarkıcıyı (Singer) ve bir söz yazarını (Songwriter) temsil eden iki tane arayüzümüz olsun:

public interface Singer {
    AudioClip sing(Song s);
}

public interface Songwriter {
    Song compose(int chartPosition);
}

Gerçek hayatta, bazı şarkıcılar aynı zamanda söz yazarıdırlar. Bu türleri tanımlamak için soyut sınıflar yerine arayüzler kullandığımız için, bir sınıfın hem Singer hem de Songwriter arayüzlerini uygulaması gayet mümkündür. Hatta, hem Singer hem de Songwriter arayüzlerini kullanan üçüncü bir arayüz tanımlayarak, içerisinde başka metotlar da ekleyebiliriz.

public interface SingerSongwriter extends Singer, Songwriter {
    AudioClip strum();
    void actSensitive();
}

Her zaman bu tarz bir esnekliğe gerek duymayabilirsiniz, ancak lazım olduğunda arayüzler hayat kurtarır. Bu sistemi soyut sınıflarla kurmak isterseniz, her bir kombinasyon için tür hiyerarşinize yeni sınıflar eklemek zorunda kalırsınız. Bu da tahmin edeceğiniz üzere hem işinizi zorlaştıracak hem hata yapmanıza sebebiyet verecektir.

Arayüzler sarmalayan sınıflar (wrapper class) yazmanıza izin verirler ve güvenli ve esnek bir biçimde var olan işlevselliği geliştirebilmenizi sağlarlar. (Madde 18) Soyut sınıflar kullanıldığında ise, programcıların yazılıma ek işlevsellik kazandırabilmek için kalıtım yapmaktan başka çaresi olmaz. Sonuçta ortaya çıkan da sarmalayan sınıflardan daha kırılgan ve zayıf çocuk sınıflar olur.

Eğer bir arayüzdeki bir metodun çok bariz bir uygulama şekli varsa, arayüzü uygulayacak programcılara yardımcı olmak için varsayılan metot (default method) yazabilirsiniz. Bunun bir örneği olarak aşağıdaki removeIf metoduna bakabiliriz. Eğer bu şekilde bir varsayılan metot sağlıyorsanız @implSpec Javadoc etiketini kullanarak belgelemeyi unutmayın. (Madde 19)

// Java 8'de Collection arayüzüne eklenen varsayılan metot
default boolean removeIf(Predicate<? super E> filter) {
    Objects.requireNonNull(filter);
    boolean result = false;
    for (Iterator<E> it = iterator(); it.hasNext(); ) {
        if (filter.test(it.next())) {
            it.remove();
            result = true;
        }
    }
    return result;
}

Varsayılan metotlar kullanarak arayüzü kullanacak programcılara ne kadar destek olabileceğiniz konusunda çeşitli sınırlamalar vardır. Her ne kadar birçok arayüz kendi türü için equals ve hashCode gibi Object metotlarının nasıl davranması gerektiğini belgelese de, bunları varsayılan metot olarak tanımlamak yasaktır. Ayrıca arayüzler nesne alanları (instance field) ve private static metotlar haricinde public olmayan statik üyeler barındıramazlar. Son olarak, arayüz koduna erişiminiz yoksa varsayılan metot yazmanız da mümkün olmaz.

Ancak, arayüzlerin ve soyut sınıfların avantajlarını beraber kullanabilmek için arayüzün yanında soyut bir iskelet gerçekleştirim sınıfı (skeletal implementation class) sağlayabilirsiniz. Arayüzü tür tanımlaması yapmak için kullanabilir ve içerisine mümkün olduğu taktirde varsayılan metotlar ekleyebilirsiniz. İskelet sınıf ise geriye kalan temel olmayan arayüz metotlarını gerçekleştirmek için kullanılır. (Burada arayüzün temel metotlarından kasıt, arayüzde ve iskelet sınıfta soyut olarak bırakılıp, gerçekleştirimin son kullanıcıya bırakılması gereken metotlardır) Arayüzü direk uygulamak yerine iskelet sınıfı kalıtmak, sizi işin büyük bir kısmını yapmaktan kurtaracaktır. Bu da aslında Şablon (Template) tasarım desenidir.

İskelet gerçekleştirim sınıfları genellikle AbstractArayüz şeklinde isimlendirilir. Burada Arayüz kısmı iskelet sınıfın uyguladığı arayüzün adını alır. Örneğin, Collections çatısı (framework), her bir Collection arayüzü için bu şekilde iskelet sınıflar sağlamaktadır: AbstractCollection, AbstractSet, AbstractList, AbstractMap gibi. Aslında bu sınıfları başında Abstract yerine Skeletal olacak şekilde isimlendirmek daha mantıklı olabilirdi ama bu şekliyle genel kabul gördüğü için buna uymak daha doğru olacaktır. Doğru tasarlandığında, iskelet sınıflar (ister ayrı bir soyut sınıf olsun isterse de sadece arayüz içerisindeki varsayılan metotlardan oluşmuş olsun), programcıların arayüzleri uygulamasına çok büyük kolaylık sağlarlar. Örneğin, aşağıda tamamen işlevsel olan, esasında AbstractList kalıtılarak yazılmış bir List gerçekleştirimini döndüren static fabrika metodunu görüyoruz:

// İskelet sınıf üzerine kurulmuş somut gerçekleştirim 
static List<Integer> intArrayAsList(int[] a) {
    Objects.requireNonNull(a);

    // Java 9'dan önceki bir versiyonu kullanıyorsanız burada 
    // <Integer> ile tür belirtmelisiniz
    return new AbstractList<>() {
        @Override 
        public Integer get(int i) {
            return a[i];  // Autoboxing (Madde 6)
        }
    
        @Override 
        public Integer set(int i, Integer val) {
            int oldVal = a[i];
            a[i] = val;     // Auto-unboxing
            return oldVal;  // Autoboxing
        }
    
        @Override 
        public int size() {
            return a.length;
        } 
    };
}

Burada geri döndürülen List gerçekleştiriminin neler yapabildiğini düşündüğünüzde, bu örneğin iskelet sınıfların gücünü gösteren ne kadar çarpıcı bir örnek olduğu da ortaya çıkmaktadır. Aslında bu örnek, int türünde bir diziyi Integer nesneleri tutan bir liste görüntüsüne dönüştürerek Adapter tasarım desenini uygulamaktadır. Ancak şunu da belirtelim ki int ilkel türü ve Integer nesnelerinin sürekli birbirine dönüştürülmesinden ötürü (boxing ve unboxing) performansı çok iyi olmayacaktır. Dikkat ederseniz, buradaki List gerçekleştirimi anonim sınıf (anonymous class) biçiminde yazılmıştır. (Madde 24)

İskelet sınıfların güzel tarafı, soyut sınıflar oldukları için arayüzü uygulamak isteyen programcılara ciddi bir kolaylık sağlarken, bunlar tür olarak kullanıldığında ortaya çıkan kısıtları bize yansıtmamasıdır. Yukarıdaki örnek üzerinden gidecek olursak, programcının asıl hedefi en nihayetinde List türünde bir nesne yaratmaktır. Ancak bunu yaparken List arayüzünü uygulayıp birçok işlevi sıfırdan yazmak yerine AbstractList iskelet sınıfını kullanarak sadece değiştirmek istediği kısımları geçersiz kılmaktadır.

Bu şekilde iskelet sınıfı olan arayüzleri gerçekleştirmek isteyen hemen herkes, sağlanan iskelet sınıfı kalıtarak bu işi yapar. Ancak bu tamamen size bağlıdır, isterseniz iskelet sınıfı pas geçerek arayüzü direk de uygulayabilirsiniz. Bu durumda da yine arayüzde tanımlıysa varsayılan metotlardan faydalanabilirsiniz. Eğer iskelet sınıfı taban alarak bir gerçekleştirim yapamıyorsanız, iskelet sınıfı kalıtan bir private iç sınıf kullanarak arayüz metotları çağrıldığında bu private sınıfın metotlarına iletim yapabilirsiniz. Bu, Madde 18‘de anlatılan sarmalayan sınıf (wrapper class) tekniğine benzemektedir.

İskelet sınıflar yazmak çok zor bir iş olmasa da biraz zahmetlidir. Öncelikle uygulayacağınız arayüzdeki hangi metotları dolduracağınızı ve hangi metotları boş bırakacağınızı (yukarıda bahsedildiği gibi bunlara aynı zamanda arayüzün temel metotları da denebilir) belirlemek gerekmektedir. Boş bırakmak istediğiniz metotlar iskelet sınıfta yine abstract olarak kalacaktır. Sonra, boş bıraktığınız temel metotları kullanarak yazabildiğiniz varsayılan metotları arayüze ekleyin. Eğer sadece temel ve varsayılan metotlarla bütün arayüzü tamamlayabilirseniz işiniz bitti demektir ve ayrı bir iskelet sınıfa gerek yoktur. Aksi taktirde, arayüzü uygulayan soyut bir sınıf yaratarak geriye kalan bütün metotları geçersiz kılacak şekilde bir gerçekleştirim yapın. Bu sınıf, gerektiği taktirde public olmayan başka metot ve alanlar da içerebilir.

Basit bir örnek olması açısından, Map.Entry arayüzüne bakabiliriz. Burada temel metotlar getKey, getValue ve bir ihtimal setValue olarak belirlenebilir. Arayüz, bu türdeki nesneler için equals ve hashCode için doğru davranışın nasıl olması gerektiğini belgelemektedir ve toString için temel metotların ne şekilde kullanılabileceği açıktır. Ancak, Object sınıfından gelen bu metotları varsayılan metot olarak tanımlamak mümkün olmadığı için bütün bunlar iskelet sınıfın içerisine yazılmıştır:

// Map.Entry arayüzü için iskelet sınıf
public abstract class AbstractMapEntry<K,V> 
    implements Map.Entry<K,V> {
       
    // Bu metot mutlaka geçersiz kılınmalıdır!
    @Override 
    public V setValue(V value) {
       throw new UnsupportedOperationException();
    }

    // Map.Entry.equals ile belirtilen sözleşmeye uyulmaktadır
    @Override 
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof Map.Entry))
            return false;
        Map.Entry<?,?> e = (Map.Entry) o;
        return Objects.equals(e.getKey(), getKey())
               &amp;&amp; Objects.equals(e.getValue(), getValue()) 
    }

    // Map.Entry.hashCode ile belirtilen sözleşmeye uyulmaktadır   
    @Override 
    public int hashCode() {
        return Objects.hashCode(getKey())
               ^ Objects.hashCode(getValue());
    }
   
    @Override 
    public String toString() {
        return getKey() + "=" + getValue();
    }
}

Tekrar hatırlatalım, bu iskelet sınıf Map.Entry arayüzünde varsayılan metotlar ile yazılamazdı çünkü varsayılan metotlar equals, hashCode, toString gibi Object metotlarını geçersiz kılamazlar.

İskelet sınıflar kalıtılmak üzere tasarlandığı için, Madde 19’da anlatılan bütün tasarım ve belgeleme esaslarına uymalısınız. Önceki örnekte, biz asıl anlatılmak isteneni vurgulamak için dokümantasyonu çıkardık, ancak iskelet sınıflar için iyi dokümantasyon çok önemlidir.

Bazı arayüzler için iskelet sınıfların yerine AbstractMap.SimpleEntry ile yapıldığı gibi çok basit bir gerçekleştirim de sağlayabilirsiniz. İskelet sınıf gibi bir arayüzü uygulayan bu basit sınıflar, kalıtım için tasarlanmış olmalarına rağmen abstract değillerdir. Arayüz için yazılabilecek en basit gerçekleştirimi temsil ederler. Bu sınıfları olduğu gibi kullanabilir veya kalıtıp değişiklik yapabilirsiniz.

Özetle, arayüzler birden fazla gerçekleştirimi yazılabilecek türleri tanımlamanın en iyi yoludur. Basit olmayan bir arayüzünüz varsa, arayüzün yanında bir de iskelet sınıf sağlamaya çalışın. Bunu yaparken de arayüzünüze mümkün olduğunca çok sayıda varsayılan metot ekleyin ki arayüzü direk uygulayan herkes bundan faydalanabilsin. Ancak bu her zaman mümkün olmadığı için birçok durumda abstract sınıf biçiminde iskelet sınıflar da yazmanız gerekebilir.

Share

Bir Cevap Yazın