Effective Java Madde 45: Streamleri Akıllıca Kullanın

Stream API Java 8 ile dile eklenmiştir ve sıralı veya paralel toplu işlemleri (bulk operations) kolaylaştırmak amacıyla kullanılır. Bu API stream denilen sonlu veya sonsuz sayıda eleman ve stream hattı (stream pipeline) denilen ve bu elemanlar üzerinde aşamalı olarak hesaplama yapan iki parçadan oluşur. Stream elemanlarının kaynağı diziler, koleksiyonlar (collections), dosyalar (files), rastgele sayı üreticiler veya başka streamler olabilir. Bu elemanların türleri de nesne referansları veya int, long, double temel türleri olabilir.

Bir stream hattı sıfır veya daha fazla ara işlem (intermediate operation) ve bir tane sonlandırıcı işlemden (terminal operation) oluşur. Her ara işlem stream elemanları üzerinde bir dönüşüm gerçekleştirir: her elemanı bir fonksiyona sokmak veya bunları belli bir filtrelemeden geçirmek gibi. Her ara işlemin sonucu kendinden sonrakine girdi olarak verilir. Sonlandırıcı işlem ise en son yapılan ara işlemden gelen elemanları son bir işleme tabi tutarak bir sonuç üretir: elemanları bir koleksiyona kaydetmek, bunlardan bir tanesini döndürmek veya hepsini yazdırmak gibi.

Stream hatlarının işletilmesi geciktirilir (lazy execution), yani hesaplama sonlandırıcı işlem çağrılmadan başlamaz. Asıl sonucu o ürettiği için sonlandırıcı işlem açısından gerekli olmayan elemanları işlemek için zaman harcanmaz. Bu sayede streamlerin sonsuz sayıda elemanla çalışabilmesi mümkün kılınmıştır. Sonlandırıcı işlem içermeyen bir stream hattı hiçbir işlem yapmayacaktır, o yüzden eklemeyi unutmayın.

Kendi haline bırakırsanız stream hatları sıralı (sequential) olarak işletilirler. Bu işletimi paralele dönüştürmek için tek yapmanız gereken stream hattında parallel metodunu çağırmaktır ancak bu çoğu zaman önerilmez. (Madde 48)

Stream API kullanarak pratikte aklınıza gelen bütün hesaplamaları yapmanız mümkündür ancak bu her zaman stream kullanmanız gerektiği anlamına gelmez. Doğru kullanıldığında streamler hesaplamaları çok kısaltır ve anlaşılır hale getirir. Yanlış kullanıldığında ise okunabilirliği bozar ve bakım yapmayı zorlaştırır. Ne zaman stream kullanılması gerektiği ile ilgili çok keskin kurallar olmasa da belli önerilerde bulunmak mümkündür.

Aşağıdaki programı ele alalım. Bu program sözlük olarak tanımlanmış bir dosyadan kelimeleri okumakta ve anagram gruplarını yazdırmaktadır. Kullanıcı yazdırılacak anagram gruplarının minimum kaç kelimeden oluşması gerektiğini belirlemektedir. Hatırlayın, iki kelime aynı harfleri içeriyor ama bu harflerin sırası farklı ise anagramdır, “coin” ve “icon” gibi. Program kullanıcınının belirttiği dosyadan kelimeleri okumakta ve bunları bir map içerisine eklemektedir. Anahtar olarak her kelimenin alfabetik olarak sıralanmış biçimi kullanılmaktadır. Örneğin hem “staple” hem de “petals” kelimeleri için anahtar değeri “aelpst” olacaktır. Alfabetik sıralandığında aynı değeri ürettikleri için bu iki kelime anagram olacaktır. Map değeri olarak da aynı alfabetik sıralamaya sahip kelimelerin bir listesi, yani anagram grupları tutulmaktadır. Program daha sonra map üzerinden values() metodunu çağırarak eleman sayısı kullanıcının belirlediği değerin üzerinde olan grupları yazdırmaktadır. Bu program stream yerine yineleme (iteration) kullanarak yazılmıştır.

// sözlükteki anagram gruplarını yazdıran program - yinelemeli
public class Anagrams {
    public static void main(String[] args) throws IOException {
        File dictionary = new File(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);
        Map<String, Set<String>> groups = new HashMap<>();
        try (Scanner s = new Scanner(dictionary)) {
            while (s.hasNext()) {
                String word = s.next(); 
                groups.computeIfAbsent(alphabetize(word),
                       (unused) -> new TreeSet<>())
                       .add(word);
            } 
        }
        for (Set<String> group : groups.values()) {
            if (group.size() >= minGroupSize) {
                System.out.println(group.size() + ": " + group);
            }
        }
    }
    private static String alphabetize(String s) {
        char[] a = s.toCharArray();
        Arrays.sort(a);
        return new String(a);
    } 
}

Bu programın 10 ve 11. satırlarında kullandığımız ve Java 8’le dile eklenen computeIfAbsent metodundan bahsetmekte yarar var. Bu metot map içinde ilk parametresi ile belirtilen anahtar değer var mı yok diye kontrol eder. Varsa bu anahtara karşılık gelen değeri döndürür. Yoksa da anahtarı bir fonksiyona sokarak (ikinci parametre) buna karşılık gelen bir değer üretir, bunu map’e ekler ve aynı zamanda geri döndürür. Bu metot bir anahtar için birden fazla değer tutulan map gerçekleştirimlerini kolaylaştırmaktadır.

Şimdi de aynı problemi çözen aşağıdaki programa bakalım. Buradaki fark streamlerin aşırı derecede kullanılmış olmasıdır. Dikkat ederseniz try bloğunun içindeki bütün kod aslında tek bir ifadeden (expression) oluşuyor.

// Streamlerin aşırı kullanımı - bunu yapmayın!
public class Anagrams {
    public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);
        try (Stream<String> words = Files.lines(dictionary)) {
            words.collect(
                groupingBy(word -> word.chars().sorted()
                     .collect(StringBuilder::new,
                     (sb, c) -> sb.append((char) c),
                     StringBuilder::append).toString()))
            .values().stream()
                .filter(group -> group.size() >= minGroupSize)
                .map(group -> group.size() + ": " + group)
                .forEach(System.out::println);
        } 
    }
}

Bu kodu okumakta ve anlamakta zorlanıyorsanız üzülmeyin, yalnız değilsiniz. Daha kısadır ama okunabilirliği zayıftır. Özellikle stream API ile çok çalışmamış programcılar bunu anlamakta güçlük çekeceklerdir. Streamleri bu şekilde aşırı kullanmak programların okunabilirliğini düşürür ve bakım yapmayı zorlaştırır.

Neyse ki streamleri ve yinelemeyi beraber kullanarak bir orta yol bulmak mümkündür. Aşağıdaki program aynı problemi çözmektedir ve streamleri kullanmasına rağmen aşırıya kaçmamıştır. Ortaya çıkan kod orijinal versiyona göre çok daha anlaşılır ve kısadır:

// orantılı stream kullanımı kodu kısaltır ve daha anlaşılır yapar
public class Anagrams {
    public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);
        try (Stream<String> words = Files.lines(dictionary)) {
            words.collect(groupingBy(word -> alphabetize(word)))
               .values().stream()
               .filter(group -> group.size() >= minGroupSize) 
               .forEach(group -> 
                  System.out.println(group.size() + ": " + group));
        } 
    }
    private static String alphabetize(String s) {
        char[] a = s.toCharArray();
        Arrays.sort(a);
        return new String(a);
    }
}

Streamlerle daha önce çok çalışmamış olsanız bile yukarıdaki kodu anlamak çok zor değildir. Stream elemanlarını edinmek için try-with-resources ile dosyayı açıp kelimeleri okuyoruz. Stream hattı içinde ara işlem yapılmıyor, sonlandırıcı işlem olan collect bütün kelimeleri alfabetik sıralanmış biçimlerini kullanarak bir map içinde grupluyor. (Madde 46) Bu map programın yineleme kullanılan versiyonundaki map ile aynıdır. Daha sonra bu map üzerinden values metodu çağrılarak yeni bir stream oluşturuluyor. Bu yeni stream içindeki elemanlar anagram gruplarını temsil etmektedir. Daha sonra ara işlem yapan filter metodu sayesinde gruptaki anagram sayısı minGroupSize değerinden daha az olan gruplar eleniyor. Son olarak kalan elemanların her biri için forEach metodu çağrılarak yazdırılıyor.

Bu programda lambda parametre isimlerinin dikkatlice seçildiğini gözden kaçırmayın. Lambda ifadelerinde tür bilgisi bulunmadığı için parametre isimlerinin dikkatli seçilmesi stream hatlarının okunabilirliğini artırmak açısından çok önemlidir.

Ayrıca alfabetik sıralama işleminin farklı bir metotta yapılıyor olması da önemlidir. Bu sayede metoda bir isim vererek ne iş yaptığı açıkça belirtilmiş durumdadır. Gerektiği durumlarda metot dokümantasyonu da yapılabilir. Bu gibi yardımcı metotların kullanılması da stream hatlarının okunabilirliğini ciddi ölçüde artırır.

alphabetize metodunu yazmak için de farklı bir stream kullanabilirdik ama bunu doğru biçimde yazmak daha zor olurdu. Ortaya çıkan kod ise hem daha az anlaşılır olurdu hem de daha yavaş çalışırdı. Bunun sebebi de Java’nın temel char türünden streamleri desteklememesidir. char değerlerini stream kullanarak işlemeye çalıştığımızda ne gibi sorunlar ortaya çıktığını anlamak için aşağıdaki koda bakalım:

"Hello world!".chars().forEach(System.out::print);

Bu kodun Hello world! yazdırmasını beklersiniz ama aslında 721011081081113211911111410810033 yazdırır. Bunun sebebi "Hello world!".chars() çağrısından gelen stream elemanlarının char değil int türünde olmasıdır. Bu sebeple print fonksiyonu int değerler yazdıracaktır. Bunu düzeltmek için kodu aşağıdaki gibi güncelleyebilirsiniz:

"Hello world!".chars().forEach(x -> System.out.print((char) x));

Ancak en doğrusu char değerleri işlemek için stream kullanmaktan kaçınmaktır.

Streamleri kullanmaya başladıktan sonra bütün döngüleri stream ile değiştirmek isteyebilirsiniz ancak bu doğru bir yaklaşım değildir. Her ne kadar bu mümkün olsa da büyük ihtimalle programın okunabilirliği ve bakım yapılabilirliği zorlaşacaktır. Hesaplama yapmanın kısmen karmaşık olduğu durumlarda bile Anagrams programında olduğu gibi streamler ile yinelemeyi beraber kullanmak daha doğru olacaktır. Bu yüzden streamleri sadece mantıklı olduğu durumlarda kullanın.

Bu maddede gördüğünüz program örneklerinden anlaşılacağı üzere stream hatları fonksiyon nesneleri (lambda veya metot referansları) kullanarak elemanlar üzerinde tekrarlı hesaplama yapmaktadır. Yinelemeli (iterative) kod ise tekrarlı hesaplama yaparken kod bloklarını kullanmaktadır. Kod blokları ile yapılabilen ancak fonksiyon nesneleri ile yapılamayan şeyler vardır:

  • Kod bloğu ile kapsam (scope) içindeki yerel değişkenleri okuyabilir ve değiştirebilirsiniz. Lambda ile bunları değiştirmeniz mümkün değildir. Okuyabilmek içinse final veya effectively final olması gerekir. (final anahtar kelimesi ile tanımlanmasa bile ilk değer atamasından sonra değeri değiştirilmeyen değişkenler effectively final olarak kabul edilir.)
  • Kod bloğu kullandığınızda çevreleyen metottan çıkış yapabilir, döngülerin işletimini break veya continue ile değiştirebilir veya metodun tanımladığı bir aykırı durumu fırlatabilirsiniz. Lambda ile bunların hiç birisini yapamazsınız.

Eğer bir hesaplama yaparken bu yöntemlere başvurmanız gerekiyorsa stream kullanmak çok isabetli olmayabilir. Diğer taraftan, streamlerin güçlü olduğu alanlar da vardır:

  • Bir dizi elemanın homojen olarak dönüşüme tabi tutulması
  • Elemanların filtrelenmesi
  • Tek bir işlemle bir dizi elemanın toplanması, minimum değer hesaplanması, ard arda eklenmesi vs.
  • Bir dizi elemanın gruplanarak bir koleksiyona yazılması
  • Bir dizi eleman içerisinde belli kriterlere göre arama yapılması

Bir hesaplama en iyi bu yöntemlerle ifade edilebiliyorsa stream kullanmak mantıklı olacaktır.

Stream kullanımını zorlaştıran etmenlerden birisi de bir ara işlem yapıldıktan sonra önceki değerlerin kaybolmasıdır. Örneğin ara işlem olarak bir değeri başka bir değere dönüştürürseniz ilk değer kaybolacaktır. Orijinal değerlere ihtiyacınız varsa eski ve yeni değerleri birbirine eşleyen ayrı bir veri yapısı tutabilirsiniz ama bu çok verimli olmayacaktır. Özellikle bunu birden fazla ara işlem için yapmanız gerekiyorsa ortaya karmaşık ve anlaması zor bir kod çıkacaktır. Bunun yerine stream elemanlarının önceki değerlerine erişmemiz gerektiği durumlarda yaptığımız ara işlemi tersinden uygulamak daha mantıklı olacaktır.

Örnek olarak, ilk yirmi Mersenne asal sayısını yazdıran bir program yazalım. Merssenne sayıları 2p-1 şeklinde ifade edilebilen sayılardır. p bir asal sayı ise Merssenne sayısı da asal olabilir, bunlara da Mersenne asal sayıları denir. Bunları bulabilmek için önce bütün asal sayıları içeren bir stream yaratmamız gerekmektedir. Aşağıda bu sonsuz streami döndüren bir metot görüyorsunuz. BigInteger sınıfının statik üyelerine erişimi kolaylaştırmak için static import kullanıldığını varsayıyoruz:

static Stream<BigInteger> primes() {
    return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}

Buradan metot ismi olarak kullanılan primes tesadüfen seçilmiş değildir. Bu gibi stream döndüren metotlarda çoğul metot isimleri kullanmak stream hatlarının okunabilirliğini artırmaktadır. Metot Stream.iterate fabrika metodunu kullanmakta ve iki parametre almaktadır. İlk parametre streamin ilk elemanını, ikinci parametre ise önceki elemanı kullanarak streamin sonraki elemanlarını hesaplamak için gerekli fonksiyonu temsil etmektedir. İlk yirmi Mersenne asal sayısını hesaplayan programı aşağıda görüyorsunuz:

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);
}

Program asal sayıları döndüren primes metodunu çağırarak bir stream edinmekte ve map metodu ile bu asal sayıları Mersenne sayılarına dönüştürmektedir. Mersenne sayıları arasındaki Mersenne asal sayıları filter metodu sayesinde elde ediliyor ve yazdırılıyor. Yirmi tane bulunduğu zaman hesaplama durduruluyor.

Şimdi varsayalım ki Mersenne asal sayılarını yazdırırken bunun hangi p değeri kullanılarak üretildiğini de yazdırmak istiyoruz. Bu değer sadece en başta elde ettiğimiz stream içerisinde mevcuttur, sonuçları yazdırdığımız sonlandırıcı işlem yapılırken bu değerlere erişim yoktur. Neyse ki bunu hesaplamak için map ile yapılan dönüşümü tersine çevirmek mümkündür. p değeri Mersenne sayısının ikili gösterimindeki bit sayısı ile aynıdır, dolayısıyla sonlandırıcı işlemi aşağıdaki gibi değiştirerek p değerlerini de yazdırabiliriz:

.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));

Stream mi yoksa yineleme mi kullanmak gerektiğine net olarak yanıt bulamadığınız durumlar da olacaktır. Örneğin bir iskambil destesinin yaratılması işlemini düşünelim. Bunun için elimizde değiştirilemeyen (immutable) bir Card sınıfı ve kartların numarasını temsil eden Rank, takımları temsilen de Suit isimli iki enum olsun. Her Card nesnesi Rank ve Suit türlerinde birer değere sahip olacaktır. İskambil destesinin tamamını oluşturmak için bu iki enum türünde tanımlı değerlerin bütün kombinasyonlarını kullanmamız gerekecektir. Matematikte buna kartezyen çarpımı denilmektedir. Aşağıda bu işi yapmak için for-each döngüsü kullanan yinelemeli yaklaşımı görüyorsunuz:

// yinelemeli (iterative) yaklaşım
private static List<Card> newDeck() {
    List<Card> result = new ArrayList<>();
    for (Suit suit : Suit.values()) {
        for (Rank rank : Rank.values()) {
            result.add(new Card(suit, rank));
         }
     }   
    return result;
}

Şimdi de flatMap metodunu ara işlem olarak kullanan stream tabanlı yaklaşıma bakalım. Bu işlem bir stream içindeki bütün elemanları başka bir streamdeki elemanlarla eşlemekte ve sonra bunları birleştirmektedir. Ayrıca burada iç içe iki tane lambda fonksiyonu kullanılmıştır:

// Stream tabanlı yaklaşım
private static List<Card> newDeck() {
    return Stream.of(Suit.values())
        .flatMap(suit ->
             Stream.of(Rank.values())
                .map(rank -> new Card(suit, rank)))
        .collect(toList());
}

Burada tanımladığımız iki newDeck metodundan hangisini tercih etmeliyiz? Bu noktada artık kişisel tercihlere ve programlama yaptığımız ortama göre karar vermeliyiz. İlk versiyon daha basit ve doğal bir yaklaşım gibi görünüyor. Birçok programcı bunu anlamakta zorluk çekmeyecektir ama stream tabanlı versiyonu tercih edenler de olacaktır. Stream kullanmaya alışıksanız ve fonksiyonel programlamaya aşinaysanız bu yaklaşım size daha anlaşılır gelebilir. Eğer siz de stream versiyonunu daha anlaşılır buluyorsanız ve kodla ilgilenen diğer programcıların da zorluk çekmeyeceğini düşünüyorsanız o zaman stream kullanmalısınız.

Özetle, bazı işleri stream bazı işleri de yineleme kullanarak yapmak daha doğrudur. Ancak bu ikisinin birlikte kullanılması gereken durumlar da sıkça karşımıza çıkar. Her ne kadar keskin kurallar olmasa da bu yazıda verilen örneklerden yola çıkarak bir kestirimde bulunabilirsiniz. Birçok durumda hangi yolu kullanmanız gerektiği açıkça ortada olacaktır ancak bazen de arada kalacaksınız. Eğer emin olamazsanız iki yöntemi de deneyip hangisinin daha mantıklı olduğunu gözlemleyebilirsiniz.

Share