Effective Java Madde 23: Sınıf Hiyerarşilerini İşaretli Sınıflara Tercih Edin

Nesnelerinin mantıksal olarak birbirlerine yakın şeyler ifade etmesi sebebiyle tek bir sınıf içerisinde tanımlanan, bir işaret (tag) alanı ile nesnenin tam olarak neyi temsil ettiğini gösteren sınıflara işaretli sınıf diyebiliriz. Aşağıda, her ikisi de birer geometrik şekil olması sebebiyle hem bir daireyi hem de dikdörtgeni ifade edebilmek için yazılmış bir sınıf görüyoruz. shape alanı bize nesnenin bir daireyi mi yoksa dikdörtgeni mi temsil ettiğini söylemektedir.

// işaretli sınıf - problemli! 
class Figure {

    enum Shape { RECTANGLE, CIRCLE };

    // işaret alanı, bu şeklin türünü belirtiyor
    final Shape shape;

    // bu alanlar sadece shape=RECTANGLE ise kullanılıyor
    double length;
    double width;

    // bu alan sadece shape=CIRCLE ise kullanılıyor
    double radius;

    // daire için kullanılan yapıcı metot
    Figure(double radius) {
        shape = Shape.CIRCLE;
        this.radius = radius;
    }

    // dikdörtgen için kullanılan yapıcı metot
    Figure(double length, double width) {
        shape = Shape.RECTANGLE;
        this.length = length;
        this.width = width;
    }

    double area() {
        switch(shape) {
            case RECTANGLE:
                return length * width;
            case CIRCLE:
                return Math.PI * (radius * radius);
            default:
                throw new AssertionError(shape);
        } 
    }
}

Bu tarz işaretli sınıfların birçok dezavantajı vardır. Birincisi, gereksiz işaret alanı, enum türü ve switch bloğu yüzünden çok karışıktır. Birden fazla türü aynı sınıfa sıkıştırmaya çalıştığı için okunabilirliği çok zayıftır. Bellekte kapladığı alan olması gerekenden fazladır çünkü ihtiyaç olmadığı halde bir daire için (Shape.CIRCLE) length ve width, bir dikdörtgen içinse (Shape.RECTANGLE) radius alanını tutmaktadır. Üstelik bu alanları final olarak tanımlamak isteseydik yapıcı metotlar içerisinde lazım olmayan alanlara bile atama yapmamız gerekirdi. Yapıcı metotların, derleyiciden yardım almaksızın doğru shape türünü ataması ve doğru alanlara atama yapması gerekir, burada bir hata yapılırsa program çalışma zamanında çökecektir. Yeni bir tür eklemek isterseniz (örneğin üçgen), bütün sınıfı elden geçirmeniz ve switch bloğuna eklemeler yapmanız gerekir, eğer unutursanız program yine çalışma zamanında çökecektir. Bu çok basit bir örnek olduğu için tek bir switch bloğu olsa da, pratikte bundan çok daha fazlası olabilir. Bu da hata yapma riskini çok artırır. Son olarak, bir istemci açısından nesnenin türü (Figure), onun tam olarak neyi ifade ettiği konusunda hiçbir fikir vermemektedir. Bunu için istemcinin shape alanını da kontrol etmesi gerekir. Kısacası, işaretli sınıflar hem çok karmaşık, hem hata yapmaya müsait hem de verimi düşük sınıflardır.

Şanslıyız ki, Java gibi nesne tabanlı dillerde bu durumlarda kullanabileceğimiz çok daha iyi bir alternatif var: tür hiyerarşisi kurmak. İşaretli sınıflar aslında sınıf hiyerarşilerinin başarısız bir taklididir.

İşaretli bir sınıfı sınıf hiyerarşisine çevirmek için öncelikle soyut bir sınıf (abstract class) tanımlayın. Bu sınıfa metot olarak her bir tür için uygulanması gereken ve gerçekleştirimi türe göre değişiklik gösteren metotları yazın. Yukarıdaki örnekte area metodu buna örnektir çünkü alan hesaplama yöntemi türe göre değişiklik göstermektedir. Bu soyut sınıf, hiyerarşinin tepesinde olacaktır. Gerçekleştirimi türe göre değişmeyen, başka bir deyişle bütün türler için kodu aynı olan metotları bu sınıfa somut metot olarak ekleyin. Yine bütün türler için kullanılan ortak alanlar varsa onları da ekleyin. Bizim Figure örneğimizde bütün türler tarafından ortak olarak kullanılabilecek bir metot veya alan yoktur.

Sonra, gerçekleştirmek istediğiniz her tür için bir somut sınıf yaratıp soyut sınıfınızı kalıtın. Bizim örneğimize göre Circle ve Rectangle olmak üzere iki tane somut sınıf oluşturulması gerekir. Bu sınıflar için kullanılacak alanları da ekleyin: yani Circle sınıfında sadece radius, Rectangle sınıfında ise length ve width alanları olması gerekir. Son olarak ata sınıfta soyut tanımladığınız metodu geçersiz kılarak o tür için olması gereken kodu yazın. Yukarıdaki örneği buna çevirecek olursak:

// işaretli sınıfın tür hiyerarşisi ile yazılışı
abstract class Figure {
    abstract double area();
}

class Circle extends Figure {
    final double radius;

    Circle(double radius) { 
        this.radius = radius; 
    }

    @Override 
    double area() { 
        return Math.PI * (radius * radius); 
    } 
}

class Rectangle extends Figure {
    final double length;
    final double width;
  
    Rectangle(double length, double width) {
        this.length = length;
        this.width  = width;
    }
       
    @Override 
    double area() { 
        return length * width; 
    }
}

Bu sınıf hiyerarşisi, daha önceki işaretli sınıf için anlattığımız bütün eksiklikleri çözmektedir. Kod çok basittir ve anlaşılması kolaydır. İşaretli sınıfta kullanmak zorunda kaldığımız gereksiz alanlar ve switch bloğu yoktur. Her tür kendi sınıfında tanımlandığı için kendisini ilgilendirmeyen alanlarla ilgilenmek zorunda değildir. Bütün alanları final tanımlamak mümkün olmuştur. Bu sayede, yapıcı metot kendi türü için bütün gerekli alanlara atama yapmak zorundadır, aksi taktirde derleyici hata verecektir. Ata sınıfta tanımlı soyut metotların çocuk sınıflarda da tanımlanması yine derleyici tarafından garanti edilir. Yani, işaretli sınıfta switch bloğuna eklenmeyen türlerin çalışma zamanında hata vermesi durumu artık mümkün değildir. Birden fazla programcı, ata sınıfa erişimleri olmasa bile çocuk sınıflar yaratarak birbirlerinden bağımsız bir biçimde kodlarını geliştirebilirler.

Tür hiyerarşilerinin başka bir avantajı da, alt türlerin birbiri arasındaki hiyerarşik düzeni kurabilmektir. Örneğin, buraya bir kare türü eklemek istersek, kareyi dikdörtgenden kalıtıp özel bir dikdörtgen olduğunu aşağıdaki gibi hiyerarşik düzene yansıtabiliriz:

class Square extends Rectangle {
    Square(double side) {
        super(side, side);
    }
}

Şunu da belirtelim ki, anlatılmak isteneni vurgulamak için yukarıda erişim metotları kullanmak yerine alanlara direk erişiyoruz. Eğer bu hiyerarşi dışarıdan erişilebilir (public) olsaydı, burada bir tasarım hatası yapmış olurduk. (Madde 16)

Özetle, işaretli sınıfların kullanımı birçok durum için uygunsuzdur. Eğer böyle bir sınıf yazmayı aklınızdan geçiriyorsanız, işaret alanını kaldırıp bunu bir tür hiyerarşisine çevirip çeviremeyeceğinizi ciddi olarak düşünün. Böyle bir sınıfla karşılaşırsanız da, tür hiyerarşisine dönüştürmeyi düşünün.

Share

Effective Java Madde 19: Kalıtımı ya Tasarım ve Dokümantasyon ile Destekleyin ya da Yasaklayın

Madde 18’de, kalıtılmak için tasarlanmamış ve belgelenmemiş (dokümantasyon) yabancı sınıfları kalıtmanın tehlikeli olduğunu söylemiştik. Peki ama bir sınıfın kalıtım için tasarlanması ve belgelenmesi ne anlama gelmektedir?

Birincisi, sınıf içerisinde bulunan her bir metot için, geçersiz kılındığında ne gibi etkiler olacağı net olarak belgelenmelidir. Başka bir deyişle, sınıf geçersiz kılınabilen (override edilebilen) bütün metotlarını çok iyi belgelemelidir. Bütün public ve protected metotlar için, bu metotların başka hangi geçersiz kılınabilen metotları hangi sırada çağırdığı ve bu çağrıların sonucunda işletimin nasıl etkileneceği belgelenmelidir. (burada geçersiz kılınabilen metot derken final olmayan, public veya protected metotlar kastedilmektedir) Daha genel bir ifadeyle, bir sınıf geçersiz kılınabilen metotları çağırdığı bütün durumları belgelemelidir. Mesela, bu çağrılar arka plandaki bir thread veya statik ilklendiricilerden (static initializer) gelebilir.

Geçersiz kılınabilen başka metotları işleten bir metot, yaptığı bu çağrıları @implSpec Javadoc etiketini kullanarak ”Implementation Requirements” başlığı altında belgeler. Bu bölümde metodun iç çalışma mekanizması anlatılır. java.util.AbstractCollection sınıfında bunun bir örneğini bulabiliriz:

public boolean remove(Object o)

Removes a single instance of the specified element from this collection, if it is present (optional operation). More formally, removes an element e such that Objects.equals(o, e), if this collection contains one or more such elements. Returns true if this collection contained the specified element (or equivalently, if this collection changed as a result of the call).

Implementation Requirements: This implementation iterates over the collection looking for the specified element. If it finds the element, it removes the element from the collection using the iterator’s remove method. Note that this implementation throws an UnsupportedOperationException if the Iterator returned by this collection’s iterator method does not implement the remove method and this collection contains the specified object.

Not: Yukarıdaki alıntı orijinal Javadoc dokümantasyonu olduğu için birebir çevirmeyi gerekli bulmadım. Özetle, ikinci paragrafta remove metodunun çalışma mantığını anlatıyor ve aynı sınıftaki Iterator referansının remove metodunu kullanarak (Iterator.remove) veri yapısından eleman sildiğini söylüyor. Bu Iterator nesnesini elde etmek için de iterator() metodunu çağırıyor. Eğer burada geri döndürülenIterator nesnesi remove metodunu gerçekleştirmemişse UnsupportedOperationException hatası fırlatılıyor.

Bu belgelemeden anlaşıldığı üzere, iterator metodunu geçersiz kılmak remove metodunun çalışmasını etkileyecektir çünkü bu metot iterator metodunu kendi içerisinde çağırmaktadır. Hatta iterator metodu tarafından döndürülen Iterator nesnesinin davranışının, remove üzerindeki etkisini de belirtmektedir. (istisna fırlatma durumu) Hatırlarsanız Madde 18’de HashSet sınıfını kalıtan bir programcı add metodunu geçersiz kıldığında addAll metodunun davranışını etkileyip etkilemeyeceğini bilemiyordu.

Ama bu durum iyi yazılmış API belgelerinde metotların ne yaptığını güzelce açıklayıp nasıl yapıldığını gizli tutması gerektiği görüşüne ters düşmüyor mu? Aynen öyle! Bu maalesef kalıtımın sarmalama (encapsulation) ilkesini ihlal etmesi sebebiyle oluşan talihsiz bir sonuç. Bir sınıfın güvenli bir şekilde kalıtılabilmesi için, istemcilerin normal şartlarda bilmemesi gereken gerçekleştirim detaylarını belgelemek gerekmektedir.

@implSpec Javadoc etiketi Java 8 ile eklendi ve Java 9’la beraber sıkça kullanılmaya başlandı. Bu etiketin varsayılan olarak (default) aktif olması gerekirken, Java 9 itibariyle komut satırından -tag "apiNote:a:API Note:" parametresi geçilmesi gerekmektedir. Aksi taktirde Javadoc bu etiketi görmezden gelmektedir.

Kalıtım için tasarım yapmak sadece belgeleme yapmaktan ibaret değildir. Programcılara acı çektirmeden verimli çocuk sınıflar yazmalarına izin vermek için, sınıfın iç çalışma mekanizmasına müdahale edebilmeleri için akıllıca seçilmiş protected metotlar, nadir bazı durumlarda ise protected alanlar eklemek gerekebilir. Örneğin java.util.AbstractList sınıfındaki removeRange metoduna bakalım:

protected void removeRange(int fromIndex, int toIndex)

Removes from this list all of the elements whose index is between fromIndex, inclusive, and toIndex, exclusive. Shifts any succeeding elements to the left (reduces their index). This call shortens the list by (toIndex - fromIndex) elements. (If toIndex == fromIndex, this operation has no effect.)

This method is called by the clear operation on this list and its sublists. Overriding this method to take advantage of the internals of the list implementation can substantially improve the performance of the clear operation on this list and its sublists.

Implementation Requirements: This implementation gets a list iterator positioned before fromIndex and repeatedly calls ListIterator.next followed by ListIterator.remove, until the entire range has been removed. Note: If ListIterator.remove requires linear time, this implementation requires quadratic time.

Parameters:
fromIndex: index of first element to be removed.
toIndex: index after last element to be removed.

Not: Yine yukarıdaki gibi, Javadoc çevirisi yapmaktansa özetle ne dediğine bakalım. Önemli kısım olan ikinci paragrafta bu removeRange metodunun clear metodu tarafından çağrıldığını, bu metodu geçersiz kılarak çocuk sınıfların clear metodu işletildiğinde çok ciddi performans kazanabilecekleri belirtilmiş.

Bu metot List gerçekleştirimlerinden birini direk kullanan istemci sınıflar için aslında hiç de gerekli değildir. protected yapılarak API’a dahil edilmesinin tek sebebi, dokümantasyonda belirtildiği gibi AbstractList sınıfını kalıtan çocuk sınıflara hızlı çalışan bir clear metodu yazabilmelerini sağlamak içindir. Bu metot verilmeseydi, çocuk sınıflar ya clear metotları çağrıldığında ciddi performans kayıplarını kabul edecekler ya da alt liste (subList) oluşturma mekanizmasını sıfırdan yazmak zorunda kalacaklardı – bu da hiç kolay bir şey değil!

Peki bir sınıfı kalıtılabilmesi için tasarlıyorsak, hangi metotları veya alanları protected yaparak çocuk sınıflara açmamız gerektiğini nereden bileceğiz? Maalesef bunun sihirli bir formülü yok. Yapılabilecek en iyi şey çok iyi düşünmek ve çocuk sınıflar yazarak testler yapmaktır. Ancak mümkün olduğunca az sayıda metot ve alanı açmak yerinde olacaktır, çünkü bunların her biri ileride belirli gerçekleştirim detaylarına bağlı kalmanızı gerektirmektedir. Diğer taraftan, bunu kısıtlayayım derken gerekli bir metodu açmamak da sınıfı pratikte asla kalıtılamayacak duruma sokabilir.

Bu tür kalıtım için tasarlanmış sınıfların doğru tasarlanıp tasarlanmadığını anlamanın tek yolu çocuk sınıflar yazarak test etmektir. Eğer gerekli bir sınıf üyesini protected yapmayı gözden kaçırdıysanız, test esnasında yazacağının çocuk sınıflarda bu eksikliği farketmeniz çoğu zaman mümkündür. Tam tersine, birkaç tane çocuk sınıf yazmanıza rağmen hiç birisinde protected bir üyeyi kullanma ihtiyacı hissetmediyseniz, o zaman buna gerek yoktur ve private olarak değiştirebilirsiniz. Tecrübelere göre, bu şekilde 3 tane çocuk sınıf yazmak genellikle yeterlidir. Ancak bu çocuk sınıfların en az bir tanesi ata sınıfı yazan kişiden farklı biri tarafından yazılmalıdır.

Kalıtım için tasarladığınız sınıfın geniş kitlelere yayılma ihtimali var ise, şunu unutmayın ki belgelediğiniz ve protected sınıf üyeleri için uyguladığınız gerçekleştirim detaylarına sonsuza dek bağlı kalmak zorundasınız. Bu durum ileride performans iyileştirmeleri yapmak veya sınıfın işlevselliğini artırmak için yapacağınız değişiklikleri çok zorlaştırabilir hatta imkansız hale getirebilir. Bu sebeple, sınıfınızı mutlaka çocuk sınıflar yazarak test etmelisiniz.

Ayrıca bu durumda yapmak zorunda olduğunuz özel belgelemenin, sınıfı kalıtmak yerine nesnesini oluşturup olağan bir şekilde kullanmak isteyen programcıların kullandığı normal dokümantasyonu kirlettiği de aşikardır. Bu yazı yazılırken, dokümantasyon araçlarının normal kullanıcılar ile sınıfı kalıtmak isteyen kullanıcılar arasında ayrım yapabilecek kadar gelişmiş olmadığını belirtelim.

Bir sınıfın kalıtıma izin vermeden önce uyması gereken birkaç tane daha kural vardır. Yapıcı metotlar geçersiz kılınabilen metotları doğrudan veya dolaylı olarak çağırmamalıdırlar. Bu kurala uymamak yazılımda hatalara yol açar. Çocuk sınıfın nesnesi yaratılırken ata sınıfın yapıcı metodu çocuk sınıfınkinden önce çalıştırılır. Eğer ata sınıfın yapıcı metodu geçersiz kılınabilen bir metodu çağırıyorsa, çocuk sınıfta geçersiz kılınan bu metot kendi yapıcı metodundan önce çalışacaktır. Eğer bu metot da çocuk sınıfın yapıcı metodunda yapılması gereken işlemlere (ilk değer ataması gibi) bağımlı ise, program istenilen sonucu vermeyecektir. Bunu örneklemek için aşağıdaki sınıfı inceleyelim:

public class Super {
    // Bozuk - yapıcı metot geçersiz kılınabilen metot çağırıyor
    public Super() {
        overrideMe();
    }
    
    public void overrideMe() {  }
}

Şimdi de bu sınıfı kalıtıp overrideMe metodunu geçersiz kılan çocuk sınıfa bakalım:

public final class Sub extends Super {
    // final alan yapıcı metot tarafından atanıyor
    private final Instant instant;
    
    Sub() {
        instant = Instant.now();
    }
    // ata sınıf tarafından çağrılan geçersiz kılınmış metot
    @Override public void overrideMe() {
        System.out.println(instant);
    }
    
    public static void main(String[] args) {
        Sub sub = new Sub();
        sub.overrideMe();
    } 
}

Bu programın instant değerini iki defa yazdırması gerektiğini umuyor olabilirsiniz ancak ilk seferinde null yazdıracaktır çünkü overrideMe metodu ilk seferde Super sınıfındaki yapıcı metot tarafından, instant değeri henüz atanmamışken işletilecektir. Bu programda final tanımlanan instant alanının farklı zamanlarda iki farklı değer ürettiğini de gözden kaçırmayın! Ayrıca, eğer overrideMe içerisinde instant üzerinden herhangi bir metot çağırsaydık, Super yapıcı metodundan gelen ilk çağrıda overrideMe metodu NullPointerException fırlatırdı. Şu anki haliyle NullPointerException fırlatmamasının tek sebebi println metodunun null parametreleri tolere etmesidir.

Ata sınıfın yapıcı metodundan private, final ve static metotları çağırmakta bir sorun yoktur çünkü bunların hiç birisi geçersiz kılınabilir değildir.

Cloneable ve Serializable arayüzleri kalıtım için tasarlanan sınıflar için daha büyük güçlükler doğurmaktadır. Genel olarak, kalıtım için tasarlanan sınıfların bu arayüzleri uygulaması iyi bir fikir değildir çünkü bu sınıfı kalıtacak programcılara ağır bir yük yüklenmiş olur. Ama yine de bunu yapmak istiyorsanız, Madde 13 ve Madde 86’da anlatıldığı şekilde kullanabileceğiniz özel yöntemler mevcuttur. Bu arayüzlerden birini uygulamaya karar verdiyseniz, yapıcı metotlardaki kısıtlamanın clone ve readObject metotları için de geçerli olduğunu unutmayın: clone ve readObject içerisinde geçersiz kılınabilen başka metotları doğrudan veya dolaylı olarak çağırmayın!

Şimdiye kadar anlatılanlardan, bir sınıfı kalıtıma izin verecek şekilde tasarlamanın çok zahmetli bir iş olduğunu ve sınıf üzerinde ciddi kısıtlamalara yol açtığını anlamışsınızdır. Bu sebeple bu kararı alırken ciddi kafa yormanız gerekmektedir. Soyut sınıflar (abstract class) gibi durumlarda bu açıkça yapılması gereken bir şey olsa da, değiştirilemez sınıflar (immutable class) söz konusuysa kalıtımı düşünerek tasarım yapmak anlamsızdır. (Madde 17)

Peki ama sıradan somut sınıflar için ne yapmalıyız? Bunlar genellikle ne final olarak tanımlanır ne de kalıtılmak üzere tasarım ve belgeleme yapılır, bu tehlikeli bir durumdur! Böyle bir sınıfa her değişiklik yapıldığında, bu sınıfı kalıtmış olan çocuk sınıflarda problemlere yol açmak olasıdır. Bu, teoride kalmış bir problemden ibaret değildir. Kalıtılmak için tasarlanmamış ve belgelenmemiş sınıflarda yapılan değişikliklerden sonra bu sınıfı kalıtan kişilerden hata raporları gelmesi son derece yaygın bir durumdur.

Bunun için en iyi çözüm, kalıtım için tasarlanmamış ve belgelenmemiş sınıfların kalıtılmasını yasaklamaktır. Bunun için de iki yol vardır. Kolay olanı sınıfı final olarak tanımlamaktır. İkinicisi ise bütün yapıcı metotları private veya package private tanımlayıp, public static fabrika metotları aracılığıyla nesne yaratılmasına izin vermektir. Bu alternatif Madde 17’de ele alınmıştır. Bu iki yöntemden herhangi birisinin kullanılmasında bir sakınca yoktur.

Bu öneri biraz çelişkili gelebilir çünkü birçok yazılımcı sıradan somut sınıfları kalıtarak eklemeler ve çıkarmalar yapmaya çok alışkındır. Madde 18’de anlatılan wrapper class yöntemi, işlevselliği artırmak için kalıtıma nazaran daha iyi bir alternatiftir.

Eğer somut bir sınıf standart bir arayüzü uygulamıyorsa, kalıtımı kısıtlamak suretiyle bazı programcıları zor durumda bırakabilirsiniz. Böyle bir sınıfta kendinizi kalıtıma izin vermek zorunda hissederseniz, en azından sınıfın geçersiz kılınabilen metotlarının hiç birisini çağırmadığından emin olun ve bu durumu belgeleyin. Bunu yaparak, kalıtılması makul derecede güvenli bir sınıf yazmış olursunuz, çünkü çocuk sınıfta geçersiz kılınan hiçbir metot başka metotların bozulmasına sebep olmayacaktır.

Sınıfın geçersiz kılınabilen metotlarını çağırmasını davranış değişikliğine sebep olmadan engellemenin bir yolu mevcuttur. Geçersiz kılınabilen her metodun içeriğini private bir yardımcı metoda (helper method) taşıyın ve geçersiz kılınabilen her metottan kendisine ait olan yardımcı metodu çağırın. Daha sonra sınıfın içerisinde geçersiz kılınabilen metotları çağıran kısımları değiştirip direk olarak onlara karşılık gelen private yardımcı metotları çağırın.

Özetle, bir sınıfı kalıtıma göre tasarlamak zor bir iştir. Sınıfın kendi içindeki kullanımları çok iyi belgelemelisiniz ve bunlara sınıfın ömrü boyunca riayet etmelisiniz. Bunu yapmazsanız, çocuk sınıflar ata sınıfın gerçekleştirim detaylarına bağımlı olurlar ve bu gerçekleştirim değiştiğinde hata vermeye başlarlar. Diğer programcılata verimli çocuk sınıflar yazmalarına izin vermek için, bazı metotları protected olarak dışarı açmanız gerekebilir. Sınıfınızdan çocuk sınıflar yaratılması konusunda ciddi bir gereklilik hissetmiyorsanız, kalıtımı yasaklamak daha faydalı olacaktır.

Share