Effective Java Madde 46: Stream Kullanırken Yan Etkisi Olmayan Fonksiyonları Tercih Edin

Stream kullanmaya yeni başladıysanız alışmak zaman alabilir. Yapmak istediğiniz hesaplamayı stream hatları (stream pipeline) kullanarak ifade etmek zor gelebilir. Bunu başardığınız zaman da size pek bir faydası olmadığını düşünebilirsiniz. Streamler sadece yeni bir API değil, aynı zamanda bir fonksiyonel programlamaya yaklaşımıdır. Streamlerden en üst düzeyde faydalanabilmek için sadece API’ı değil bu yaklaşımı da benimsemelisiniz.

Bu yaklaşımın en önemli tarafı yapmak istediğiniz hesaplamayı aşamalı bir dönüşüm olarak ifade edebilmektir. Bu dönüşümler yapılırken mümkün olduğunca saf fonksiyonlar kullanılması önemlidir. Saf fonksiyonlar sonuçları sadece girdilere (input) bağlı olan ve nesne durumunda değişiklik yapmayan fonksiyonlardır. Bunun mümkün olması için de hem ara hem de sonlandırıcı işlem fonksiyonlarının yan etkisiz olması gerekmektedir. Başka bir deyişle fonksiyonun üreteceği sonuç dış etmenlerden etkilenmemeli ve durum değişikliğine sebep olmamalıdır.

Bazen aşağıdaki gibi yazılmış stream kodlarıyla karşılaşabilirsiniz. Bu program bir dosyadaki kelimelerin hangi sıklıkla yer aldığını hesaplamakta ve bir sıklık tablosunda (frequency table) saklamaktadır:

// Stream API kullanıyor ama fonksiyonel programlama yaklaşımından 
// uzak. YAPMAYIN! 
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word -> {
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    }); 
}

Bu koddaki problem nedir? En nihayetinde Stream API, lambda fonksiyonu ve metot referansı kullanarak doğru hesaplamayı yapıyor. Basitçe söylemek gerekirse bu kod stream kodu değildir, stream kodu kılığına girmiş yinelemeli (iterative) koddur. Stream API’dan herhangi bir fayda sağlamadığı gibi anlaşılması da kısmen zordur. Aynı işi yapan yinelemeli bir kod daha kısa ve anlaşılır olacaktır. Buradaki problemin kaynağı ise bütün işin sonlandırıcı işlem olan forEach metodu içinde yapılıyor olmasıdır. Burada lambda fonksiyonu freq veri yapısında değişiklikler yapmakta, yani bir nesnenin durumunu değiştirmektedir. forEach metodunun önceki ara işlemlerde elde edilen sonuçların raporlanması haricinde kullanımı “kötü kokan kod” (code smell) anlamına gelmektedir. Nesne durumlarında değişiklik yapan lambda fonksiyonları da bu kategoriye girer. Peki bu kod nasıl yazılmalıdır?

// Kelime sıklığını hesaplama - streamlerin doğru kullanımı
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words
        .collect(groupingBy(String::toLowerCase, counting()));
}

Bu kod öncekiyle aynı işi yapmaktadır ve streamlerin doğru kullanımına örnektir. Daha kısa ve temizdir. Peki bunun yerine programcılar neden önceki versiyonu tercih ediyorlar? Bu sorunun cevabı basittir: alışkanlıklar. Java programcıları for döngülerini çok iyi bilirler ve forEach metodu da buna benzediği için ilk tercihleri o olur. Ancak forEach metodu aslında sonlandırıcı stream işlemleri arasında en zayıfı ve fonksiyonel programlama yaklaşımına en aykırı olanıdır, çünkü esasında yinelemeli kod yazmaya teşvik eder. Ayrıca paralel streamlerle birlikte çalışmaya da uygun değildir. Dolayısıyla forEach metodu sadece önceki hesaplamaları raporlamak için kullanılmalıdır, hesaplama yapmak için değil.

Geliştirilmiş kodda kullanılan collect metodu bir toplayıcıyı (collector) temsil etmektedir. Bu, streamleri doğru kullanmak için öğrenilmesi gereken çok önemli bir kavramdır. Toplayıcıları üreten Collectors API otuz dokuz tane metoda sahip olduğu için gözünüzü korkutabilir ama bu detay seviyesi esasında bizi ilgilendirmiyor. Bu karmaşık arayüzü görmezden gelip toplayıcıları bir indirgeme stratejisi içeren nesneler gibi düşünebilirsiniz. Burada indirgemeden kasıt çok sayıdaki stream elemanlarının tek bir nesneyle ifade edilmesidir. Toplayıcı tarafından üretilen bu nesneler genellikle bir koleksiyon (Collection) olur.

Stream elemanlarını bir Collection içinde toplayan üç tane toplayıcı vardır: toList(), toSet() ve toCollection(collectionFactory). Bunlar sırasıyla bir liste (List), bir küme (Set) ve programcı tarafından belirlenen bir koleksiyon türü döndürürler. Bu bilgiyle, az önce yarattığımız kelimelerin sıklık tablosundan en sık kullanılan on kelimeyi bize döndüren bir stream hattı yazabiliriz.

// Sıklık tablosundaki ilk on kelimeyi bulan kod
List<String> topTen = freq.keySet().stream() 
    .sorted(comparing(freq::get).reversed())
    .limit(10)
    .collect(toList());

Dikkat ederseniz toList statik bir metot olmasına rağmen yazarken sınıf adı kullanmadık. Stream hatlarını daha okunabilir hale getirmek için burada olduğu gibi Collectors metotlarının statik olarak import edilmesi mantıklı olacaktır.

Bu kodda üzerinde konuşulması gereken kısım sorted metoduna geçilen comparing(freq::get).reversed() ifadesidir. Buradaki comparing metodu bir Comparator nesnesi üretmektedir (Madde 14). Argüman olarak geçilen freq::get metot referansı ise tabloda anahtarlara karşılık gelen kelimelerin kaç kere kullanıldığını, yani sıklığını döndürmektedir. Son olarak Comparator üzerinden çağrılan reversed metodu ise sıralamayı tersine çevirmektedir, çünkü biz sıralamayı en sık kullanılandan en aza doğru yapmak istiyoruz. Sonrasında bu hesaplamayı on elemanla kısıtlamak için limit metodu çağrılmakta ve sonuçlar bir listede toplanmaktadır.

Peki toplayıcı üretmek için tanımlanmış diğer otuz altı metot ne için var? Bunların çoğu stream elemanlarını koleksiyon yerine bir map içinde toplamak için yazılmıştır ve çok daha karmaşık olabilirler. Bunları kullanırken her stream elemanı bir anahtar ve bir değerle ifade edilecek şekilde dönüştürülmektedir, bazı elemanlar aynı anahtar değerini paylaşabilirler.

En basit map toplayıcısı toMap(keyMapper, valueMapper) şeklinde ifade edilir. İlk fonksiyon parametresi stream elemanlarını bir anahtarla, ikinci fonksiyon ise bir değerle eşlemek için kullanılır. Madde 34’de fromString metodunu yazarken bunu kullanmıştık. Burada anahtar olarak enum sabitinin String formu, değer olarak da enum sabitinin kendisi kullanılmaktadır:

// toMap toplayıcısı kullanarak enum sabitlerinden map oluşturmak
private static final Map<String, Operation> stringToEnum =
    Stream.of(values()).collect(
        toMap(Object::toString, e -> e));

toMap toplayıcısının bu basit versiyonu her stream elemanının kendine özgü (unique) bir anahtarla ifade edilebildiği durumlar için çok uygundur. Ancak birden fazla eleman aynı anahtarla eşleşirse IllegalStateException fırlatılır.

Birden fazla elemanın aynı anahtarla eşleştiği durumları ele almak için toMap metodunun farklı biçimleri veya groupingBy metodu kullanılabilir. Buna örnek olarak toMap metodunun üç parametreli biçimini verebiliriz: toMap(keyMapper, valueMapper, mergeFunction). Burada yeni eklenen mergeFunction parametresi V map için değer türünü ifade etmek üzere BinaryOperator<V> türünde bir fonksiyondur. Aynı anahtarla eşleşen değerler bu fonksiyon kullanılarak birleştirilebilirler. Örneğin, mergeFunction yerine matematiksel çarpım yapan bir fonksiyon geçersek, aynı anahtara sahip olan değerler birbirleriyle çarpılarak tek bir değer üretilecektir.

Üç parametreli toMap versiyonunu başka şekillerde de kullanabiliriz. Farzedelim ki elimizde çeşitli sanatçılara ait albümleri içeren bir stream olsun. Biz de her sanatçıyı en çok satan albümüyle eşleştiren bir map oluşturmak istiyoruz. Aşağıdaki kod işimizi görecektir:

// anahtarları seçilmiş elemanlarla eşleyen toplayıcı örneği
Map<Artist, Album> topHits = albums.collect( 
    toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));

Bu kodda kullanılan maxBy metodu BinaryOperator arayüzünden statik import ile gelmektedir. Bu metot bir Comparator<T> nesnesi alıp bunu BinaryOperator<T>‘ye dönüştürmektedir. Bu örnekte Comparator nesnesi comparing(Album::sales) tarafından üretilmektedir. Bunun anlamı aslında şudur: aynı anahtarla (sanatçı) eşleşen ikinci bir değer (albüm) bulunursa, bunu mevcut değerle satış rakamlarını kullanarak (Album::sales) karşılaştır. İçlerinden hangisi daha fazla satış rakamına sahipse (maxBy) anahtarı o değerle eşleştir. Böylece aynı sanatçıya sahip albümler stream tarafından işlendikçe bu karşılaştırma yapılacak ve sonunda en çok satış rakamına sahip albüm eşlenmiş olacaktır.

Üç parametreli toMap toplayıcısını aşağıdaki gibi de kullanabiliriz:

toMap(keyMapper, valueMapper, (v1, v2) -> v2)

Bu kod aynı anahtarla eşleşen ikinci bir değer bulduğunda ikinci değeri ilk değerin üzerine yazmaktadır, yani ilk değer kaybolmaktadır. Her ne kadar birçok stream için bu kabul edilebilir bir durum olmasa da bu değerlerin birbirleriyle aynı olduğu veya hepsinin geçerli olduğu durumlarda kullanılabilir.

toMap metodunun bir de dört parametreli versiyonu vardır. Dördüncü parametre map üreten bir fabrikayı temsil etmektedir. Sonuçta üretilen map türünü özellikle belirlemek istiyorsanız (EnumMap, TreeMap gibi) bunu kullanabilirsiniz. Ayrıca paralel işletim için kullanılan ConcurrentHashMap üreten versiyonları da toConcurrentMap olarak tanımlanmıştır.

Collectors API toMap metotlarına ek olarak bir de groupingBy sunmaktadır. Bu metot stream elemanlarını gruplayarak bir map ile ifade edilebilmelerini sağlar. Gruplamanın neye göre yapılacağı argüman olarak geçilen sınıflandırma fonksiyonu tarafından belirlenir. Başka bir deyişle bu fonksiyonun görevi bir stream elemanını alıp hangi gruba girdiğini döndürmektir. Bu metodunun en basit hali Madde 45’de anagramları gruplarken kullandığımız şekliyledir:

words.collect(groupingBy(word -> alphabetize(word)))

Hatırlarsanız burada kelimeleri alfabetik olarak sıralayan alphabetize metodu gruplama için kullanılıyordu. Yani alfabetik olarak sıralandığında aynı sonucu üreten kelimeler anagram oldukları için aynı gruba dahil oluyordu. Bu haliyle groupingBy metodu aynı gruptaki birden fazla elemanı temsil etmek için map değerleri olarak liste yapıları kullanır. Bunu değiştirmek için groupingBy metoduna ikinci argüman olarak başka bir toplayıcı geçebilirsiniz. Bu ikincil toplayıcı aynı gruba düşen elemanları tek bir koleksiyona indirgemek için kullanılacaktır. Örneğin toSet() geçerseniz aynı gruptaki elemanlar liste yapılarında değil kümelerde (set) saklanacaktır. Buna alternatif olarak toCollection(collectionFactory) da geçebilirsiniz. Böylece aynı gruptaki elemanların ne tür bir koleksiyonda toplanmasını istediğinizi belirtebilirsiniz.

İki parametreli groupingBy metodunu counting() toplayıcısını geçerek de kullanabilirsiniz. Bu kullanım aynı gruptaki elemanları liste veya küme gibi bir koleksiyonda tutmak yerine sadece gruptaki eleman sayısını tutacaktır. Bu yazının başında gördüğünüz kelimelerin kullanım sıklığını hesaplayan kod bunu yapmaktadır:

Map<String, Long> freq = words 
    .collect(groupingBy(String::toLowerCase, counting()));

groupingBy metodunun üç parametreli versiyonu ise toMap‘de olduğu gibi önceki parametrelere ek olarak map fabrikası geçmemize olanak sağlar. Bunu kullanarak örneğin değerleri TreeSet türünde olan bir TreeMap oluşturabilirsiniz. Paralel işletimde kullanılmak üzere ConcurrentHashMap döndüren groupingByConcurrent metotları da mevcuttur.

counting metodu tarafından döndürülen toplayıcılar groupingBy örneğinde gördüğümüz gibi ikincil toplayıcı olarak kullanılmak için tasarlanmıştır. Aynı işlevsellik count metodu ile streamler için zaten mevcuttur. Bu sebeple collect(counting()) gibi bir kullanım için hiçbir sebep yoktur. Collectors içinde bu özelliği taşıyan on beş tane daha toplayıcı vardır. Bunların içinde isimleri summing, averaging ve summarizing ile başlayan toplayıcılar, reducing metodu ve bunun aşırı yüklenmiş (overloaded) versiyonları, filtering, mapping, flatMapping ve collectingAndThen metotları vardır. Bunlar streamlerde zaten mevcut olan işlevselliği toplayıcılar için tekrarlamaktadır. Buradaki amaç ikincil toplayıcıların küçük bir stream gibi davranabilmesini sağlamaktır.

Collectors metotları içinde yer alan ve sadece String gibi CharSequence nesneleri ile kullanılabilen joining metodundan da bahsetmekte fayda var. Parametresiz kullanıldığında bu metot streamdeki elemanların hepsini arka arkaya ekleyen bir toplayıcı döndürecektir. Tek parametre ile kullanıldığında elemanları arka arkaya eklerken bir ayırıcı belirtebilirsiniz. Örneğin virgül kullanırsanız elemanlar arasına virgül eklenmiş şekilde birleştirilirler. Üç parametreli versiyonunda ise ayrıcının yanında önek (prefix) ve sonek (suffix) belirtebilirsiniz. Sonuçta toplayıcının üreteceği değerler koleksiyonları yazdırdığınızda gördüğünüz gibi olacaktır, örneğin [came, saw, conquered]

Özetle, stream hatlarını programlanın özü yan etkisi olmayan fonksiyonlar kullanmaktır. Sonlandırıcı işlem olan forEach sadece stream hattı tarafından yapılmış hesaplamayı raporlamak için kullanılmalıdır, hesaplamanın kendisi için değil. Streamleri doğru kullanabilmek için toplayıcıları (collector) iyi bilmeniz gerekmektedir. Bunlardan en önemlileri toList, toSet, toMap, groupingBy ve joining toplayıcılarıdır.

Share

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