Effective Java Madde 40: Override Notasyonunu Sürekli Olarak Kullanın

Java kütüphaneleri çok sayıda notasyon (annotation) barındırırlar. Çoğu programcı için bunların en önemlisi @Override notasyonudur. Sadece metot tanımlarında kullanılabilen bu notasyon metodun kalıttığı sınıf veya uyguladığı arayüzdeki başka bir metodu geçersiz kıldığını belirtir. Bu notasyonu düzenli olarak kullandığınız taktirde sizi birçok hatadan koruyacaktır. Şimdi harf ikililerini ifade etmek için yazılmış aşağıdaki Bigram sınıfına bakalım:

// Hatayı bulabilir misiniz?
public class Bigram {
    private final char first;
    private final char second;
    public Bigram(char first, char second) {
        this.first  = first;
        this.second = second;
    }
    public boolean equals(Bigram b) {
        return b.first == first && b.second == second;
    }
    public int hashCode() {
        return 31 * first + second;
    }
    public static void main(String[] args) {
        Set<Bigram> s = new HashSet<>();
        for (int i = 0; i < 10; i++) {
            for (char ch = 'a'; ch <= 'z'; ch++) {
                s.add(new Bigram(ch, ch));
            }
        }
        System.out.println(s.size());
    }
}

Program main metodunda yirmi altı tane harf ikilisini "aa", "bb", "cc" .... "zz" olacak şekilde on kere üst üste bir kümeye (HashSet) eklemekte ve sonra da bu kümenin kaç elemanlı olduğunu yazdırmaktadır. HashSet aynı elemanı en fazla bir kez eklememize izin verdiği için sonucun 26 çıkmasını bekleyebilirsiniz ama 260 çıkacaktır. Peki sorun nerede?

Halbuki sınıfı yazan kişi equals metodunu geçersiz kılmak istemiş (Madde 10) ve hatta hashCode‘u da geçersiz kılması gerektiğini unutmamış (Madde 11). Ancak, equals metodunu geçersiz kılmaya çalışan programcı yanlışlıkla aşırı yükleme (overloading) yapmıştır çünkü Object sınıfından gelen equals parametre türü olarak Object beklerken Bigram sınıfındaki equals metodunda Object yerine Bigram parametresi kullanılmıştır. Bu sebeple de Object sınıfındaki equals metodu Bigram tarafından kalıtılacak ve nesne karşılaştırması için kullanılacaktır. Aynı harf ikililerini ifade eden Bigram nesneleri kalıtılan equals için eşit değildir çünkü farklı nesneleri temsil etmektedirler. Bu sebeple de HashSet içine 26 değil 260 eleman eklenecektir.

Neyse ki bu gibi problemlerin önüne geçmek için çok kolay bir yol var. @Override notasyonunu kullandığımız zaman derleyiciye üst türde tanımlı bir metodu geçersiz kılmak istediğimizi bildirmiş oluyoruz. Bu sayede derleyici bizim için gerekli kontrolleri yapacaktır ve üst türde geçerli kılmaya müsait bir metot yoksa hata verecektir. Şimdi bunu Bigram sınıfındaki equals metodunda nasıl kullanırız onu görelim:

@Override 
public boolean equals(Bigram b) {
    return b.first == first &amp;&amp; b.second == second;
}

Bu notasyonu kullandığımızda sınıfı derlemeye çalışırsak aşağıdaki gibi bir hatayla karşılaşırız:

Bigram.java:10: method does not override or implement a method from a supertype
@Override public boolean equals(Bigram b) { 
^

Bu hatayı görünce nerede hata yaptığınızı hemen anlayacaksınız, alnınıza vurup aşağıdaki şekilde düzelteceksiniz:

@Override 
public boolean equals(Object o) { 
    if (!(o instanceof Bigram)) {
        return false;
    }
    Bigram b = (Bigram) o;
    return b.first == first &amp;&amp; b.second == second;
}

Bu sebeple, üst sınıflarda tanımlı bir metodu geçersiz kılmak istediğiniz her durumda Override notasyonunu kullanmalısınız.

Modern IDE’lerin hemen hepsi geçersiz kılınan metotlara bu notasyonu otomatik olarak ekleyecek şekilde ayarlanabilir. Hatta Override notasyonunu kullanmayı unuttuğunuz durumlarda sizi eklemeniz için uyarabilirler. Düzenli kullandığınız durumda bu uyarılar sizi istemsiz geçersiz kılma durumlarına karşı da korurlar. Üst türde tanımlı olduğunu bilmediğiniz aynı imzaya sahip bir metot yazacak olursanız, IDE uyarısı sayesinde bunu farkedip durumu düzeltebilirsiniz.

Override notasyonu hem arayüzlerden gelen hem de üst sınıflardan kalıtılan metotları geçersiz kılmak için kullanılabilir. Varsayılan metotların (default method) dile eklenmesi ile arayüzü uygulayan sınıflarda doğru metot imzalarının kullanıldığından emin olmak için bu notasyonun kullanılması yine önerilir. Ancak arayüz sizin kontrolünüzde ise ve varsayılan metot olmadığını biliyorsanız, uygulayan sınıfta arayüz metotlarını geçersiz kılarken Override notasyonunu kullanmamayı seçebilirsiniz.

Soyut sınıflar ve arayüzler yazarken ise bir üst sınıf veya başka bir arayüzden gelen metodları geçersiz kılmak istediğiniz durumlarda Override notasyonunu kullanmak iyi bir fikir olacaktır. Örneğin, Set arayüzü Collection arayüzünü kalıtır ama yeni bir metot eklemez. Bu sebeple Collection arayüzüne yanlışlıkla yeni bir metot eklememek için bütün metot tanımlarında Override notasyonunu kullanması mantıklı olacaktır.

Özetle, Override notasyonu kullanıldığı zaman derleyici sizi türlü hatalardan korur. Bu notasyonu üst türlerdeki metotları geçersiz kılmak istediğiniz durumlarda kullanın. Somut sınıflarda soyut metotları geçersiz kılıyorsanız kullanmanız şart değildir ancak bir zararı da yoktur.

Share

Effective Java Madde 39: Notasyonları İsimlendirme Modellerine Tercih Edin

Eskiden bazı uygulama çatıları (framework) ile çalışabilmek için belli isimlendirme kurallarına uymak gerekiyordu. Örneğin, JUnit test çatısının 4. versiyondan önce test metotlarını algılayıp çalıştırabilmesi için metot isimlerinin “test” ile başlaması gerekiyordu. Bu yaklaşımın birçok dezavantajı vardı. Yapılan yazım hataları hiçbir hata vermeden test metotlarının görmezden gelinmesine sebep oluyordu. Örneğin testSafetyOverride isimli bir test metodu yazmak isterken yanlışlıkla tsetSafetyOverride yazarsanız JUnit 3 size bir uyarı vermez ama testi de çalıştırmaz. Böylece siz de testin geçtiğini zannederek sahte bir güven duygusuna kapılırsınız.

Ayrıca bu isimlendirme kurallarının doğru yerlerde kullanıldığına emin olamayız. Mesela bir programcı bir sınıfa TestSafetyMechanisms adını vermek suretiyle içindeki bütün metotların isimleri ne olursa olsun çalıştırılacağını zannedebilir. Ancak sınıf isimlerinin JUnit 3 için bir anlamı yoktur. Bu yüzden testler yine çalıştırılmayacak, hata görmeyen programcı da testlerinin geçtiği yanılgısına kapılacaktır.

Başka bir problem de testleri parametreli bir biçimde yazmak istersek ortaya çıkar. Örnek olarak diyelim ki yazdığımız bazı testlerin sadece belirli bir aykırı durum fırlatıldığında başarılı olmasını istiyoruz. Burada aykırı durumun ismi aslında test için bir parametredir ancak bunu isimlendirme modelleri kullanarak yapmaya çalışmak ortaya çirkin ve kırılgan kodların ortaya çıkmasına sebep olur. (Madde 62)

Notasyonlar (annotations) bu problemlerin hepsinin üstesinden kolaylıkla gelmektedir. JUnit de 4. sürümünden itibaren bunları desteklemektedir. Bu maddede biz de çok basit bir test çatısının nasıl yazılabileceğini notasyonlar kullanarak göstereceğiz. Varsayalım ki testlerin otomatik olarak çalışmasını ve bir aykırı durum oluşursa hata vermesini sağlamak için bir notasyon tanımlamak istiyoruz. Test isimli bu notasyon aşağıdaki gibi yazılabilir:

// notasyon türlerinin tanımlanması
import java.lang.annotation.*;
/**
 * Notasyonu uygulayan metotların test metodu olduğunu gösterir
 * Sadece parametresiz ve statik metotlarla kullanılabilir
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

Test notasyonunu tanımlarken de başka notasyonlar kullandık, bunlara meta notasyon denmektedir. @Retention(RetentionPolicy.RUNTIME) tanımladığımız Test notasyonunun çalışma zamanında korunması gerektiğini belirtmektedir, aksi taktirde notasyonumuz test kütüphanesi tarafından görülemezdi. @Target(ElementType.METHOD) ise bu notasyonun sadece metotlar üzerinde kullanılabileceğini belirtmektedir. Sınıflar, alanlar veya başka program elemanları ile kullanmak derleme hatalarına yol açacaktır.

Yorum satırlarında görebileceğiniz gibi “Sadece parametresiz ve statik metotlarla kullanılabilir” uyarısı yapılmaktadır ancak derleyici bu kurala uyulup uyulmadığını siz ek olarak bir de notasyon işleyici (annotation processor) yazmadığınız sürece denetleyemez. Böyle bir işleyici olmadığı için Test notasyonu bir nesne metodu üzerinde veya parametresi olan bir metot üzerinde tanımlanırsa derleyici şikayet etmeyecektir. Bu durumu çalışma zamanında test koşturucu (testleri çalıştıran basit program) ele alacaktır.

Pratikte Test notasyonunun nasıl kullanılacağını aşağıdaki örnekten anlayabiliriz. Bu tarz notasyonlara işaretçi notasyon denilir çünkü notasyonun parametresi olmadığı için beraber kullanıldığı metodu başka program parçalarının görebilmesi için işaretler. Burada programcı bir yazım yanlışı yaparsa veya notasyonu izin verilmeyen bir program elemanı ile kullanırsa derleyiciden hata alacaktır.

// işaretçi notasyonun kullanıldığı sınıf
public class Sample {
    @Test 
    public static void m1() { } // bu test geçmelidir 

    public static void m2() { }

    @Test 
    public static void m3() { // bu test başarısız olmalıdır
        throw new RuntimeException("Boom");
    }

    public static void m4() { }

    @Test 
    public void m5() { } // geçersiz kullanım: nesne methodu 
    
    public static void m6() { }

    @Test 
    public static void m7() { // // bu test başarısız olmalıdır
        throw new RuntimeException("Crash");
    }
    public static void m8() { }
}

Sample sınıfında yedi tane statik metot var ve bunların dört tanesi Test notasyonuyla işaretlenmiş. Bunlardan m3 ve m7 aykırı durum fırlatıyor, m1 ve m5 ise fırlatmıyor. Ancak m5 statik metot olmadığı için geçersiz kullanıma örnektir. Dolayısıyla, bu sınıfta dört tane test var. Bunlardan bir tanesi başarılı iki tanesi başarısız olacaktır, bir tanesi ise geçersizdir. Test notasyonuyla işaretlenmemiş olan diğer dört metot test koşturucu taradından görmezden gelinecektir.

Burada Test notasyonunun kullanılması Sample sınıfında bir anlam değişikliğine yol açmaz. İşaretli metotlar bu notasyonla ilgilenen başka programlar tarafından özel bir işleme tabi tutulur. Şimdi Test notasyonu için bunu yapan test koşturucuya bakalım:

// Test notasyonunu işleyen program
import java.lang.reflect.*;
public class RunTests {
    public static void main(String[] args) throws Exception {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName(args[0]);
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(Test.class)) { 
                tests++;
                try {
                    m.invoke(null);
                    passed++;
                } catch (InvocationTargetException wrappedExc) {
                    Throwable exc = wrappedExc.getCause();
                    System.out.println(m + " failed: " + exc);
                } catch (Exception exc) {
                    System.out.println("Invalid @Test: " + m);
                }
            } 
        }
        System.out.printf("Passed: %d, Failed: %d%n",
                  passed, tests - passed);
    } 
}

Bu test koşturucu program komut satırından bir sınıf ismi almakta ve bu sınıftaki Test notasyonuya işaretlenmiş bütün metotları Method.invoke ile reflection kullanarak çalıştırmaktadır. 9. satırda isAnnotationPresent kullanılarak metodun Test notasyonuna sahip olup olmadığını kontrol edilmektedir. Eğer test metodu bir aykırı durum üretirse reflection mekanizması bunu InvocationTargetException ile sarmalamaktadır. Program da bu aykırı durumu yakalayıp test metodu tarafından fırlatılan orijinal aykırı durumu getCause metodu ile çıkarmakta ve yazdırmaktadır.

Eğer test modunu çalıştırınca InvocationTargetException haricinde bir aykırı durumla karşılaşıyorsak bu derleme anında yakalanamayan bir geçersiz bir kullanımı ifade etmektedir. Bizim örneğimizde nesne metotları veya parametresi olan metotlar çağırıldığında geçersiz kullanım oluşmaktadır. İkinci catch bloğu bu hataları yakalayıp raporlamak için yazılmıştır. RunTests programını Sample sınıfı için çalıştırırsak aşağıdaki sonucu alırız:

public static void Sample.m3() failed: RuntimeException: Boom 
Invalid @Test: public void Sample.m5() 
public static void Sample.m7() failed: RuntimeException: Crash 
Passed: 1, Failed: 3

Şimdi de belli bir aykırı durumu fırlattığı taktirde başarılı sayılacak testleri nasıl destekleriz ona bakalım. Bunun için yeni bir notasyon türü tanımlayacağız:

// Parametreli notasyon türü
import java.lang.annotation.*;
/**
 * Notasyonu kullanan metotların başarılı sayılması   
 * için belirli bir aykırı durumu fırlatması gerekmektedir.
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}

Bu notasyonun Class<? extends Throwable> türünde bir parametresi vardır. Yani parametre olarak Throwable sınıfını kalıtan bir sınıfın Class nesnesini kullanabiliriz. Bütün aykırı durumlar (exception) Throwable sınıfını kalıttığı için bu notasyonu kullanan kişiler istedikleri aykırı durum türünü parametre olarak kullanabilirler. Bu kullanım sınırlandırılmış tür belirtecine bir örnektir. (Madde 33) Şimdi bu notasyon pratikte nasıl kullanılabilir ona bakalım:

// ExceptionTest notasyonunun pratikte kullanımı
public class Sample2 {
    @ExceptionTest(ArithmeticException.class)
    public static void m1() {  // Bu test geçecektir
        int i = 0;
        i = i / i; 
    }
    @ExceptionTest(ArithmeticException.class)
    public static void m2() {  // Başarısız! (yanlış aykırı durum)
        int[] a = new int[0];
        int i = a[1];
    }
    @ExceptionTest(ArithmeticException.class)
    public static void m3() { }  // Başarısız (aykırı durum yok)
}

Şimdi de test koşturucuyu düzenleyip bu yeni notasyonu da işlemesini sağlayalım. Tek yapmamız gereken aşağıdaki kodu main metoduna eklemek olacaktır:

if (m.isAnnotationPresent(ExceptionTest.class)) {
    tests++;
    try {
        m.invoke(null);
        System.out.printf("Test %s failed: no exception%n", m);
    } catch (InvocationTargetException wrappedEx) {
        Throwable exc = wrappedEx.getCause();
        Class<? extends Throwable> excType =
            m.getAnnotation(ExceptionTest.class).value();
        if (excType.isInstance(exc)) {
            passed++;
        } else {
            System.out.printf(
                "Test %s failed: expected %s, got %s%n",
                m, excType.getName(), exc);
        }
    } catch (Exception exc) {
        System.out.println("Invalid @Test: " + m);
    }
}

Bu kod Test notasyonu için yazdığımıza benziyor ama bir ekleme yaptık. Bir aykırı durum fırlatıldığında bunun türünü notasyonun parametresinde tanımlı aykırı durumla karşılaştırıyoruz. Eğer beklediğimiz aykırı durumla catch bloğunda yakalanan aynı ise test başarılı olmuştur diyoruz. Bu program hatasız ve uyarısız derlendiğinden çalışma zamanında ClassCastException alma riskimiz yoktur.

Şimdi bu örneği bir adım daha ileriye götürelim. Bir testin başarılı olabilmesi için belli bir aykırı durum değil de bir grup içinden herhangi bir aykırı durum fırlatılması şart olsun. Mesela bir test metodu NullPointerException veya IndexOutOfBoundsException fırlatıldığı taktirde başarılı sayılsın. Notasyon mekanizması bu kullanımı destekleyen bir kolaylık sunmaktadır. ExceptionTest notasyonundaki parametre türünü Class nesnelerinin dizisi olacak şekilde değiştirebiliriz:

// parametre türü olarak dizi kullanan notasyon
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Exception>[] value(); 
}

Notasyonlarda kullanılan bu dizi gösterimi esnektir. Tek elemanlı diziler için optimize edilmiştir. Önceki örnekte tanımladığımız ExceptionTest notasyonlarının tamamı bu kullanımla tek elemanlı dizilere dönüşeceği için geçerli olacaktır. Parametre olarak birden çok aykırı durum geçmek istersek notasyonu aşağıdaki gibi kullanabiliriz:

//  Notasyonun dizi parametresi ile kullanımı
@ExceptionTest({ IndexOutOfBoundsException.class,
                 NullPointerException.class }) 
public static void doublyBad() {
    List<String> list = new ArrayList<>();
    // Bu metot IndexOutOfBoundsException veya 
    // NullPointerException fırlatabilir.
    list.addAll(5, null);
}

Bunu desteklemek için test koşturucu programımızda ExceptionTest notasyonunu işleyen kısmı aşağıdaki gibi güncelleyebiliriz:

if (m.isAnnotationPresent(ExceptionTest.class)) {
    tests++;
    try {
        m.invoke(null);
        System.out.printf("Test %s failed: no exception%n", m);
    } catch (Throwable wrappedExc) {
        Throwable exc = wrappedExc.getCause();
        int oldPassed = passed;
        Class<? extends Exception>[] excTypes =
             m.getAnnotation(ExceptionTest.class).value();
        for (Class<? extends Exception> excType : excTypes) {
            if (excType.isInstance(exc)) {
                passed++;
                break; 
            }
        }
        if (passed == oldPassed) {
            System.out.printf("Test %s failed: %s %n", m, exc);
        }
    }
}

Java 8 itibariyle notasyon parametresini diziye çevirmek yerine kullanabileceğimiz ikinci bir seçenek daha vardır. @Repeatable meta notasyonunu kullanarak tekrarlı notasyonlar tanımlamak mümkündür. Şimdi ExceptionTest notasyonu için bunun nasıl yapıldığını görelim:

// Repeatable notasyon
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
    Class<? extends Exception> value();
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
    ExceptionTest[] value();
}

Dikkat ederseniz @Repeatable meta notasyonu parametre olarak bir taşıyıcı notasyon türünün (containing annotation type) Class nesnesini almaktadır.

Şimdi de test metotlarımızı bu yeni teknikle nasıl yazmalıyız ona bakalım:

// tekrarlı notasyon kullanan test metodu örneği
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() { ... }

Gördüğünüz gibi aynı metot için ExceptionTest notasyonunu iki kere kullanabildik. Notasyon tanımlanırken @Repeatable kullandığımız için bu mümkün olmaktadır.

Şimdi tekrarlı notasyonları işlemek için test koşturucu programı nasıl güncellemek gerekiyor ona bakalım:

// Tekrarlı notasyonların işlenmesi
if (m.isAnnotationPresent(ExceptionTest.class)
    || m.isAnnotationPresent(ExceptionTestContainer.class)) {
    tests++;
    try {
        m.invoke(null);
        System.out.printf("Test %s failed: no exception%n", m);
    } catch (Throwable wrappedExc) {
        Throwable exc = wrappedExc.getCause(); 
        int oldPassed = passed; 
        ExceptionTest[] excTests =
            m.getAnnotationsByType(ExceptionTest.class);
        for (ExceptionTest excTest : excTests) {
            if (excTest.value().isInstance(exc)) { 
                passed++;
                break; 
            }
        }
        if (passed == oldPassed) {
            System.out.printf("Test %s failed: %s %n", m, exc);
        }
    }
}

Tekrarlı notasyonlar okunabilirliği artırmak için dile eklenmiştir. Tanımladığınız notasyonun aynı program elemanı için birden çok kez uygulanabilmesini istiyorsanız kullanabilirsiniz. Ancak tanımlanması ve işlenmesi için biraz daha fazla ve karmaşık kod yazmanız gerektiğini unutmayın.

Bu maddede yazdığınız test çatısı tabii ki çok basittir ancak yine de notasyonların isimlendirme modellerine göre ne kadar üstün olduğunu göstermeye yeterlidir. Notasyonların yapabilecekleri burada gösterdiklerimizin çok daha ötesindedir. Siz de programcıların yazdıkları kaynak koda herhangi bir bilgi eklemesini gerektiren araçlar yazarsanız isimlendirme modelleri değil mutlaka notasyonları kullanın.

Bununla beraber, çoğu programcı günlük hayatlarında notasyon yazma ihtiyacı hissetmeyecektir. Ancak bütün programcılar Java tarafından bize sunulan notasyonları gereken yerlerde kullanmalıdırlar. (Madde 40, Madde 27)

Share