Streams API'nın ilk tasarımından tasarım mantığına biraz ışık tutabilecek bazı hatırlamalara sahibim.
2012 yılında, dile lambdalar ekledik ve lambdalar kullanılarak programlanan ve paralelliği kolaylaştıracak, koleksiyon odaklı ya da "toplu veri" bir dizi operasyon istedik. Birlikte tembel zincirleme fikri bu noktada iyi bir şekilde belirlenmiştir. Ara işlemlerin sonuçları saklamasını da istemedik.
Karar vermemiz gereken temel sorunlar, zincirdeki nesnelerin API'de nasıl göründüğü ve veri kaynaklarına nasıl bağlandıklarıydı. Kaynaklar genellikle koleksiyonlardı, ancak bir dosyadan veya ağdan gelen verileri ya da anında üretilen verileri, örneğin rastgele bir sayı üreticisinden desteklemek istedik.
Mevcut çalışmanın tasarım üzerinde birçok etkisi vardı. Daha etkili olanlar arasında Google'ın Guava kütüphanesi ve Scala koleksiyon kütüphanesi vardı. (Herkes bu Guava etkisi, not konusunda sürpriz ise Kevin Bourrillion , Guava kurşun geliştirici, oldu JSR-335 Lambda . Uzman grubu) Scala koleksiyonları, biz özel ilgi olması Martin Odersky tarafından bu konuşma bulundu: geleceğe Scala Koleksiyonlarını Prova Etme: Değişkenden Kalıcıya Paralel'e . (Stanford EE380, 1 Haziran 2011)
O zamanlar prototip tasarımımız etrafındaydı Iterable
. Bilinen işlemler filter
, map
vb. Uzantı (varsayılan) yöntemlerdi Iterable
. Birini çağırmak zincire bir işlem ekledi ve diğerini döndürdü Iterable
. Gibi bir terminal işlemi zinciri kaynağa count
çağırır iterator()
ve işlemler her aşamanın yineleyicisi içinde uygulanır.
Bunlar tekrarlanabilir olduğu için, iterator()
yöntemi bir kereden fazla çağırabilirsiniz . O zaman ne olmalı?
Kaynak bir koleksiyonsa, bu çoğunlukla işe yarar. Koleksiyonlar yinelenebilir ve her iterator()
etkin durumdan bağımsız olan farklı bir yineleyici örneği üretmeye çağırılır ve her biri koleksiyonu bağımsız olarak gezer. Harika.
Peki ya kaynak tek adımda ise, bir dosyadan satır okumak gibi? Belki ilk yineleyici tüm değerleri almalı ancak ikinci ve sonraki değerler boş olmalıdır. Belki de değerler Yineleyiciler arasına yerleştirilmelidir. Ya da belki her yineleyici aynı değerleri almalıdır. Peki, iki yineleyiciniz varsa ve biri diğerinden daha ileri giderse? Birisi okunana kadar ikinci Yineleyicideki değerleri arabelleğe almalıdır. Daha da kötüsü, bir Iterator alıp tüm değerleri okursanız ve ancak o zaman ikinci bir Iterator alırsanız. Değerler şimdi nereden geliyor? Birisinin ikinci bir Yineleyici istemesi durumunda hepsinin arabelleğe alınması için bir gereksinim var mı ?
Açıkçası, tek seferlik bir kaynak üzerinde birden fazla Yineleyiciye izin vermek birçok soruya neden olur. Onlar için iyi cevaplarımız yoktu. iterator()
İki kez ararsanız olacaklar için tutarlı ve öngörülebilir bir davranış istedik . Bu, bizi birden fazla çaprazlamaya izin vermemeye itti ve boru hatlarını tek atış yaptı.
Ayrıca başkalarının bu sorunlara çarptığını gözlemledik. JDK'da çoğu Yinelenebilir, birden fazla gezintiye izin veren koleksiyonlar veya koleksiyon benzeri nesnelerdir. Hiçbir yerde belirtilmemiş, ancak Iterables'ın çoklu geçişe izin verdiği yazılı olmayan bir beklenti var gibi görünüyordu. Dikkate değer bir istisna NIO DirectoryStream arabirimidir. Spesifikasyonu bu ilginç uyarıyı içerir:
DirectoryStream Yinelenebilir'i genişletirken, yalnızca tek bir Yineleyiciyi desteklediği için genel amaçlı Yinelenebilir değildir; ikinci veya sonraki bir yineleyiciyi elde etmek için yineleyici yönteminin çağrılması IllegalStateException özel durumunu atar.
[orijinalinde kalın]
Bu, alışılmadık ve tatsız görünüyordu, sadece bir kez olabilecek bir dizi yeni Iterable oluşturmak istemedik. Bu bizi Yinelenebilir kullanmaktan uzaklaştırdı.
Bu zaman zarfında, Bruce Eckel'in Scala ile ilgili bir sorun yaşadığını anlatan bir makalesi çıktı. Bu kodu yazmıştı:
// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)
Oldukça basit. Metin satırlarını Registrant
nesnelere ayrıştırır ve iki kez yazdırır. Aslında sadece bir kez basar. registrants
Aslında bir yineleyici olduğu zaman bunun bir koleksiyon olduğunu düşündüğü ortaya çıkıyor . İkinci foreach
değer, tüm değerlerin tükendiği boş bir yineleyici ile karşılaşır, böylece hiçbir şey yazdırmaz.
Bu tür deneyimler bizi, birden fazla çaprazlama denemesi halinde açıkça tahmin edilebilir sonuçlara sahip olmanın çok önemli olduğuna ikna etti. Ayrıca, tembel boru hattı benzeri yapıları veri depolayan gerçek koleksiyonlardan ayırmanın önemini de vurguladı. Bu da tembel boru hattı operasyonlarının yeni Stream arayüzüne ayrılmasını sağladı ve doğrudan Koleksiyonlar üzerinde sadece istekli, mutasyona uğramış operasyonları korudu. Brian Goetz bunun gerekçesini açıkladı .
Koleksiyon tabanlı boru hatları için birden fazla geçişe izin vermeye, ancak koleksiyon tabanlı olmayan boru hatları için izin vermemeye ne dersiniz? Tutarsız, ama mantıklı. Ağdan değerleri okuyorsanız , elbette bunları tekrar geçemezsiniz. Onları birden çok kez dolaşmak istiyorsanız, bunları açıkça bir koleksiyona çekmeniz gerekir.
Ancak, koleksiyon tabanlı boru hatlarından çoklu geçişe izin vermeyi keşfedelim. Diyelim ki bunu yaptınız:
Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);
( into
İşlem artık hecelenmiştir collect(toList())
.)
Kaynak bir koleksiyonsa, ilk into()
çağrı kaynağa geri bir Yineleyiciler zinciri oluşturur, boru hattı işlemlerini yürütür ve sonuçları hedefe gönderir. İkinci çağrı into()
başka bir Yineleyiciler zinciri oluşturacak ve boru hattı işlemlerini tekrar gerçekleştirecektir . Bu açık bir şekilde yanlış değildir, ancak tüm filtre ve harita işlemlerini her öğe için ikinci kez gerçekleştirme etkisi vardır. Bence birçok programcı bu davranıştan şaşıracaktı.
Yukarıda belirttiğim gibi, Guava geliştiricileriyle konuşuyorduk. Sahip oldukları harika şeylerden biri , nedenleriyle birlikte uygulamaya karar vermedikleri özellikleri açıkladıkları bir Fikir Mezarlığıdır . Tembel koleksiyon fikri kulağa hoş geliyor, ama işte bunun hakkında söylemek zorundalar. Aşağıdakileri döndüren bir işlemi düşünün :List.filter()
List
Buradaki en büyük endişe, çok fazla işlemin pahalı, doğrusal zaman önermeleri haline gelmesidir. Bir listeye filtre uygulamak ve sadece bir Koleksiyon veya Yinelenebilir bir liste değil, bir listeyi geri almak istiyorsanız, ImmutableList.copyOf(Iterables.filter(list, predicate))
ne yaptığını ve ne kadar pahalı olduğunu "ön plana çıkaran" kullanabilirsiniz .
Belirli bir örnek vermek gerekirse, bir Listenin get(0)
veya Listenin maliyeti size()
nedir? Sık kullanılan sınıflar için ArrayList
bunlar O (1). Ancak bunlardan birini tembel olarak filtrelenmiş bir listede çağırırsanız, filtreyi destek listesinin üzerinde çalıştırması gerekir ve aniden bu işlemler O (n) olur. Daha da kötüsü, her operasyonda destek listesinden geçmesi gerekiyor.
Bu bize çok tembellik gibi geldi. Bazı işlemleri ayarlamak ve siz "Go" yapana kadar gerçek yürütmeyi ertelemek bir şeydir. İşleri, potansiyel olarak büyük miktarda yeniden hesaplamayı gizleyecek şekilde ayarlamak başka bir şeydir.
Paul Sandoz , doğrusal olmayan veya "tekrar kullanılmayan" akışlara izin vermemeyi teklif ederek , "beklenmedik veya kafa karıştırıcı sonuçlar" yaratmalarına izin vermenin potansiyel sonuçlarını açıkladı . Ayrıca paralel yürütmenin işleri daha da zorlaştıracağını belirtti. Son olarak, işlem beklenmedik bir şekilde birden çok kez veya programlayıcının beklediğinden farklı sayıda yürütülürse, yan etkileri olan bir boru hattı işleminin zor ve belirsiz hatalara yol açacağını da ekleyeceğim. (Ama Java programcıları yan etkileri olan lambda ifadeleri yazmazlar, değil mi?
Bu, Java 8 Streams API tasarımı için tek adım geçişe izin veren ve kesinlikle doğrusal (dallanmayan) bir boru hattı gerektiren temel mantık. Birden fazla farklı akış kaynağında tutarlı davranışlar sağlar, tembelliği istekli işlemlerden açıkça ayırır ve basit bir yürütme modeli sağlar.
İle ilgili olarak IEnumerable
, ben C # ve .NET konusunda bir uzman olmaktan uzak, bu yüzden herhangi bir yanlış sonuç çıkarırsanız (yavaşça) düzeltilmesini takdir ediyorum. Bununla birlikte, IEnumerable
birden fazla geçişin farklı kaynaklarla farklı davranmasına izin verdiği anlaşılmaktadır ; ve IEnumerable
bazı önemli yeniden hesaplamalara neden olabilecek iç içe operasyonların dallanma yapısına izin verir . Farklı sistemlerin farklı denemeler yaptığını takdir etsem de, bunlar Java 8 Streams API'sının tasarımında kaçınmak istediğimiz iki özellik.
OP tarafından verilen hızlı sıralama örneği ilginç, şaşırtıcı ve biraz dehşet verici olduğunu söylediğim için üzgünüm. Arama bir QuickSort
alır IEnumerable
ve bir döndürür IEnumerable
, bu yüzden final IEnumerable
geçilene kadar hiçbir sıralama yapılmaz . Ancak çağrının IEnumerables
yapacağı şey, gerçekte yapmadan, quicksort'un yapacağı bölümlemeyi yansıtan bir ağaç yapısı oluşturmaktır . (Sonuçta bu tembel bir hesaplamadır.) Kaynağın N öğeleri varsa, ağaç en geniş genişliğinde N öğeleri olacak ve lg (N) seviyeleri derin olacaktır.
Bana öyle geliyor - ve bir kez daha, ben bir C # veya .NET uzmanı değilim - bu, pivot seçimi gibi belirli zararsız görünümlü çağrıların ints.First()
göründüğünden daha pahalı olmasına neden olacak. İlk seviyede, elbette, O (1). Ancak ağacın derinliklerinde, sağ kenardaki bir bölümü düşünün. Bu bölümün ilk öğesini hesaplamak için, kaynağın tamamı bir O (N) işleminden geçirilmelidir. Ancak yukarıdaki bölümler tembel olduğundan, yeniden hesaplanması gerekir, bu da O (lg N) karşılaştırmaları gerektirir. Bu nedenle, pivotun seçilmesi, tüm çeşit kadar pahalı bir O (N lg N) işlemi olacaktır.
Ama geri dönüşü geçene kadar sıralamıyoruz IEnumerable
. Standart hızlı sıralama algoritmasında, her bölümlendirme düzeyi bölüm sayısını iki katına çıkarır. Her bölüm sadece boyutun yarısıdır, bu nedenle her seviye O (N) karmaşıklığında kalır. Bölüm ağacı O (lg N) yüksek, bu yüzden toplam çalışma O (N lg N).
Tembel IEnumerables ağacı ile ağacın altında N bölümleri vardır. Her bölümün hesaplanması için, her biri ağaç üzerinde lg (N) karşılaştırmaları gerektiren bir N öğesinin geçişi gerekir. Ağacın altındaki tüm bölümleri hesaplamak için O (N ^ 2 lg N) karşılaştırmaları gerekir.
(Bu doğru mu? Buna inanamıyorum. Birisi lütfen bunu benim için kontrol et.)
Her durumda, IEnumerable
karmaşık hesaplama yapılarını oluşturmak için bu şekilde kullanılabilecek gerçekten de havalı . Ancak, hesaplama karmaşıklığını düşündüğüm kadar arttırırsa, bu şekilde programlamanın, son derece dikkatli olmadığı sürece kaçınılması gereken bir şey olduğu görülmektedir.