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

One Reply to “Effective Java Madde 46: Stream Kullanırken Yan Etkisi Olmayan Fonksiyonları Tercih Edin”

Bir Cevap Yazın