Чому потоки Java одноразові?


239

На відміну від C # 's IEnumerable, де конвеєр виконання може бути виконаний стільки разів, скільки ми хочемо, в Java потік можна "повторити" лише один раз.

Будь-який дзвінок до операції терміналу закриває потік, роблячи його непридатним. Ця 'особливість' забирає багато сил.

Я думаю, що причина цього не є технічною. Які дизайнерські міркування стояли за цим дивним обмеженням?

Редагувати: щоб продемонструвати те, про що я говорю, розгляньте таку реалізацію Швидкого сортування у C #:

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));
}

Тепер, щоб бути впевненим, я не прихильник того, що це хороша швидка реалізація! Однак це чудовий приклад вираженої сили лямбда-експресії в поєднанні з потоковою роботою.

І це не можна зробити на Java! Я навіть не можу запитати потік, чи порожній він, не роблячи його непридатним.


4
Чи можете ви навести конкретний приклад, коли закриття потоку "забирає силу"?
Rogério

23
Якщо ви хочете використовувати дані з потоку не один раз, вам доведеться скинути їх у колекцію. Це майже так, як це має працювати: або вам доведеться повторити обчислення, щоб генерувати потік, або ви повинні зберігати проміжний результат.
Луї Вассерман

5
Гаразд, але повторення того ж обчислення в одному потоці звучить неправильно. Потік створюється з даного джерела перед тим, як проводити обчислення, подібно до того, як створюються ітератори для кожної ітерації. Я все одно хотів би бачити фактичний конкретний приклад; Зрештою, я вважаю, що існує чіткий спосіб вирішити кожну проблему за допомогою потоків, що використовуються один раз, припускаючи, що відповідний спосіб існує з перелічувачами C #.
Rogério

2
Спочатку це було IEnumerablejava.io.*
бентежно

9
Зауважте, що використання IE незліченно декількох разів у C # є крихкою схемою, тому передумова питання може бути злегка хибною. Багато реалізацій IEnumerable це дозволяють, але деякі - ні! Інструменти аналізу коду, як правило, попереджають вас проти подібних дій.
Сандер

Відповіді:


368

У мене є деякі спогади з раннього проекту API Streams, які можуть пролити деяке світло на обґрунтування дизайну.

Ще в 2012 році ми додавали до мови лямбдати, і ми хотіли, щоб набір операцій, орієнтованих на колекції або "масових даних", запрограмований за допомогою лямбда, щоб полегшити паралелізм. Ідея ліниво поєднати операції разом була добре утверджена до цього моменту. Ми також не хотіли, щоб проміжні операції зберігали результати.

Основними питаннями, які нам потрібно було вирішити, було те, як виглядали об’єкти в ланцюжку в API та як вони підключились до джерел даних. Джерела часто були колекціями, але ми також хотіли підтримувати дані, що надходять із файлу чи мережі, або дані, що генеруються в ході, наприклад, з генератора випадкових чисел.

Було багато впливів існуючих робіт на дизайн. Серед найбільш впливових - бібліотека Google Guava та колекція бібліотек Scala. (Якщо хто -то дивується про вплив з гуави, зверніть увагу , що Кевін Bourrillion , гуави провідний розробник, був на JSR-335 Lambda . Експертної групи) У колекції Scala, ми знайшли цю розмову по Одерська бути особливий інтерес: перспективну Підтвердження колекцій Scala: від змінних до стійких до паралельних . (Stanford EE380, 1 червня 2011 р.)

Наш прототип в той час був заснований навколо Iterable. Знайомі операції filter, mapі так далі були розширення ( по замовчуванню) методи на Iterable. Виклик одного додав операцію до ланцюга, а інший повернув Iterable. Операція на терміналі, як countби викликала iterator()ланцюг до джерела, і операції здійснювалися в ітераторі кожного етапу.

Оскільки це Інтерабелі, ви можете викликати iterator()метод не один раз. Що тоді має статися?

Якщо джерелом є колекція, це здебільшого добре працює. Колекції є взаємодіючими, і кожен виклик iterator()створює окремий екземпляр Iterator, який не залежить від інших активних екземплярів, і кожен проходить колекцію незалежно. Чудово.

А що робити, якщо джерело однократне, як читання рядків з файлу? Можливо, перший ітератор повинен отримати всі значення, але другий і наступні повинні бути порожніми. Можливо, значення повинні бути переплетені серед Ітераторів. А може, кожен ітератор повинен отримати всі однакові значення. Тоді, що робити, якщо у вас є два ітератори і один стає далі перед іншим? Комусь доведеться запам'ятовувати значення у другому ітераторі, поки вони не будуть прочитані. Гірше, що робити, якщо ви отримаєте одного Ітератора і прочитаєте всі значення, і тільки потім отримаєте другий Ітератор. Звідки беруться значення тепер? Чи є вимога, щоб усі вони були укуповані на випадок, якщо хтось хоче другого Ітератора?

Зрозуміло, що дозволити декілька ітераторів над одноразовим джерелом викликає багато питань. Ми не мали гарних відповідей на них. Ми хотіли послідовної, передбачуваної поведінки щодо того, що станеться, якщо зателефонувати iterator()двічі. Це підштовхнуло нас до заборони декількох траверсалів, зробивши трубопроводи однократними.

Ми також спостерігали, як інші стикаються з цими питаннями. У JDK більшість Iterables - це колекції або схожі на колекцію об’єкти, які дозволяють проводити багаторазове проходження. Це ніде не вказано, але, мабуть, існує неписане сподівання, що Ітерабелі дозволять багаторазове проходження. Помітним винятком є інтерфейс NIO DirectoryStream . Його специфікація включає це цікаве попередження:

Хоча DirectoryStream розширює Iterable, він не є Iterable загального призначення, оскільки він підтримує лише одного Ітератора; виклик методу ітератора для отримання другого або наступного ітератора викидає IllegalStateException.

[напівжирним оригіналом]

Це здалося незвичним і неприємним, що ми не хотіли створювати цілу купу нових Ітерабелів, які можуть бути лише один раз. Це відштовхнуло нас від використання Iterable.

Приблизно в цей час з’явилася стаття Брюса Еккеля, в якій описано місце, яке виникло у Скарлі. Він написав цей код:

// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)

Це досить просто. Він розбирає рядки тексту на Registrantоб'єкти та виводить їх двічі. За винятком того, що він насправді роздруковує їх лише один раз. Виявляється, він думав, що registrantsце колекція, а насправді це ітератор. Другий дзвінок foreachнаштовхується на порожній ітератор, з якого всі значення вичерпані, тому він нічого не друкує.

Такий досвід переконав нас, що дуже важливо мати чітко передбачувані результати, якщо буде здійснено багаторазове обхід. Він також підкреслив важливість розмежування лінивих конвеєрних конструкцій від фактичних колекцій, які зберігають дані. Це, в свою чергу, призвело до розмежування лінивих операцій з трубопроводу в новий інтерфейс Stream і збереження лише нетерплячих, мутативних операцій безпосередньо на Collections. Брайан Гец пояснив обґрунтування цього.

Що робити із дозволом декількох проїздів для трубопроводів, що базуються на зборах, але заборонити його для трубопроводів, що не збираються? Це непослідовно, але розумно. Якщо ви читаєте значення з мережі, звичайно, ви не можете їх знову перейти. Якщо ви хочете їх обміняти кілька разів, вам доведеться чітко перенести їх у колекцію.

Але давайте дослідимо, як дозволити багаторазове проходження від трубопроводів на основі колекцій. Скажімо, ви зробили це:

Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);

( intoОперація зараз написана collect(toList()).)

Якщо джерело - це колекція, то перший into()виклик створить ланцюжок ітераторів назад до джерела, виконає операції з конвеєром та відправить результати в пункт призначення. Другий виклик into()буде створити ще один ланцюжок ітераторів, і виконувати операції трубопроводу знову . Це, очевидно, не так, але це робить ефект виконання всіх операцій з фільтруванням та картою вдруге для кожного елемента. Я думаю, що багато програмістів були б здивовані такою поведінкою.

Як я вже згадував вище, ми спілкувалися з розробниками Guava. Однією з цікавих речей є кладовище Idea, де вони описують особливості, які вони вирішили не застосовувати разом із причинами. Ідея лінивих колекцій звучить досить круто, але ось, що вони мають сказати про це. Розглянемо List.filter()операцію, яка повертає List:

Найбільша стурбованість тут полягає в тому, що занадто багато операцій стають дорогими пропозиціями лінійного часу. Якщо ви хочете відфільтрувати список і повернути його назад, а не лише Колекцію чи Ітерабельний, ви можете скористатись тим ImmutableList.copyOf(Iterables.filter(list, predicate)), що "наголошує", що він робить, і наскільки це дорого.

Щоб взяти конкретний приклад, яка вартість get(0)або size()в Списку? Для часто використовуваних класів, наприклад ArrayList, вони O (1). Але якщо ви викликаєте одне з них у ліниво відфільтрованому списку, він повинен запустити фільтр над списком резервного копіювання, і раптом ці операції будуть O (n). Гірше, що він повинен пройти список списку під час кожної операції.

Це нам здавалося занадто великим ліном. Одна річ - налаштувати деякі операції та відкласти фактичне виконання, поки ви так не «Відійдете». Інше налаштувати речі таким чином, що приховує потенційно велику кількість перерахунків.

Пропонуючи заборонити нелінійні потоки або потоки без повторного використання, Пол Сандос описав потенційні наслідки, що дозволяють їм спричинити "несподівані або заплутані результати". Він також зазначив, що паралельне виконання може зробити речі ще складнішими. Нарешті, я додам, що операція на трубопроводі з побічними ефектами призведе до складних та незрозумілих помилок, якби операція несподівано виконувалася кілька разів або хоча б різна кількість разів, ніж очікував програміст. (Але програмісти на Java не пишуть лямбда-вирази з побічними ефектами, чи не так? РОБИТИ ??)

Отож, це основне обгрунтування дизайну API 8 8 для потоків Java, що дозволяє проходити одноразово і вимагає строго лінійного (без розгалуження) трубопроводу. Він забезпечує послідовну поведінку в декількох різних джерелах потоку, він чітко відокремлює ледачих від нетерплячих операцій, а також забезпечує просту модель виконання.


Що стосується IEnumerable, я далеко не фахівець з C # і .NET, тому я вдячний би бути виправленим (обережно), якщо я роблю неправильні висновки. Однак, схоже, це IEnumerableдозволяє багаторазовому проходженню по-різному поводитися з різними джерелами; і це дозволяє розгалужувати структуру вкладених IEnumerableоперацій, що може спричинити за собою значну перерахунок. Хоча я ціную, що різні системи роблять різні компроміси, це дві характеристики, яких ми прагнули уникати при розробці API Java 8 Streams.

Приклад швидкості, який дає ОП, цікавий, дивовижний, і, на жаль, скажу, дещо жахливий. Виклик QuickSortприймає IEnumerableта повертає IEnumerable, тому сортування фактично не проводиться до проходження фіналу IEnumerable. Однак, як видається, дзвінок - це побудувати структуру дерева, IEnumerablesяка відображає розділ, який би робив quicksort, насправді цього не роблячи. (Зрештою, це ліниві обчислення.) Якщо джерело має N елементів, дерево буде N елементів у найширшому, і воно буде рівнем lg (N) глибоким.

Мені здається - і ще раз, я не є експертом з C # або .NET - це призведе до того, що певні нешкідливі дзвінки, наприклад, вибір пункту через ints.First(), виявляться дорожчими, ніж вони виглядають. На першому рівні, звичайно, це O (1). Але розгляньте перегородку глибоко в дереві, в правій частині краю. Для обчислення першого елемента цього розділу необхідно пройти все джерело, операцію O (N). Але оскільки розділи, наведені вище, ліниві, їх потрібно перерахувати, вимагаючи порівняння O (lg N). Отже, вибір стрижня був би операцією O (N lg N), яка дорожча, як цілий сорт.

Але ми насправді не сортуємо, поки не пройдемо повернене IEnumerable. У стандартному алгоритмі швидкого розбиття кожен рівень розділення подвоює кількість розділів. Кожен розділ має лише половину розміру, тому кожен рівень залишається на рівні O (N). Дерево перегородок високе O (lg N), тому загальна робота становить O (N lg N).

З деревом ледачих IEnumerables, внизу дерева є N перегородок. Для обчислення кожного розділу потрібно пройти N елементів, кожен з яких потребує lg (N) порівняння вгору по дереву. Для обчислення всіх розділів у нижній частині дерева тоді знадобиться порівняння O (N ^ 2 lg N).

(Це правильно? Я навряд чи вірю в це. Хтось, будь ласка, перевірив це на мене.)

У будь-якому випадку, справді класно, що IEnumerableможна використовувати цей спосіб для побудови складних структур обчислень. Але якщо це збільшить обчислювальну складність настільки, наскільки я думаю, що це робить, здається, що програмування таким способом - це те, чого слід уникати, якщо не бути надзвичайно обережним.


35
Перш за все, дякую за чудову і не поблажливу відповідь! Це, безумовно, найточніше і на сьогоднішній день пояснення, які я отримав. Що стосується прикладу QuickSort, то, здається, ви праві щодо ints.First здуття живота в міру зростання рівня рекурсії. Я вважаю, що це можна легко виправити, прагнучи обчислити 'gt' і 'lt' (зібравши результати з ToArray). Це, безумовно, підтверджує вашу думку про те, що цей стиль програмування може спричинити несподівану ціну продуктивності. (Продовжуйте у другому коментарі)
Віталій

18
З іншого боку, з мого досвіду роботи з C # (більше 5 років) я можу сказати, що викорінення "зайвих" обчислень не так вже й важко, як тільки ви потрапили на проблему з продуктивністю (або заборонили, якщо хтось зробив немислиме і ввів бічний вплив там). Мені просто здалося, що було зроблено занадто багато компромісів для забезпечення чистоти API, за рахунок можливостей C #. Ви точно допомогли мені налаштувати мою точку зору.
Віталій

7
@Vitaliy Дякую за сумлінний обмін ідеями. Я дізнався трохи про C # і .NET з дослідження та написання цієї відповіді.
Стюарт Маркс

10
Невеликий коментар: ReSharper - це розширення Visual Studio, яке допомагає з C #. За допомогою наведеного вище коду QuickSort ReSharper додає попередження для кожного використанняints : "Можливе багаторазове перерахування IEnumerable". Використовувати те саме IEenumerableне раз є підозрілим і його слід уникати. Я також хотів би вказати на це запитання (на яке я відповів), яке показує деякі застереження із підходом .Net (крім низької продуктивності): Список <T> і незліченна різниця
Кобі

4
@Kobi Дуже цікаво, що в ReSharper є таке попередження. Дякуємо за вказівник на вашу відповідь. Я не знаю C # /. NET, тому мені доведеться ретельно його перебирати, але, схоже, виникають проблеми, схожі на проблеми дизайну, про які я згадував вище.
Стюарт Марк

122

Фон

Хоча питання видається простим, фактична відповідь потребує певного досвіду, щоб мати сенс. Якщо ви хочете перейти до висновку, прокрутіть униз ...

Виберіть точку порівняння - Основна функціональність

Використовуючи основні поняття, концепція C # IEnumerableбільш тісно пов'язана з JavaIterable , яка здатна створити стільки ітераторів, скільки вам потрібно. IEnumerablesтворити IEnumerators. Java - IterableстворенняIterators

Історія кожної концепції схожа тим, що обидва IEnumerableі Iterableмають основну мотивацію, щоб дозволити циклічне перегляд стилів "для кожного" над членами колекцій даних. Це надмірне спрощення, оскільки вони дозволяють більше, ніж просто це, і вони також дійшли до цього етапу через різні прогресії, але це важлива спільна особливість незалежно.

Порівняємо цю особливість: в обох мовах, якщо клас реалізує IEnumerable/ Iterable, тоді цей клас повинен реалізувати принаймні один метод (для C #, це GetEnumeratorі для Java це iterator()). У кожному випадку екземпляр, повернутий з цього ( IEnumerator/ Iterator) дозволяє отримати доступ до поточних та наступних членів даних. Ця функція використовується у синтаксисі для кожної мови.

Виберіть точку порівняння - покращена функціональність

IEnumerableв C # було розширено, щоб дозволити ряд інших мовних функцій ( переважно пов'язаних з Linq ). Додані функції включають вибір, проекції, агрегацію тощо. Ці розширення мають сильну мотивацію від використання в теорії множин, подібно до концепцій SQL та реляційних баз даних.

У Java 8 також було додано функціональні можливості для забезпечення можливості функціонального програмування за допомогою Streams та Lambdas. Зауважте, що потоки Java 8 в основному не мотивовані теорією множин, а функціональним програмуванням. Незалежно від цього є багато паралелей.

Отже, це другий момент. Покращення C # були реалізовані як доповнення до IEnumerableконцепції. У Java, однак, зроблені вдосконалення були реалізовані шляхом створення нових базових концепцій лямбдасів і потоків, а потім також створення відносно тривіального способу перетворення з Iteratorsта Iterablesв потоки та навпаки.

Таким чином, порівняння IEnumerable з концепцією потоку Java є неповним. Вам потрібно порівняти його з комбінованим API Streams and Collections на Java.

У Java потоки не такі, як Iterables або Iterators

Потоки не розроблені для вирішення проблем так само, як ітератори:

  • Ітератори - це спосіб опису послідовності даних.
  • Потоки - це спосіб опису послідовності перетворень даних.

За допомогою програми Iteratorви отримуєте значення даних, обробляєте його, а потім отримуєте інше значення даних.

За допомогою потоків ви з'єднуєте послідовність функцій разом, потім подаєте вхідне значення потоку та отримуєте вихідне значення з комбінованої послідовності. Зауважте, в термінах Java кожна функція інкапсульована в один Streamекземпляр. API Streams дозволяє зв’язати послідовність Streamекземплярів таким чином, що ланцюжком є ​​послідовність виразів перетворення.

Для завершення Streamконцепції потрібне джерело даних для живлення потоку та термінальна функція, яка споживає потік.

Спосіб введення значень у потік може насправді бути з Iterable, але сама Streamпослідовність не є Iterable, а складною функцією.

A Streamтакож призначений бути лінивим, у тому сенсі, що він працює лише тоді, коли ви вимагаєте від нього значення.

Зверніть увагу на ці суттєві припущення та особливості потоків:

  • A Streamв Java - це механізм перетворення, він перетворює елемент даних в один стан, перебуваючи в іншому.
  • Потоки не мають поняття порядку чи положення даних, просто перетворюйте все, про що вони просять.
  • Потоки можуть надаватися даними з багатьох джерел, включаючи інші потоки, Ітератори, Ітерабелі, Колекції,
  • ви не можете "скинути" потік, це було б як "перепрограмування перетворення". Скидання джерела даних - це, мабуть, те, що ви хочете.
  • логічно є лише 1 елемент даних "у польоті" у потоці в будь-який час (якщо тільки потік не є паралельним потоком, в якому пункті є 1 елемент на нитку). Це незалежно від джерела даних, у якого може бути більше, ніж поточних елементів, готових до надходження в потік, або від колектора потоків, який може потребувати агрегації та зменшення кількох значень.
  • Потоки можуть бути незв'язаними (нескінченними), обмеженими лише джерелом даних, або колекторами (які можуть бути і нескінченними).
  • Потоки є "можливими", вихід фільтрує один потік, інший потік. Значення, введені та трансформовані потоком, можуть, в свою чергу, подаватися в інший потік, який робить інше перетворення. Дані в перетвореному стані перетікають з одного потоку в інший. Вам не потрібно втручатися та витягувати дані з одного потоку та підключати їх до наступного.

C # Порівняння

Якщо ви вважаєте, що Java Stream - це лише частина системи подачі, потоку та збирання, а також, що потоки та ітератори часто використовуються разом із колекціями, то не дивно, що важко пов'язати ті самі поняття, які є майже всі вбудовані в єдину IEnumerableконцепцію в C #.

Частини IE численні (і близькі пов'язані з ними поняття) очевидні у всіх концепціях Java Iterator, Iterable, Lambda та Stream.

Є невеликі речі, які можуть зробити поняття Java, які складніші в IEnumerable, і навпаки.


Висновок

  • Тут немає проблем із дизайном, просто проблема у відповідності понять між мовами.
  • Потоки вирішують проблеми по-іншому
  • Потоки додають Java функціональності (вони додають інший спосіб роботи, вони не віднімають функціональність)

Додавання потоків дає більше варіантів для вирішення проблем, що справедливо класифікувати як "посилення сили", а не "зменшення", "відбирання" або "обмеження".

Чому потоки Java одноразові?

Це питання неправильно, оскільки потоки - це послідовності функцій, а не дані. Залежно від джерела даних, яке подає потік, ви можете скинути джерело даних та подати той самий або інший потік.

На відміну від C # 's IEnumerable, де конвеєр виконання може бути виконаний стільки разів, скільки ми хочемо, в Java потік можна "повторити" лише один раз.

Порівнювати IEnumerableз a Streamє помилковим. Контекст, який ви використовуєте, щоб сказати, IEnumerableможе бути виконаний стільки разів, скільки ви хочете, найкраще порівняти з Java Iterables, який можна повторювати стільки разів, скільки вам потрібно. Java Streamявляє собою підмножину IEnumerableконцепції, а не підмножину, яка постачає дані, і тому не може бути "повторно".

Будь-який дзвінок до операції терміналу закриває потік, роблячи його непридатним. Ця 'особливість' забирає багато сил.

Перше твердження вірно, в певному сенсі. Заява "забирає владу" не є. Ви все ще порівнюєте Streams it IEnumerables. Операція терміналу в потоці подібна умові "перерва" в циклі for. Ви завжди можете мати ще один потік, якщо хочете, і якщо зможете повторно поставити потрібні вам дані. Знову ж таки, якщо ви вважаєте, що IEnumerableце більше схоже на Iterable, для цього твердження, Java робить це просто чудово.

Я думаю, що причина цього не є технічною. Які дизайнерські міркування стояли за цим дивним обмеженням?

Причина - технічна, і з тієї простої причини, що Потік - це підмножина того, що думають. Підмножина потоку не контролює подачу даних, тому слід скинути джерело, а не потік. У цьому контексті це не так дивно.

Приклад QuickSort

Ваш приклад швидкості має підпис:

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

Ви трактуєте дані IEnumerableяк джерело даних:

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

Крім того, повернене значення IEnumerableтеж є подачею даних, а оскільки це операція сортування, порядок цієї подачі є важливим. Якщо ви вважаєте, що Iterableклас Java є відповідним для цього, зокрема Listспеціалізацією Iterable, оскільки Список - це постачання даних, які мають гарантований порядок чи ітерацію, то еквівалентним кодом Java буде ваш код:

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));
}    

Зауважте, що є помилка (яку я відтворив), оскільки сортування не обробляє дублюючі значення витончено, це сортування "унікального значення".

Також зауважте, як код Java використовує джерело даних ( List) та потокові концепції в різній точці, і що в C # ці дві "особистості" можна виразити просто IEnumerable. Також, хоча я маю користьList як базовий тип, я міг би використовувати більш загальне Collection, і з невеликим перетворенням ітератора в потік я міг би використовувати ще більш загальнийIterable


9
Якщо ви думаєте "повторити" потік, ви робите це неправильно. Потік представляє стан даних у певний момент часу в ланцюзі перетворень. Дані надходять у систему в джерелі потоку, потім перетікають з одного потоку в інший, змінюючи стан у міру проходження, поки їх не збирають, зменшують чи скидають наприкінці. A Stream- це концепція "конкретного часу", а не "
циклічна

7
У потоці у вас є дані, що входять до потоку схожими на X, а вихід у потік виглядає як Y. Є функція, яку робить потік, яка виконує цю трансформацію f(x). Потік інкапсулює функцію, він не інкапсулює дані, що протікають через
rolfl

4
IEnumerableможе також надавати випадкові значення, бути незв'язаними та стати активними до того, як існують дані.
Артуро Торрес Санчес

6
@Vitaliy: Багато методів, які отримують IEnumerable<T>очікують, що вони представлятимуть кінцеву колекцію, яку можна повторити кілька разів. Деякі речі, які є ітерабельними, але не відповідають цим умовам, IEnumerable<T>тому що жоден інший стандартний інтерфейс не підходить до рахунку, але методи, які очікують, що кінцеві колекції, які можна повторити багато разів, схильні до збоїв, якщо давати ітерабельні речі, які не відповідають цим умовам. .
supercat

5
Ваш quickSortприклад міг бути набагато простішим, якби він повернув a Stream; це збереже два .stream()дзвінки та один .collect(Collectors.toList())дзвінок. Якщо ви заміните Collections.singleton(pivot).stream()з Stream.of(pivot)кодом стає майже читаним ...
Хольгер

22

Streams побудовані навколо Spliterators, які є державними, що змінюються об'єктами. У них немає дії "скидання", і, фактично, вимагати підтримки такої дії перемотування назад "забирає багато енергії". Як слід Random.ints()було б звернутися з таким запитом?

З іншого боку, для Streams, які мають висувне походження, легко побудувати еквівалент, Streamякий буде використаний заново. Просто введіть кроки, зроблені для побудови Streamметоду багаторазового використання. Майте на увазі, що повторення цих кроків не є дорогою операцією, оскільки всі ці кроки - це ліниві операції; фактична робота починається з роботи терміналу, і залежно від фактичної операції терміналу може бути виконаний зовсім інший код.

Ви, письменник такого методу, вирішуватимете, що виклик методу двічі має на увазі: чи він відтворює абсолютно таку саму послідовність, як це роблять потоки, створені для немодифікованого масиву чи колекції, чи виробляє потік із a схожа семантика, але різні елементи, такі як потік випадкових вводів або потік вхідних ліній консолі тощо.


До речі, щоб уникнути плутанини, термінал операція споживаєStream , який відрізняється від закриттяStream як виклик close()на потік робить (який необхідний для потоків , які мають асоційовані ресурси , такі як, наприклад , виробництва Files.lines()).


Здається, що багато плутанини випливає з неправильного порівняння IEnumerableз Stream. Оно IEnumerableявляє собою здатність надати фактичне IEnumerator, таким чином, як Iterableу Java. На відміну від цього, a Streamє своєрідним ітератором та порівнянним з IEnumeratorтаким, тому неправильно стверджувати, що такий тип даних може використовуватися декілька разів у .NET, підтримка не IEnumerator.Resetє обов'язковою. Розглянуті тут приклади скоріше використовують той факт, що IEnumerableможна використовувати для отримання нових IEnumerator s, а також працює з Java Collection; ви можете отримати нове Stream. Якщо розробники Java вирішили додати Streamоперації Iterableбезпосередньо, проміжні операції повертають іншуIterable, це було дійсно порівнянно, і воно могло працювати так само.

Однак розробники вирішили проти цього, і рішення обговорюється в цьому питанні . Найбільший сенс - плутанина з приводу операцій збору бажання та лінивих операцій Stream. Переглядаючи API .NET, я (так, особисто) вважаю це виправданим. Незважаючи на те, що це виглядає доцільно IEnumerableлише в одному, конкретна колекція матиме безліч методів, що безпосередньо маніпулюють колекцією, і багато методів повернення ледачих IEnumerable, в той час як особливість природи методу не завжди зрозуміла інтуїтивно. Найгірший приклад, який я знайшов (протягом декількох хвилин, коли я його переглянув) - це те, List.Reverse()чиє ім’я відповідає точно імені успадкованого (це правильний термін для методів розширення?) Enumerable.Reverse(), Але має абсолютно суперечливу поведінку.


Звичайно, це два чіткі рішення. Перший - зробити Streamтип відмінним від Iterable/, Collectionа другий - зробити Streamсвоєрідним ітератором часу, а не іншим. Але ці рішення були прийняті разом, і, можливо, так було, що про розділення цих двох рішень ніколи не розглядалося. Це не було створено, щоб бути на увазі .NET на увазі.

Справжнім дизайнерським рішенням API було додати покращений тип ітератора Spliterator. Spliterators може надаватися старим Iterables (тобто таким чином, як вони були дооснащені) або абсолютно новими реалізаціями. Потім Streamбув доданий як високий рівень фронтальної частини до досить низького рівня Spliterators. Це воно. Ви можете обговорити, чи буде інший дизайн кращим, але це не продуктивне, воно не зміниться, враховуючи те, як вони розроблені зараз.

Є ще один аспект реалізації, який ви повинні врахувати. Streams не є незмінними структурами даних. Кожна проміжна операція може повертати новий Streamекземпляр, інкапсулюючи старий, але він також може маніпулювати власним екземпляром, а також повертати себе (що не перешкоджає виконанню обох для однієї і тієї ж операції). Загальновідомі приклади - такі операції, як parallelабо unorderedякі не додають іншого кроку, а маніпулюють усім трубопроводом). Наявність такої структури даних, що змінюється, та спроби повторного використання (або ще гірше, використання її декількох разів одночасно) не грає добре ...


Для повноти ось ваш приклад quicksort, переведений на Java StreamAPI. Це показує, що насправді це не «забирає багато сил».

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);
}

Це можна використовувати як

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(", ")));

Ви можете написати це ще компактніше

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
Ну, споживає чи ні, намагаючись знову споживати, викидає виняток, що потік вже був закритий , а не споживаний. Що стосується проблеми зі скиданням потоку випадкових цілих чисел, то, як ви вже сказали, саме автор бібліотеки повинен визначити точний контракт операції скидання.
Віталій

2
Ні, повідомлення "Потік вже був запущений або закритий", і ми говорили не про операцію "скидання", а про виклик двох або більше операцій терміналу, Streamтоді як скидання джерела Spliteratorбуде мати на увазі. І я впевнений, що це можливо, виникали запитання на кшталт "Чому дзвінки count()двічі по телефону Streamдають різні результати кожен раз" і т.
Д.

1
Це абсолютно справедливо для count (), щоб дати різні результати. count () - це запит на потік, і якщо потік є змінним (або якщо бути точнішим, потік представляє результат запиту в колекції, що змінюється), то його очікують. Погляньте на API C #. Вони вирішують усі ці питання витончено.
Віталій

4
Те, що ви називаєте "абсолютно дійсним" - це контрінтуїтивна поведінка. Зрештою, це головна мотивація запитувати про використання потоку декілька разів для обробки результату, який, як очікується, буде однаковим, по-різному. Кожне запитання на тему SO про Streamнеодноразовий характер s поки що випливає зі спроби вирішити проблему шляхом виклику термінальних операцій кілька разів (очевидно, інакше ви не помічаєте), що призвело до мовчазно зламаного рішення, якщо StreamAPI дозволив це з різними результатами на кожному оцінюванні. Ось приємний приклад .
Холгер

3
Насправді ваш приклад чудово демонструє, що відбувається, якщо програміст не розуміє наслідків застосування декількох операцій на терміналі. Подумайте лише, що станеться, коли кожна з цих операцій буде застосована до зовсім іншого набору елементів. Він працює лише в тому випадку, якщо джерело потоку повертало однакові елементи для кожного запиту, але це саме неправильне припущення, про яке ми говорили.
Холгер

8

Я думаю, що між ними дуже мало відмінностей, коли ви придивитесь уважно.

На його обличчі IEnumerableвиглядає конструкція для багаторазового використання:

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

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

Однак компілятор насправді робить трохи роботи, щоб нам допомогти; він генерує наступний код:

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

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

Кожен раз, коли ви насправді перебиратимете число, компілятор створює перелік. Переписувач не підлягає багаторазовому використанню; подальші дзвінки до MoveNextпросто повернуть помилкові, і немає способу відновити її до початку. Якщо ви хочете знову повторити числа, вам потрібно буде створити інший екземпляр перелічувача.


Щоб краще проілюструвати, що IEnumerable має (може мати) таку саму «особливість», що і потік Java, розглянемо число, джерело чисел якого не є статичною колекцією. Наприклад, ми можемо створити численний об'єкт, який формує послідовність з 5 випадкових чисел:

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();
    }
}

Тепер у нас дуже схожий код на попередній масив на основі масиву, але з другою ітерацією numbers:

IEnumerable<int> numbers = new RandomNumberStream();

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

Вдруге, коли ми повторимо, numbersми отримаємо іншу послідовність чисел, яку не можна використовувати повторно в тому ж сенсі. Або ми могли б RandomNumberStreamзаписати викинутий виняток, якщо спробувати повторити його кілька разів, зробивши перелічені фактично непридатними (як Java Stream).

Крім того, що означає ваш швидкий сортування на основі чисельних даних, коли він застосовується до RandomNumberStream?


Висновок

Отже, найбільша відмінність полягає в тому, що .NET дозволяє повторно використовувати додавання IEnumerable, неявно створюючи нове IEnumeratorу фоновому режимі всякий раз, коли це буде потрібно для доступу до елементів у послідовності.

Ця неявна поведінка часто корисна (і "потужна", як ви заявляєте), оскільки ми можемо неодноразово повторювати колекцію.

Але іноді ця неявна поведінка може насправді викликати проблеми. Якщо ваше джерело даних не є статичним або доступ до нього дорогий (наприклад, база даних чи веб-сайт), багато припущень щодо цього IEnumerableдоведеться відкинути; повторне використання не таке вже й прямо


2

В API Stream можна обійти деякі захисти "запустити один раз"; наприклад, ми можемо уникати java.lang.IllegalStateExceptionвинятків (з повідомленням "потік вже функціонував або закритий") шляхом посилання та повторного використання Spliterator(а не Streamбезпосередньо).

Наприклад, цей код буде працювати без викидів:

    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);

Однак вихід буде обмежений

prefix-hello
prefix-world

а не повторення результату двічі. Це пояснюється тим, що ArraySpliteratorвикористовується як Streamджерело є справжнім і зберігає своє поточне положення. Коли ми Streamповторюємо це, ми починаємо знову в кінці.

У нас є ряд варіантів вирішення цієї проблеми:

  1. Ми могли б скористатися Streamметодом створення без громадянства, таким як Stream#generate(). Нам доведеться керувати станом зовні у власному коді та скидати між Stream"повторами":

    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. Іншим (трохи кращим, але не ідеальним) рішенням цього є написання власного ArraySpliterator(або подібного Streamджерела), яке включає деяку здатність скинути поточний лічильник. Якби ми використовували його для створення, Streamми могли б їх успішно відтворити.

    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. Найкращим рішенням цієї проблеми (на мою думку) є створення нової копії будь-якого стану, Spliteratorщо використовується в Streamконвеєрі, коли нові оператори викликаються на Stream. Це складніше і його потрібно реалізувати, але якщо ви не заперечуєте проти використання сторонніх бібліотек, cyclops-react має Streamреалізацію, яка робить саме це. (Розкриття: Я є провідним розробником цього проекту.)

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

Це надрукується

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

як і очікувалося.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.