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.
[…] 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 […]