Java Akışları neden bir defaya mahsus?


239

IEnumerableBir yürütme hattının istediğimiz kadar çok yürütülebildiği C # 'ların aksine , Java'da bir akış yalnızca bir kez' yinelenebilir '.

Herhangi bir terminal işlemine yapılan çağrı akışı kapatır ve kullanılamaz hale getirir. Bu 'özellik' çok fazla güç tüketir.

Bunun nedeninin teknik olmadığını hayal ediyorum . Bu garip kısıtlamanın arkasındaki tasarım düşünceleri nelerdi?

Düzenleme: Ben neden bahsettiğimi göstermek için, C # Hızlı Sıralama aşağıdaki uygulamasını düşünün:

IEnumerable<int> QuickSort(IEnumerable<int> ints)
{
  if (!ints.Any()) {
    return Enumerable.Empty<int>();
  }

  int pivot = ints.First();

  IEnumerable<int> lt = ints.Where(i => i < pivot);
  IEnumerable<int> gt = ints.Where(i => i > pivot);

  return QuickSort(lt).Concat(new int[] { pivot }).Concat(QuickSort(gt));
}

Şimdi emin olmak için, bunun hızlı sıralama için iyi bir uygulama olduğunu savunmuyorum! Bununla birlikte, akım operasyonu ile birleştirilmiş lambda ifadesinin ifade gücünün harika bir örneğidir.

Ve Java ile yapılamaz! Bir akışa kullanılamaz hale getirilmeden boş olup olmadığını bile soramıyorum.


4
Akışın kapatılmasının "gücü ele geçirdiği" konusunda somut bir örnek verebilir misiniz?
Rogério

23
Bir akıştaki verileri bir kereden fazla kullanmak istiyorsanız, verileri bir koleksiyona atmanız gerekir. Bu hemen hemen o nasıl sahip işe: Ya akımı üretmek için hesaplama yeniden yapmak varsa veya ara sonuç saklamak zorunda.
Louis Wasserman

5
Tamam, ancak aynı hesaplamayı aynı akışta yeniden yapmak yanlış geliyor. Her bir yineleme için yineleyiciler oluşturulduğu gibi, bir hesaplama yapılmadan önce belirli bir kaynaktan bir akış oluşturulur. Hala gerçek bir somut örnek görmek istiyorum; Sonunda, bahse girerim, C # 'un numaralandırılabilirleriyle ilgili bir yol var olduğu varsayılarak, her sorunu bir kez kullanımla akışlarla çözmenin temiz bir yolu vardır.
Rogério

2
Bu ilk başta benim için kafa karıştırıcıydı, çünkü bu sorunun C # leri IEnumerableakışlarıyla ilişkilendireceğini düşündümjava.io.*
SpaceTrucker

9
C # 'da birden çok kez IEnumerable kullanmanın kırılgan bir desen olduğunu, bu nedenle sorunun öncülünün biraz kusurlu olabileceğini unutmayın. IEnumerable'ın birçok uygulaması buna izin verir, ancak bazıları buna izin vermez! Kod analizi araçları sizi böyle bir şey yapmaya karşı uyarma eğilimindedir.
Sander

Yanıtlar:


368

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, mapvb. 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ı Registrantnesnelere ayrıştırır ve iki kez yazdırır. Aslında sadece bir kez basar. registrantsAslında bir yineleyici olduğu zaman bunun bir koleksiyon olduğunu düşündüğü ortaya çıkıyor . İkinci foreachdeğ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 ArrayListbunlar 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, IEnumerablebirden fazla geçişin farklı kaynaklarla farklı davranmasına izin verdiği anlaşılmaktadır ; ve IEnumerablebazı ö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 QuickSortalır IEnumerableve bir döndürür IEnumerable, bu yüzden final IEnumerablegeçilene kadar hiçbir sıralama yapılmaz . Ancak çağrının IEnumerablesyapacağı ş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, IEnumerablekarmaşı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.


35
Her şeyden önce, harika ve küçümseyen cevap için teşekkür ederim! Bu açık arayla en doğru ve açıkladığım nokta. QuickSort örneğine göre, ints konusunda haklısınız. Özyineleme seviyesi arttıkça ilk şişkinlik. Bunun 'gt' ve 'lt' hevesle hesaplanarak (ToArray ile sonuçları toplayarak) kolayca düzeltilebileceğine inanıyorum. Bununla birlikte, bu tarz bir programlamanın beklenmedik performans fiyatına neden olabileceği noktasını kesinlikle desteklemektedir. (İkinci yoruma devam edin)
Vitaliy

18
Öte yandan, C # (5 yıldan fazla) ile yaşadığım deneyimden, bir performans sorununu (veya bir şey düşünülemez hale getirip orada yan etkiler). Bana öyle geliyor ki, API'nin saflığını sağlamak için C # benzeri olasılıklar pahasına çok fazla uzlaşma yapıldı. Bakış açımı ayarlamama kesinlikle yardım ettin.
Vitaliy

7
@Vitaliy Açık fikirli fikir alışverişi için teşekkürler. Bu cevabı araştırıp yazarak C # ve .NET hakkında biraz öğrendim.
Stuart Marks,

10
Küçük yorum: ReSharper, C # ile yardımcı olan bir Visual Studio uzantısıdır. Yukarıdaki QuickSort kodu ile ReSharper her kullanım içinints bir uyarı ekler : "IEnumerable olası çoklu numaralandırma". Aynı işlemi bir IEenumerablekereden fazla kullanmak şüphelidir ve bundan kaçınılmalıdır. Ayrıca .Net yaklaşımı ile bazı uyarıları gösteren (kötü performansın yanı sıra) bu soruyu da işaret ediyorum: Liste <T> ve IEnumerable fark
Kobi

4
@Kobi ReSharper'da böyle bir uyarı olması çok ilginç. Cevabınızın işaretçisi için teşekkürler. C # /. NET'i bilmiyorum, bu yüzden dikkatlice seçmem gerekecek, ancak yukarıda bahsettiğim tasarım endişelerine benzer sorunlar sergiliyor gibi görünüyor.
Stuart Marks

122

Arka fon

Soru basit görünse de, gerçek cevap mantıklı gelmesi için biraz arka plan gerektirir. Sonuca atlamak istiyorsanız aşağı kaydırın ...

Karşılaştırma noktanızı seçin - Temel işlevler

Temel kavramları kullanarak, C # 'ın IEnumerablekonsept daha yakından ilişkilidir Java'nınIterable birçok şekilde oluşturmak mümkün olan yineleyiciler istediğiniz kadar. IEnumerablesoluşturun IEnumerators. Java IterableoluşturmaIterators

Her kavramın tarihi benzerdir, çünkü her ikisi de IEnumerableve Iterable'her biri için' stilinin veri toplama üyeleri üzerinde döngü yapmasına izin vermek için temel bir motivasyona sahiptir. Her ikisi de bundan daha fazlasına izin verdiği için bu aşırı basitleştirmedir ve aynı aşamaya farklı ilerlemelerle de ulaştılar, ancak ne olursa olsun önemli bir ortak özelliktir.

Bu özelliği karşılaştıralım: her iki dilde de, eğer bir sınıf IEnumerable/ öğesini uygularsa, Iterableo sınıf en az tek bir yöntem uygulamalıdır (C # için, bu GetEnumeratorve Java için iterator()). Her durumda, ( IEnumerator/ Iterator) yönteminden döndürülen örnek , verilerin geçerli ve sonraki üyelerine erişmenizi sağlar. Bu özellik, her dil için sözdiziminde kullanılır.

Karşılaştırma noktanızı seçin - Geliştirilmiş işlevsellik

IEnumerableC # 'da diğer dil özelliklerine ( çoğunlukla Linq ile ilgili ) izin verecek şekilde genişletilmiştir . Eklenen özellikler, seçimleri, projeksiyonları, toplamaları vb. İçerir. Bu uzantılar, SQL ve İlişkisel Veritabanı kavramlarına benzer şekilde, set teorisinde kullanımdan güçlü bir motivasyona sahiptir.

Java 8 ayrıca Streams ve Lambdas kullanarak bir dereceye kadar işlevsel programlama sağlamak için işlevsellik ekledi. Java 8 akışlarının öncelikli olarak set teorisi tarafından değil, fonksiyonel programlama tarafından motive edildiğini unutmayın. Ne olursa olsun, birçok paralellik var.

Yani, bu ikinci nokta. C # 'a yapılan geliştirmeler, IEnumerablekonsepte bir geliştirme olarak uygulanmıştır . Java'da olsa da, yapılan geliştirmeler Lambdas ve Akışları yeni baz kavramlarını oluştururken, sonra da gelen dönüştürmek için nispeten önemsiz bir yol oluşturarak uygulanmıştır Iteratorsve IterablesAkışları için ve vize tersi.

Bu nedenle, IEnumerable ile Java'nın Stream konseptinin karşılaştırılması tamamlanmamıştır. Java'daki birleştirilmiş Akışlar ve Koleksiyonlar API'leriyle karşılaştırmanız gerekir.

Java'da Akışlar Yinelenebilir veya Yineleyiciler ile aynı değildir

Akışlar, sorunları yineleyiciler gibi çözmek için tasarlanmamıştır:

  • Yineleyiciler veri sırasını tanımlamanın bir yoludur.
  • Akışlar, bir dizi veri dönüşümü tanımlamanın bir yoludur.

An ile Iterator, bir veri değeri alırsınız, işlersiniz ve sonra başka bir veri değeri elde edersiniz.

Akışlar ile bir dizi işlevi birbirine zincirlersiniz, daha sonra akışa bir giriş değeri beslersiniz ve birleştirilmiş diziden çıktı değerini alırsınız. Java terimleriyle, her işlevin tek bir Streamörnekte kapsüllendiğini unutmayın . Akış API'sı, bir dizi Streamörneği bir dönüşüm ifadeleri zincirini zincirleyecek şekilde bağlamanıza olanak tanır .

StreamKonsepti tamamlamak için akışı beslemek için bir veri kaynağına ve akışı tüketen bir terminal fonksiyonuna ihtiyacınız vardır.

Akıma değerleri besleme şekliniz aslında bir olabilir Iterable, ancak Streamdizinin kendisi bir değil Iterable, bileşik bir işlevdir.

A Streamayrıca tembel olmak için tasarlanmıştır, çünkü sadece ondan bir değer talep ettiğinizde çalışır.

Akışların şu önemli varsayımlarına ve özelliklerine dikkat edin:

  • StreamJava'daki A bir dönüşüm motorudur, bir durumdaki bir veri öğesini başka bir duruma dönüştürür.
  • akışların veri düzeni veya konumu hakkında hiçbir fikri yoktur, sadece istedikleri her şeyi dönüştürürler.
  • akışlar, diğer akışlar, Yineleyiciler, Yinelenebilirler, Koleksiyonlar,
  • "dönüşümü yeniden programlamak" gibi bir akışı "sıfırlayamaz". Veri kaynağını sıfırlamak istediğiniz şeydir.
  • akışta herhangi bir zamanda mantıksal olarak yalnızca 1 veri öğesi 'uçuşta' vardır (akış paralel bir akış olmadığı sürece, bu noktada iş parçacığı başına 1 öğe yoktur). Bu, akışa gönderilmeye hazır 'mevcut' öğeden daha fazlasına sahip olabilen veri kaynağından veya birden çok değeri toplayıp azaltması gerekebilecek akış toplayıcıdan bağımsızdır.
  • Akımlar sadece veri kaynağı ile sınırsız (sonsuz) veya toplayıcı (sonsuz da olabilir) olabilir.
  • Akımlar 'zincirlenebilir', bir akışın filtrelenmesinin çıktısı başka bir akıştır. Bir akışa giren ve bir akış tarafından dönüştürülen değerler, farklı bir dönüşüm gerçekleştiren başka bir akışa verilebilir. Dönüştürülmüş haldeki veriler bir akıştan diğerine akar. Müdahaleye gerek yoktur ve verileri bir akıştan alıp bir sonrakine takmanız gerekmez.

C # Karşılaştırma

Bir Java Akışının bir tedarik, akış ve toplama sisteminin yalnızca bir parçası olduğunu ve Akışlar ve Yineleyicilerin Koleksiyonlarla birlikte kullanıldığını düşündüğünüzde, aynı kavramlarla ilişkilendirmenin zor olması şaşırtıcı değildir. neredeyse hepsi IEnumerableC # ' da tek bir konsepte gömülü .

IEnumerable (ve yakın ilgili kavramlar) bölümleri tüm Java Yineleyici, Yinelenebilir, Lambda ve Akış kavramlarında belirgindir.

Java kavramlarının yapabileceği ve IEnumerable'da daha zor olan küçük şeyler vardır ve bunun tersi de geçerlidir.


Sonuç

  • Burada bir tasarım sorunu yok, sadece diller arasındaki kavramların eşleştirilmesinde bir sorun var.
  • Akışlar sorunları farklı bir şekilde çözüyor
  • Akışlar Java'ya işlevsellik ekler (bir şeyler yapmanın farklı bir yolunu ekler, işlevselliği ortadan kaldırmazlar)

Akış eklemek, problemleri çözerken size daha fazla seçenek sunar;

Java Akışları neden bir defaya mahsus?

Bu soru yanlış yönlendirilmiştir, çünkü akışlar veri değil işlev dizileridir. Akışı besleyen veri kaynağına bağlı olarak, veri kaynağını sıfırlayabilir ve aynı veya farklı akışı besleyebilirsiniz.

Bir yürütme hattının istediğimiz kadar çok yürütülebildiği C # IEnumerable'dan farklı olarak, Java'da bir akış yalnızca bir kez 'yinelenebilir'.

Bir karşılaştırma IEnumerablebir etmek Streamsaflık olur. Söylemek için kullandığınız bağlam, istediğiniz IEnumerablekadar çok kez yürütülebilir, Java ile karşılaştırıldığında en iyisidir Iterables, bu da istediğiniz kadar yinelenebilir. Java , veri sağlayan alt kümesi değil Stream, IEnumerablekavramın bir alt kümesini temsil eder ve bu nedenle 'yeniden çalıştırılamaz'.

Herhangi bir terminal işlemine yapılan çağrı akışı kapatır ve kullanılamaz hale getirir. Bu 'özellik' çok fazla güç tüketir.

İlk ifade bir anlamda doğrudur. 'Gücü alır' ifadesi değildir. Hala bu Akışları karşılaştırıyorsunuz. Akıştaki terminal işlemi, for döngüsünde bir 'break' cümlesi gibidir. İsterseniz ve ihtiyacınız olan verileri yeniden sağlayabiliyorsanız, her zaman başka bir akışa sahip olmakta özgürsünüz. Yine, eğer bu ifade için, IEnumerabledaha çok bir gibi olduğunu düşünüyorsanız Iterable, Java bunu iyi yapar.

Bunun nedeninin teknik olmadığını hayal ediyorum. Bu garip kısıtlamanın arkasındaki tasarım düşünceleri nelerdi?

Nedeni tekniktir ve basit bir nedenden dolayı bir Stream'in ne olduğunu düşündüğünün bir alt kümesidir. Akış alt kümesi veri kaynağını kontrol etmez, bu nedenle akışı değil, kaynağı sıfırlamanız gerekir. Bu bağlamda, o kadar garip değil.

QuickSort örneği

Hızlı sıralama örneğinizin imzası vardır:

IEnumerable<int> QuickSort(IEnumerable<int> ints)

Girişi IEnumerableveri kaynağı olarak görüyorsunuz:

IEnumerable<int> lt = ints.Where(i => i < pivot);

Ek olarak, dönüş değeri IEnumerablede, bu bir veri kaynağıdır ve bu bir Sıralama işlemi olduğundan, bu sarf malzemesinin sırası önemlidir. Java Iterablesınıfının bunun için uygun eşleşme olduğunu düşünüyorsanız, özellikle Listuzmanlaşma Iterable, Liste garantili bir sipariş veya yineleme içeren bir veri kaynağı olduğundan, kodunuza eşdeğer Java kodu şöyle olur:

Stream<Integer> quickSort(List<Integer> ints) {
    // Using a stream to access the data, instead of the simpler ints.isEmpty()
    if (!ints.stream().findAny().isPresent()) {
        return Stream.of();
    }

    // treating the ints as a data collection, just like the C#
    final Integer pivot = ints.get(0);

    // Using streams to get the two partitions
    List<Integer> lt = ints.stream().filter(i -> i < pivot).collect(Collectors.toList());
    List<Integer> gt = ints.stream().filter(i -> i > pivot).collect(Collectors.toList());

    return Stream.concat(Stream.concat(quickSort(lt), Stream.of(pivot)),quickSort(gt));
}    

(Yinelediğim) bir hata olduğunu unutmayın, sıralama yinelenen değerleri incelikle işlemez, bu bir 'benzersiz değer' sıralamadır.

Ayrıca Java kodunun veri kaynağını ( List) nasıl kullandığını ve farklı noktalarda akış kavramlarını nasıl kullandığını ve C # 'da bu iki' kişiliğin 'sadece ifade edilebileceğini unutmayın IEnumerable. Ayrıca, Listtaban türü olarak kullanmama rağmen , daha genel kullanabilirdim Collectionve küçük bir yineleyici-Akış dönüşümüyle, daha genel olanı kullanabilirdimIterable


9
Bir akışı 'yinelemeyi' düşünüyorsanız, yanlış yapıyorsunuzdur. Bir akış, bir dönüşüm zincirindeki belirli bir zamanda veri durumunu temsil eder. Veri sisteme bir akış kaynağına girer, daha sonra bir akıştan diğerine akar, sonunda toplanana, azaltılana veya dökülene kadar durum değiştirir. A Stream, 'döngü işlemi' değil, tam zamanında bir kavramdır .... (devam)
rolfl

7
Bir Akışla, akışa X gibi görünen ve Y'ye benzeyen akıştan çıkan verileriniz olur. Akışın bu dönüşümü gerçekleştiren bir işlevi vardır f(x)Akış, işlevi kapsül içine alır, üzerinden geçen verileri kapsamaz
rolfl

4
IEnumerableayrıca rastgele değerler sağlayabilir, bağlanmamış olabilir ve veriler var olmadan etkin hale gelebilir.
Arturo Torres Sánchez

6
@Vitaliy: Bunu alan birçok yöntem, IEnumerable<T>birden çok kez yinelenebilen sonlu bir koleksiyonu temsil etmesini bekler. Yinelenebilen ancak bu koşulları karşılamayan bazı şeyler faturaya uymadığı için uygulanır IEnumerable<T>, ancak birden fazla kez tekrarlanabilen sonlu koleksiyonlar bekleyen yöntemler, bu koşullara uymayan yinelenebilir şeyler verildiğinde çökmeye eğilimlidir. .
supercat

5
Kişisel quickSortbir iade eğer örnek daha basit olabilir Stream; iki .stream()arama ve bir .collect(Collectors.toList())arama kaydeder. Eğer kod Collections.singleton(pivot).stream()ile değiştirilirseniz Stream.of(pivot)neredeyse okunabilir hale gelir ...
Holger

22

Streams Spliteratordurum bilgisi olan, değişebilen nesneler etrafında inşa edilmiştir . Bir "sıfırlama" eylemleri yoktur ve aslında, bu geri sarma eylemini desteklemelerini gerektiren "çok fazla güç alır". Random.ints()Böyle bir talebi nasıl ele almalı?

Öte yandan, geri Streamçekilebilir bir kökene sahip olanlar için, Streamtekrar kullanılacak bir eşdeğeri oluşturmak kolaydır . Sadece Streamyeniden kullanılabilir bir yöntem oluşturmak için yapılan adımları koymak . Tüm bu adımlar tembel işlemler olduğundan, bu adımları tekrarlamanın pahalı bir işlem olmadığını unutmayın; fiili çalışma terminal işlemi ile başlar ve fiili terminal işletimine bağlı olarak tamamen farklı kodlar çalıştırılabilir.

Böyle bir yöntemin yazarı, yöntemi iki kez çağırmanın ne anlama geldiğini belirtmek size bağlıdır: değiştirilmemiş bir dizi veya koleksiyon için oluşturulan akışlarla tam olarak aynı diziyi üretir mi yoksa benzer semantikler, ancak rastgele ints akışı veya konsol giriş çizgileri akışı gibi farklı öğeler.


Bu arada, önlemek karışıklığa, bir terminal işlemi tüketirStream farklı olan kapatmaStream arama gibi close()akışı (akışları için gerekli olan, örneğin, üretilen gibi kaynaklar sahip bulunmaktadır etmez Files.lines()).


Görünüşe göre çok fazla kafa karışıklığı IEnumerableile karşılaştırmayı yanlış yönlendirmekten kaynaklanıyor Stream. An IEnumerable, bir gerçek sağlama yeteneğini temsil eder IEnumerator, bu yüzden IterableJava'daki gibi . Buna karşılık, a Streambir tür yineleyici ve karşılaştırılabilir, bu IEnumeratornedenle bu tür veri türlerinin .NET'te birden çok kez kullanılabileceğini iddia etmek yanlıştır, destek IEnumerator.Resetisteğe bağlıdır. Burada tartışılan örnekler, yeni birIEnumerable şeyler getirmek için kullanılabileceği ve Java'larla da çalıştığı gerçeğini kullanır ; yeni bir tane alabilirsiniz . Java geliştiricileri işlemleri doğrudan eklemeye karar verdiyse , ara işlemler başka bir işlem döndürüyorsa IEnumeratorCollectionStreamStreamIterableIterable, gerçekten karşılaştırılabilirdi ve aynı şekilde çalışabilirdi.

Ancak, geliştiriciler buna karşı karar verdiler ve karar bu soruda tartışıldı . En büyük nokta, istekli Koleksiyon operasyonları ve tembel Akış operasyonları hakkındaki karışıklıktır. .NET API'ye bakarak, ben (evet, kişisel olarak) haklı buluyorum. IEnumerableTek başına bakmak makul görünse de , belirli bir Koleksiyon, Koleksiyonu doğrudan manipüle eden birçok yönteme ve tembel bir şekilde dönen birçok yönteme IEnumerablesahipken, bir yöntemin belirli doğası her zaman sezgisel olarak tanınmaz. Bulduğum en kötü örnek (ona baktığım birkaç dakika içinde), List.Reverse()adı tamamen birbiriyle çelişen bir davranışa sahipken , kalıtsalın adıyla tam olarak eşleşen (bu uzantı yöntemleri için doğru terminal mi?) Enumerable.Reverse().


Tabii ki, bunlar iki farklı karardır. Birincisi /' Streamden farklı bir tür yapan ve ikincisi başka bir tür tekrarlanabilir olmaktan ziyade bir tür yineleyici yapan. Ancak bu karar birlikte verildi ve bu iki kararın birbirinden ayrılması asla dikkate alınmamış olabilir. .NET ile karşılaştırılabilir olarak yaratılmadı.IterableCollectionStream

Gerçek API tasarım kararı, iyileştirilmiş bir yineleyici türü olan Spliterator. Spliterators, eski Iterables (bunlar sonradan nasıl uyarlanır) veya tamamen yeni uygulamalar tarafından sağlanabilir . Daha sonra, Streamoldukça düşük seviyelere Spliterators bir üst seviye ön uç olarak eklenmiştir . Bu kadar. Farklı bir tasarımın daha iyi olup olmayacağı hakkında tartışabilirsiniz, ancak bu, şimdi tasarlanma şekli göz önüne alındığında, verimli değil, değişmeyecektir.

Dikkate almanız gereken başka bir uygulama yönü daha var. Streams'nin olup değişmez veri yapıları. Her bir ara işlem Streameskisini çevreleyen yeni bir örnek döndürebilir , ancak bunun yerine kendi örneğini de manipüle edebilir ve kendisini döndürebilir (bu, aynı işlem için her ikisini de yapmayı engellemez). Yaygın olarak bilinen örnekler, başka bir adım eklemeyen, ancak tüm boru hattını manipüle eden parallelveya benzeri işlemlerdir unordered). Böyle değişken bir veri yapısına sahip olmak ve aynı anda birden çok kez tekrar kullanmak (veya daha da kötüsü kullanmak) iyi sonuç vermiyor…


Tamlık için, burada Java StreamAPI'ye çevrilen hızlı örnek örneği . Gerçekten çok fazla güç almadığını gösterir.

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {

  final Optional<Integer> optPivot = ints.get().findAny();
  if(!optPivot.isPresent()) return Stream.empty();

  final int pivot = optPivot.get();

  Supplier<Stream<Integer>> lt = ()->ints.get().filter(i -> i < pivot);
  Supplier<Stream<Integer>> gt = ()->ints.get().filter(i -> i > pivot);

  return Stream.of(quickSort(lt), Stream.of(pivot), quickSort(gt)).flatMap(s->s);
}

Gibi kullanılabilir

List<Integer> l=new Random().ints(100, 0, 1000).boxed().collect(Collectors.toList());
System.out.println(l);
System.out.println(quickSort(l::stream)
    .map(Object::toString).collect(Collectors.joining(", ")));

Daha kompakt yazabilirsiniz

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {
    return ints.get().findAny().map(pivot ->
         Stream.of(
                   quickSort(()->ints.get().filter(i -> i < pivot)),
                   Stream.of(pivot),
                   quickSort(()->ints.get().filter(i -> i > pivot)))
        .flatMap(s->s)).orElse(Stream.empty());
}

1
Tüketin ya da tüketmeyin, tekrar tüketmeye çalışmak, akışın zaten kapalı olduğu , tüketilmediğine dair bir istisna atar . Rastgele tamsayılar akışını sıfırlama problemine gelince, bir sıfırlama işleminin kesin sözleşmesini tanımlamak kütüphanenin yazarına bağlıdır.
Vitaliy

2
Hayır, mesaj “akış zaten çalıştırıldı veya kapatıldı” ve biz bir “sıfırlama” işlemi hakkında konuşmuyorduk Stream, ancak kaynakların sıfırlanması anlamına gelirken iki veya daha fazla terminal işlemi çağırıyoruz Spliterator. Ve bunun mümkün olup olmadığından eminim, SO hakkında “Neden her seferinde count()iki kez arama yapmak Streamher zaman farklı sonuçlar veriyor ” gibi bir soru vardı , vb.
Holger

1
Count () için farklı sonuçlar vermek kesinlikle geçerlidir. count (), bir akıştaki bir sorgudur ve akış değiştirilebilirse (veya daha kesin olmak gerekirse, akış değiştirilebilir bir koleksiyondaki bir sorgunun sonucunu temsil ediyorsa) beklenir. C # 'ın API'sına bir göz atın. Bütün bu meseleleri incelikle ele alıyorlar.
Vitaliy

4
“Kesinlikle geçerli” dediğin şey sezgisel bir davranıştır. Sonuçta, aynı şekilde olması beklenen sonucu farklı şekillerde işlemek için bir akışı birden çok kez kullanma sorusunun ana motivasyonu. StreamŞimdiye kadar s'nin yeniden kullanılamayan doğası hakkındaki SO ile ilgili her soru, terminal işlemlerini defalarca (açıkça, aksi takdirde fark etmiyorsunuz) çağırarak bir sorunu çözme girişiminden kaynaklanıyor ve StreamAPI izin verirse sessizce çözülmüş bir çözüme yol açtı. her değerlendirmede farklı sonuçlarla İşte güzel bir örnek .
Holger

3
Aslında, örneğin bir programcı birden fazla terminal işlemi uygulamanın sonuçlarını anlamadıysa ne olacağını mükemmel bir şekilde gösterir. Bu işlemlerin her biri tamamen farklı bir dizi öğeye uygulanacağı zaman ne olacağını düşünün. Akışın kaynağı her sorguda aynı öğeleri döndürdüyse çalışır, ancak bu tam olarak bahsettiğimiz yanlış varsayımdır.
Holger

8

Yeterince yakından baktığınızda ikisi arasında çok az fark olduğunu düşünüyorum.

Yüzünde, IEnumerablea yeniden kullanılabilir bir yapı gibi görünüyor:

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

foreach (var n in numbers) {
    Console.WriteLine(n);
}

Ancak, derleyici aslında bize yardımcı olmak için biraz iş yapıyor; aşağıdaki kodu oluşturur:

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

IEnumerator<int> enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext()) {
    Console.WriteLine(enumerator.Current);
}

Numaralandırılabilir üzerinde her yineleme yaptığınızda, derleyici bir numaralandırıcı oluşturur. Sayıcı tekrar kullanılamaz; daha sonraki çağrılar MoveNextsadece false değerini döndürür ve bunu başlangıca sıfırlamanın bir yolu yoktur. Numaraları tekrarlamak isterseniz, başka bir numaralandırıcı örneği oluşturmanız gerekir.


IEnumerable'ın bir Java Stream ile aynı 'özelliğe' sahip olduğunu daha iyi göstermek için, sayıların kaynağı statik bir koleksiyon olmayan bir numaralandırmayı düşünün. Örneğin, 5 rastgele sayı dizisi oluşturan numaralandırılabilir bir nesne oluşturabiliriz:

class Generator : IEnumerator<int> {
    Random _r;
    int _current;
    int _count = 0;

    public Generator(Random r) {
        _r = r;
    }

    public bool MoveNext() {
        _current= _r.Next();
        _count++;
        return _count <= 5;
    }

    public int Current {
        get { return _current; }
    }
 }

class RandomNumberStream : IEnumerable<int> {
    Random _r = new Random();
    public IEnumerator<int> GetEnumerator() {
        return new Generator(_r);
    }
    public IEnumerator IEnumerable.GetEnumerator() {
        return this.GetEnumerator();
    }
}

Şimdi önceki dizi tabanlı numaralandırılabilir kodla çok benzer bir kod var, ancak ikinci bir yineleme numbers:

IEnumerable<int> numbers = new RandomNumberStream();

foreach (var n in numbers) {
    Console.WriteLine(n);
}
foreach (var n in numbers) {
    Console.WriteLine(n);
}

İkinci kez yinelediğimizde numbers, aynı anlamda tekrar kullanılamayan farklı bir sayı dizisi elde edeceğiz. Ya da, RandomNumberStreambirden çok kez yinelemeye çalışırsanız, numaralandırmayı gerçekten kullanılamaz hale getirir (Java Akışı gibi) için bir istisna atmak için yazabilirdik.

Ayrıca, bir numaraya uygulandığında numaralandırılabilir hızlı sıralama türünüzün anlamı RandomNumberStreamnedir?


Sonuç

Yani, en büyük fark, .NET'in bir dizideki öğelere erişmesi gerektiğinde arka planda IEnumerableyeni bir şekilde oluşturarak yeniden kullanmanıza izin vermesidir IEnumerator.

Bu örtük davranış genellikle yararlıdır (ve belirttiğiniz gibi 'güçlü'), çünkü bir koleksiyon üzerinde tekrar tekrar yineleyebiliriz.

Ancak bazen, bu örtük davranış aslında sorunlara neden olabilir. Veri kaynağınız statik değilse veya erişimi pahalıysa (veritabanı veya web sitesi gibi), ilgili birçok varsayımın IEnumerableatılması gerekir; yeniden kullanmak o kadar basit değil


2

Stream API'deki bazı "bir kez çalıştır" korumalarının bazılarını atlamak mümkündür; örneğin ( doğrudan değil java.lang.IllegalStateException) referans vererek ve yeniden kullanarak istisnalardan ("akış zaten çalıştırılmış veya kapatılmıştır" mesajı ile) kaçınabiliriz .SpliteratorStream

Örneğin, bu kod bir istisna atmadan çalışacaktır:

    Spliterator<String> split = Stream.of("hello","world")
                                      .map(s->"prefix-"+s)
                                      .spliterator();

    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);


    replayable1.forEach(System.out::println);
    replayable2.forEach(System.out::println);

Ancak çıktı ile sınırlı olacaktır

prefix-hello
prefix-world

çıktıyı iki kez tekrarlamak yerine. Bunun nedeni, kaynak ArraySpliteratorolarak kullanılan durumun Streamdurum bilgisi olması ve geçerli konumunu depolamasıdır. Bunu tekrar oynadığımızda Streamsonunda tekrar başlıyoruz.

Bu sorunu çözmek için birkaç seçeneğimiz var:

  1. Vatansız bir Streamoluşturma yönteminden yararlanabiliriz Stream#generate(). StreamDurumu kendi kodumuzda harici olarak yönetmeli ve "tekrarlar" arasında sıfırlamalıyız :

    Spliterator<String> split = Stream.generate(this::nextValue)
                                      .map(s->"prefix-"+s)
                                      .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    this.resetCounter();
    replayable2.forEach(System.out::println);
  2. Bunun bir başka (biraz daha iyi ama mükemmel değil) çözümü , mevcut sayacı sıfırlamak için bir miktar kapasite içeren kendi ArraySpliterator(veya benzer Streamkaynağımızı) yazmaktır . Eğer bunu üretmek için kullanırsak, Streamonları başarılı bir şekilde tekrar oynatabiliriz.

    MyArraySpliterator<String> arraySplit = new MyArraySpliterator("hello","world");
    Spliterator<String> split = StreamSupport.stream(arraySplit,false)
                                            .map(s->"prefix-"+s)
                                            .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    arraySplit.reset();
    replayable2.forEach(System.out::println);
  3. Bu soruna (bence) en iyi çözüm , yeni operatörler çağrıldığında boru hattında Spliteratorkullanılan herhangi bir durum bilgisi olan yeni bir kopyasını yapmaktır . Bu daha karmaşık ve uygulanması da dahil olmakla birlikte, üçüncü taraf kitaplıklarını kullanmanın bir sakıncası yoksa, cyclops-tepki tam olarak bunu yapan bir uygulamaya sahiptir. (Açıklama: Bu projenin baş geliştiricisiyim.)StreamStreamStream

    Stream<String> replayableStream = ReactiveSeq.of("hello","world")
                                                 .map(s->"prefix-"+s);
    
    
    
    
    replayableStream.forEach(System.out::println);
    replayableStream.forEach(System.out::println);

Bu yazdırılacak

prefix-hello
prefix-world
prefix-hello
prefix-world

beklenildiği gibi.

Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.