Effective Java Madde 9: try-finally Yerine try-with-resources Tercih Edin

Java kütüphaneleri yazılımcının close metodunu çağırarak kapatması gereken birçok kaynak barındırır. Bunlara örnek olarak InputStream, OutputStream ve java.sql.Connection verilebilir. Bu kaynakları kapatmak (serbest bırakmak), istemciler tarafından sıklıkla unutulur ve bu durum ciddi performans sorunlarına yol açar. Bu kaynaklar her ne kadar finalizer (sonlandırıcı) kullanarak istemci tarafında yapılacak hatalara bir önlem almaya çalışsalar da, pratikte biz sonlandırıcıların iyi çalışmadığını biliyoruz. (Madde 8)

Önceleri, try-finally ifadesi kullandığımız kaynakların bir istisna (exception) fırlatıldığında dahi doğru biçimde kapatılmasını garanti eden en iyi yoldu.

// try-finally - Artık kaynakları kapatmanın en iyi yolu değil!
static String firstLineOfFile(String path) throws IOException { 
    BufferedReader br = new BufferedReader(new FileReader(path)); 
    try {
        return br.readLine();
    } finally {
        br.close();
    }
}

Bu çok da kötü görünmüyor, ancak ikinci bir kaynak daha eklediğimizde durum kötüleşiyor.

// try-finally birden fazla kaynakla kullanılınca kod çirkinleşiyor
static void copy(String src, String dst) throws IOException {
    InputStream in = new FileInputStream(src);
    try {
        OutputStream out = new FileOutputStream(dst);
        try {
            byte[] buf = new byte[BUFFER_SIZE];
            int n;
            while ((n = in.read(buf)) >= 0) { 
                out.write(buf, 0, n);
            }
        } finally {
            out.close();
        }
    } finally {
        in.close();
    }
}   

İnanması zor olabilir ama, iyi programcılar bile çoğu zaman bunu yanlış kullanmaktadır. Örneğin, Java Puzzlers kitabının 88. sayfasında ben de hata yaptım ve yıllarca hiç kimse fark etmedi. Dahası, 2007 yılında Java kütüphanelerindeki close metodu kullanımlarının üçte ikisi yanlıştı.

try-finally blokları doğru kullanılarak kod yazıldığı durumlarda bile (yukarıdaki iki örnek gibi), ortada küçük de olsa bir sorun bulunmaktadır. Hem try hem de finally bloklarındaki kodların istisna fırlatma ihtimali bulunmaktadır. Örneğin, ilk örnekteki firstLineOfFile metodunda, try bloğundakireadLine çağrısı fiziksel aygıttaki olası bir problemden dolayı istisna fırlatabilir, finally içerisindeki close çağrısı da aynı sebepten dolayı başarısız olabilir. Bu durumda, ikinci fırlatılan istisna birinciyi tamamen görünmez kılacaktır. Stack trace içerisinde birinci istisna görülmeyecektir, bu da problemi çözmeye çalışan kişileri çok zor durumda bırakacaktır. Çünkü genellikle bir sorunu çözmeye çalışırken ilk hatanın nereden kaynaklandığını görmek önemlidir. Bu sorunu çözmek için yukarıda verilen kodları geliştirmek mümkündür, ancak sonuçta oluşan kod daha karmaşık olduğu için bunu genellikle kimse yapmaz.

Bütün bu problemler Java 7 ile birlikte gelen try-with-resources ifadesiyle çözülmüştür. Bu ifadeyi kullanabilmek için kapatılması gereken kaynakların, void geri döndüren tek bir close metodu içeren AutoClosable arayüzünü uygulaması gerekmektedir. Java kütüphanelerindeki birçok sınıf ve arayüz bugün bu arayüzü kullanmaktadır. Eğer siz de kapatılması gereken bir kaynağı temsil eden bir sınıf yazarsanız, AutoClosable arayüzünü mutlaka uygulamalısınız.

Yukarıda verdiğimiz birinci örneğin try-with-resources ile kullanımı aşağıdaki gibi olacaktır.

// try-with-resources - kaynakları kapatmanın en iyi yolu!
static String firstLineOfFile(String path) throws IOException {
  try (BufferedReader br = new BufferedReader(new FileReader(path))) {
    return br.readLine();
  } 
}

Ve bu da ikinci örneğimizin try-with-resources ile yazılışı:

// try-with-resources birden fazla kaynakla kullanımı 
static void copy(String src, String dst) throws IOException {
    try (InputStream   in = new FileInputStream(src);
         OutputStream out = new FileOutputStream(dst)) {
        
        byte[] buf = new byte[BUFFER_SIZE];
        int n;
        while ((n = in.read(buf)) >= 0) {
            out.write(buf, 0, n);
        }
    }
}

Burada gördüğünüz gibi, istemcinin ayrıca close metodunu çağırmasına gerek kalmamaktadır. try-with-resources kullanıldığında AutoClosable arayüzünden gelen close metodu otomatik olarak çağrılmakta ve istemci tarafında yapılacak hatalara karşı önlem alınmaktadır.

try-with-resources ifadesiyle yazılan kodlar daha kısa ve okunabilir olmasının yanında, hataların teşhisini de kolaylaştırmaktadır. firstLineOfFile metodunu düşünecek olursak, eğer hem readLine metodu hem de görünmez close metodu istisna fırlatırsa, ikinci istisna birinciyi görünmez kılmaz, tam tersine birinci görülebilsin diye ikinci gizlenir (suppressed). Hatta ikiden daha fazla istisna fırlatıldığı durumlarda birden çok istisna da gizlenebilir. Çünkü yazılımcının ilk oluşan hatayı görebilmesi hatanın teşhisi açısından önemlidir. Burada saklanan istisnalar da aslında kaybolmazlar, stack trace içerisinde yine görülebilirler ancak gizlendiklerine dair bir ibare yer alır. Hatta bunlara Java 7’de Throwable sınıfına eklenen getSuppressed metoduyla programatik olarak da erişmek mümkündür.

try-with-resources kullanırken aynen try-finally kullanırken olduğu gibi catch blokları eklemek de mümkündür. Bu, sizlere iç içe katmanlar eklemenize gerek kalmadan kodunuzda oluşabilecek istisnaları ele almanızı sağlar. Pratikte kullanımı pek mantıklı gözükmese de, ilk örneğimizi aşağıdaki gibi değiştirerek istisna fırlatmak yerine, veri okumada bir hata oluştuğunda varsayılan bir değeri döndürecek şekilde yazabiliriz.

// try-with-resources catch bloğuyla kullanımı
static String firstLineOfFile(String path, String defaultVal) {
  try (BufferedReader br = new BufferedReader(new FileReader(path))) {
    return br.readLine();
  } 
  catch (IOException e) {
    return defaultVal;
  } 
}

Buradaki ders çok net: kapatılması gereken kaynaklarla çalışırken try-finally yerine try-with-resources ifadesini kullanmalıyız. Bu sayede ortaya çıkan kod çok daha kısa ve anlaşılır olacaktır, oluşabilecek hatalar sonucunda da teşhis yapmamız kolaylaşacaktır. Bu yeni yöntem, try-finally ile yazılması çok zor ve zahmetli olan kod parçalarının doğru biçimde yazılmasını son derece kolaylaştırmaktadır.

Share

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

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