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

One Reply to “Effective Java Madde 39: Notasyonları İsimlendirme Modellerine Tercih Edin”

Bir Cevap Yazın