Effective Java Madde 49: Parametrelerin Geçerliliğini Kontrol Edin

Çoğu metot ve yapıcı metot parametre geçilen değerler için kısıtlamalar koyar. Örneğin dizin (index) değerlerinin pozitif tamsayı olması ve nesne referanslarının null olmaması gibi kısıtlamalarla sıklıkla karşılaşırız. Bu kısıtlamaları metot gövdesinin başında uygulamalı ve açıkça belgelemelisiniz. Bunu yaptığımız taktirde hataların olabildiğince erken saptanması mümkün olabilir, aksi taktirde bunu geciktirmiş oluruz ve bir hata ile karşılaştığımızda nereden kaynaklandığını bulmak zorlaşır.

Bir metoda geçersiz bir parametre verildiğinde, eğer metot kod işletimine başlamadan önce parametrelerin geçerliliğini kontrol ediyorsa erkenden hata üretecek ve mantıklı bir aykırı durum fırlatarak işletimi durduracaktır. Bunu yapmadığı taktirde birkaç farklı durumla karşılaşabiliriz. Birincisi uygulamanız işletimin farklı bir yerinde kafa karıştırıcı bir aykırı durum fırlatabilir. Daha kötüsü, metot işletimini tamamlar ancak yanlış bir sonuç üretebilir. En kötüsü ise bir nesnenin durumunu bozarak ileride tamamen alakasız bir kod parçası işletilirken hataya yol açabilir.

public ve protected erişim belirtecine sahip metotlar için Javadoc’un @throws etiketini kullanarak parametreler geçersiz olduğunda fırlattığınız aykırı durumu belgeleyebilirsiniz. (Madde 74) Genellikle bu aykırı durumlar IllegalArgumentException, IndexOutOfBoundsException, veya NullPointerException olacaktır. (Madde 72) Metot parametrelerine getirdiğiniz kısıtlamaları ve bu kısıtlamalara uyulmadığı taktirde fırlatacağınız aykırı durumları belgeledikten sonra, bunu koda dökmek çok basit olacaktır. İşte bir örnek:

/**
* Metodun yaptığı hesaplamanın detayları..
*
* @param m (modulus), pozitif olmalı
* @return this mod m
* @throws ArithmeticException m sıfıra eşitse veya daha küçükse
*/
public BigInteger mod(BigInteger m) { 
    if (m.signum() <= 0) { 
           throw new ArithmeticException("Modulus <= 0: " + m);
    }
    ... // Hesaplama kodu
}

Dikkat ederseniz bu metot m == null ise NullPointerException üretecektir ancak biz metodu belgelerken bundan bahsetmedik. Bu aykırı durum metodun içinde bulunduğu BigInteger sınıfının kendi dokümantasyonunda belgelenmiştir, bu sebeple metot için tekrar yazmaya gerek yoktur. Bu yöntemi kullanarak sınıf içindeki her bir metot için ayrı ayrı NullPointerException belgelemekten kaçınabilirsiniz.

Java 7 ile dile eklenen Objects.requireNonNull metodu null kontrolü için esnek ve kolay bir seçenek sunmaktadır. Bu sebeple null kontrolünü kendiniz yapmanıza gerek yoktur. Bu metot aykırı durum için istediğiniz bir mesajı geçmenize izin verir ve geçtiğiniz parametreyi geri döndürdüğü için değer ataması yaparken de kullanabilirsiniz:

// Java'da null denetimi için örnek kullanım 
this.strategy = Objects.requireNonNull(strategy, "strategy");

Tabii ki isterseniz bu geri dönüş değerini yok sayabilirsiniz ve sadece null kontrolü için kullanabilirsiniz.

Java 9’la birlikte java.util.Objects sınıfına checkFromIndexSize, checkFromToIndex ve checkIndex gibi aralık kontrolü (range checking) yapan metotlar da eklenmiştir. Bunlar her ne kadar requireNonNull kadar kullanışlı ve esnek olmasa da ihtiyacınızı karşıladığı taktirde kullanabilirsiniz.

Dışarıya açık olmayan metotlar için (private ve package-private), istemcinin kontrolü tamamen elinizde olacaktır. Bu metotların parametrelerini denetlemek için assert anahtar kelimesini kullanabilirsiniz.

private static void sort(long a[], int offset, int length) {
    assert a != null;
    assert offset >= 0 && offset <= a.length;
    assert length >= 0 && length <= a.length - offset; 
    ... // Metot gövdesi
}

Esas itibarıyla bu assert ifadeleri, belirtilen koşulun doğru (true) olması gerektiğini simgelerler ve istemcilerin paketinizi nasıl kullandığından etkilenmezler. Normal geçerlilik kontrollerinin aksine, assert ifadeleri eğer true üretmezlerse AssertionError hatası fırlatırlar. Assert ifadeleri bilinçli olarak etkinleştirilmedikleri sürece devre dışı olurlar, aktifleştirmek için java komutuna -ea (veya -enableassertions) parametresini geçmek gerekir. Bu ifadelerle ilgili daha fazla bilgi için buraya bakabilirsiniz.

Eğer bir metot geçilen parametreleri kendisi kullanmıyor ancak daha sonra kullanılmak üzere bir alanda saklıyorsa, bu parametrelerin geçerliliğini denetlemek daha büyük bir öneme sahip olmaktadır. Örneğin Madde 20‘de yazdığımız, parametre olarak bir int dizisi alıp bu dizinin List görünümünü döndüren intArrayAsList isimli statik fabrika metodunu hatırlayalım. Bu metoda istemci null geçerse, Objects.requireNonNull denetiminden dolayı NullPointerException fırlatılacaktır. Bu denetimin koddan çıkartıldığını varsayarsak, metod bir List döndürecektir ancak istemci bunu kullanmaya çalıştığı anda NullPointerException hatası alacaktır. Ancak istemcinin kullanım anında bu List nesnesinin kaynağını anlamak kolay olmayabilir ve bu hatanın çözülmesini güçleştirebilir.

Bazı durumlarda ise bir hesaplama yapmak için kullanacağımız parametrelerin geçerliliğini önceden kontrol etmek çok da mantıklı olmayabilir. Buna örnek olarak, kendisine verilen nesneleri sıralayan Collections.sort(List) metodunu verebiliriz. Sıralamanın yapılabilmesi için verilen listedeki nesnelerin birbiriyle karşılaştırılabilir olması gerekmektedir. Eğer sıralama esnasında buna aykırı bir durum bulunursa ClassCastException hatası üretilecektir ve aslında sort metodunun yapması gereken de budur. Dolayısıyla, listedeki elemanları birbirleriyle karşılaştırılabilir olup olmadığını önceden kontrol etmek bize çok bir fayda sağlamayacaktır.

Bu maddeden metot parametrelerine zorunlu olmayan kısıtlamalar koymanın iyi bir şey olduğu sonucunu çıkartmayın. Tam tersine, metotlarınız geçilen parametre değerleriyle mantıklı bir işlem yapabildiği sürece kısıtlamalardan kaçınmaya çalışın ki daha geniş bir kullanım alanı bulabilsin.

Özetle, bir metot veya yapıcı metot yazarken parametreler üzerinde ne gibi kısıtlamalar olması gerektiğini iyice düşünün. Bu kısıtlamaları belgeleyin ve metodun hemen başında gerekli denetimleri uygulayın. Bunu bir alışkanlık haline getirmek önemlidir. Sarf edeceğiniz bu küçük çabanın karşılığını geçerlilik denetimleri hata bulduğu zaman fazlasıyla alacaksınız.

Share

Effective Java Madde 48: Streamleri Paralel Yaparken Dikkatli Olun

Yaygın olarak kullanılan programlama dilleri arasında Java, paralel programlamayı kolaylaştıran araçlar sunma konusunda her zaman en önde olmuştur. 1996’da Java ilk ortaya çıktığında wait/notify mekanizması ile threadleri destekliyordu. Java 5 java.util.concurrent paketiyle beraber paralel koleksiyonlar ve executor yapısını dile eklerken, Java 7 ile fork-join mekanizmasına kavuştuk. Java 8 ise tek bir parallel metot çağrısı sayesinde paralel işletim imkanı sunan streamleri dile ekledi. Java’da paralel işletilen programlar yazmak giderek kolaylaşıyor gibi görünse de, bunu doğru ve yüksek performans alarak yapmak hiç de kolay değildir. Thread güvenliği ve canlılık (liveness) ihlalleri paralel programlamanın doğasında olan sorunlardır ve paralel streamler de bunun bir istisnası değildir.

Madde 45’de yazdığımız bu programı ele alalım:

// İlk 20 Mersenne asal sayısını üreten stream tabanlı program
public static void main(String[] args) {
    primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
        .filter(mersenne -> mersenne.isProbablePrime(50))
        .limit(20)
        .forEach(System.out::println);
}
static Stream<BigInteger> primes() {
    return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}

Bu program benim bilgisayarımda Mersenne asal sayılarını hemen yazdırmaya başlıyor ve 12.5 saniyede sonlanıyor. Peki bunu hızlandırmak niyetiyle stream hattına parallel() çağrısını eklersem ne olur? Paralel işletimi etkinleştirdiğim için program gerçekten de hızlanır mı? Maalesef bunu yaptığımda program hiçbir şey yazdırmıyor, işlemci kullanımı %90’a fırlıyor ve orada kalıyor. Belki uzun süre beklerseniz program sonlanabilir ama ben yarım saat sonra pes edip programı durdurmak zorunda kaldım.

Peki neden böyle oldu? Basitçe söylemek gerekirse, stream mekanizmasının bu işlemi paralel olarak nasıl yapabileceği konusunda hiçbir fikri yok. Stream üretmek için Stream.iterate kullanıyorsanız veya ara işlemlerden bir tanesi limit ise, stream hatlarını paralel yapmak en iyi şartlar altında dahi size performans artışı sağlamaz. Bizim örneğimizdeki stream hattı bu problemlerin ikisine de sahiptir. Paralelleştirme algoritması limit ara işlemini ele alabilmek için fazladan birkaç eleman hesaplamakta bir sakınca görmez. Daha sonra fazladan hesaplanan bu elemanlar atılacaktır. Ancak bu örnekte Mersenne asal sayıları için fazladan bir eleman hesaplamak, kabaca ondan önceki elemanların tamamını hesaplamak için harcanan zaman kadar sürer. Bu sebeple de paralel işletim algoritması çöker. Buradaki ders çok açıktır: Stream hatlarını rastgele paralelleştirmeyin!

Paralel streamlerin yüksek performansla çalışabilmesi için bunların ArrayList, HashMap, HashSet, ConcurrentHashMap nesneleri; diziler, int aralıkları (IntStream.range) veya long aralıkları (LongStream.range) üzerinden yaratılması gerekir. Bütün bu veri yapılarının ortak noktası istenen küçüklükte parçalara kolayca bölünebilmeleridir. Bu da paralel işletimi kolaylaştıran bir durumdur. Stream kütüphanesi bunu yapmak için Stream ve Iterator‘da bulunan spliterator metodunu kullanır.

Bu veri yapılarının ikinci önemli özelliği ise sıralı bir şekilde işlendikleri zaman referans yerelliği (locality of reference) sunmalarıdır. Başka bir deyişle ard arda gelen elemanların referansları bellekte beraber tutulmaktadır. Ancak referansların beraber olması bunların işaret ettiği nesnelerin de bellekte birbirlerine yakın olacağı anlamına gelmez, bu yerelliği azaltan bir faktördür. Referans yerelliği toplu yapılan işlemlerin paralel işlenmesinde çok önemli bir faktördür. Bu olmadığında threadler verinin bellekten işlemciye aktarılması için beklemek zorunda kalırlar. Referans yerelliğini en iyi sağlayan veri yapıları ise temel türlerdeki dizilerdir, çünkü bunlar verinin kendisini bellekte peş peşe saklarlar.

Bir stream hattının sonlandırıcı işlemi de paralel işletimin verimini etkiler. Hesaplamanın zaman alan kısmı ara işlemler değil de sonlandırıcı işlemde yapılıyorsa ve bu işlemin doğası gereği peş peşe yapılması gerekiyorsa paralel işletim pek verimli olmaz. Paralel işletime en uygun sonlandırıcı işlemler indirgeme (reduction) işlemleridir. İndirgeme işlemleri bütün stream elemanlarının reduce, min, max, count veya sum gibi metotlar kullanılarak birleştirilmesi sonucu tek sonuç üretirler. anyMatch, allMatch veya noneMatch gibi sonlandırıcı işlemler de paralel işletimde verimlidirler. Ancak Stream.collect metoduyla kullanılan toplayıcı işlemler pek verimli olmaz çünkü stream elemanlarının bir koleksiyonda toplanmasının getirdiği ek yük fazladır.

Stream hesaplamalarının paralelleştirilmesi kötü performansa sebep olabileceği gibi, programın yanlış sonuçlar üretmesine ve tutarsız davranmasına da sebep olabilir. Bu tür hataların kaynağı stream hatlarında kullanılan fonksiyon nesnelerinin Stream kütüphanesinin tanımladığı bağlayıcı kurallara uymamasıdır. Örneğin, reduce metoduna geçilen toplayıcı ve birleştirici fonksiyonların belirli matematiksel kuralları sağlaması ve durum taşımaması (stateless) gerekir. Bu kurallar ihlal edildiğinde (Madde 46) stream hattı sıralı işletimde düzgün çalışsa bile paralel işletimde büyük ihtimalle çökecektir.

Streamlerin verimli bir biçimde paralel işletilmesi için burada anlatılan bütün kurallara uysanız bile (doğru veri yapısı ve sonlandırıcı işlem seçimi, fonksiyon nesnelerinin paralel işletime uygun olması gibi) paralel işletimden beklediğiniz performans artışını almanız zordur. Bunun sebebi paralel işletimin kendisinin de bir ek yük getirmesidir. Eğer paralel hesaplamadan elde edilen kazanç, paralel işletimin getirdiği ek yükü fazlasıyla karşılayabiliyorsa o zaman bir performans kazanımı mümkün olur.

Bir stream hattını paralel yaparken amacın performans iyileştirmesi olduğunu unutmayın. Bu yüzden de iyileştirme yaparken öncesi ve sonrasındaki performans değerlerini ve üretilen sonuçları karşılaştırın (Madde 67). Bu testlerin gerçekçi bir sistem üzerinde yapılması önemlidir. Bütün paralel stream hatları tek bir fork-join havuzunu kullandıklarından bir tanesinde oluşabilecek hata başka stream hatlarında problemlere sebep olabilir.

Milyonlarca satırlık bir uygulama üzerinde çalışan ve stream kütüphanesini sıklıkla kullanan bir tanıdığım, paralel streamleri sadece birkaç yerde verimli olarak kullanabildiğini söylüyor. Tabii ki bu stream hesaplamalarını hiçbir koşulda paralel yapmayın anlamına gelmez! Doğru şartlar altında bir stream hattına parallel çağrısını ekleyerek işlemci çekirdeği sayısıyla orantılı olarak ciddi bir hız artışı elde edebilirsiniz. Makina öğrenmesi ve büyük çaptaki verilerin işlenmesi gibi alanlarda paralel işletimden ciddi performans kazanımları sağlanmaktadır.

Paralel işletimin verimli olduğu bir stream örneğine bakalım. Aşağıdaki primeCount(n) fonksiyonu, n değerine eşit veya daha küçük olan asal sayıların sayısını vermektedir:

// Paralel streamlerin faydalı olabileceği bir hesaplama
static long primeCount(long n) {
    return LongStream.rangeClosed(2, n)
        .mapToObj(BigInteger::valueOf)
        .filter(i -> i.isProbablePrime(50))
        .count();
}

Benim bilgisayarımda primeCount(108) değerini hesaplamak için 31 saniye geçmesi gerekti. Bu stream hattına sadece parallel() ekleyince bu süre 9.2 saniyeye düştü, yani dört çekirdekli bir işlemcide 3.7 kat hız artışı elde ettik:

// Asal sayıların sayılması - paralel versiyon
static long primeCount(long n) {
    return LongStream.rangeClosed(2, n)
       .parallel()
       .mapToObj(BigInteger::valueOf)
       .filter(i -> i.isProbablePrime(50))
       .count();
}

Eğer rastgele üretilmiş sayılardan oluşan bir stream üzerinde paralel hesaplama yapmak istiyorsanız ThreadLocalRandom yerine SplittableRandom kullanın. SplittableRandom tam olarak bu amaçla yazılmıştır ve paralel işletime çok uygundur. ThreadLocalRandom ise tek bir thread ile çalışmaya müsaittir. Paralel işletimde de çalışacaktır ancak SplittableRandom kadar hız artışı sağlamayacaktır.

Özetle, bir stream hattının doğru sonuçları üreteceğinden ve hız artışı sağlayacağından emin değilseniz paralel yapmaya kalkışmayın. Yanlış durumda yapılan paralel işletimin programın çökmesi veya performansın yerlerde sürünmesi gibi etkileri olabileceğini unutmayın. Eğer bir stream hattını paralel yaparak kazanç sağlayabileceğinizi düşünüyorsanız, gerçekçi bir ortamda mutlaka performansı ve üretilen sonuçları test edin. Sadece ve sadece bu testleri geçtiği taktirde bir stream hattını paralel yapmak yararınıza olacaktır.

Share