Почему Java Streams разовые?

239

В отличие от C # 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
Обратите внимание, что использование IEnumerable несколько раз в C # - хрупкая модель, поэтому предпосылка вопроса может быть несколько ошибочной. Многие реализации IEnumerable позволяют, но некоторые этого не делают! Инструменты анализа кода, как правило, предостерегают вас от подобных действий.
Сандер

Ответы:

368

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

Еще в 2012 году мы добавляли лямбда-выражения в язык и хотели, чтобы набор операций, ориентированный на коллекции или «объемные данные», был запрограммирован с использованием лямбда-выражений, которые облегчили бы параллелизм. Идея лениво связывать операции была хорошо обоснована к этому моменту. Мы также не хотели, чтобы промежуточные операции сохраняли результаты.

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

Существовало много влияний существующих работ на дизайн. Среди наиболее влиятельных были библиотека Google Guava и библиотека коллекций Scala. (Если кто -то удивляется о влиянии из гуавы, обратите внимание , что Кевин Bourrillion , гуавы ведущий разработчик, был на JSR-335 Lambda . Экспертной группы) В коллекции Scala, мы нашли этот разговор по Одерски быть особый интерес: перспективную Проверка коллекций Scala: от изменчивых до постоянных и параллельных . (Стэнфорд EE380, 1 июня 2011 г.)

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

Поскольку это Iterables, вы можете вызывать iterator()метод более одного раза. Что должно произойти потом?

Если источником является коллекция, это в основном работает нормально. Коллекции являются Итерируемыми, и каждый вызов iterator()создает отдельный экземпляр Итератора, который не зависит от каких-либо других активных экземпляров, и каждый обходит коллекцию независимо. Отлично.

А что, если источник однократный, как чтение строк из файла? Возможно, первый итератор должен получить все значения, но второй и последующие должны быть пустыми. Возможно, значения должны чередоваться среди итераторов. Или, может быть, каждый итератор должен получить все одинаковые значения. Тогда, что если у вас есть два итератора, и один становится дальше другого? Кто-то должен будет буферизовать значения во втором Итераторе, пока они не будут прочитаны. Хуже того, что если вы получите один итератор и прочитаете все значения, и только тогда получите второй итератор. Откуда берутся ценности? Требуется ли их буферизация на случай, если кто-то захочет второго итератора?

Очевидно, что использование нескольких итераторов в одном источнике вызывает много вопросов. У нас не было хороших ответов для них. Мы хотели последовательного, предсказуемого поведения для того, что произойдет, если вы позвоните iterator()дважды. Это подтолкнуло нас к запрету нескольких обходов, сделав трубопроводы одним выстрелом.

Мы также наблюдали, как другие сталкивались с этими проблемами. В JDK большинство Iterables являются коллекциями или подобными коллекциям объектами, которые допускают многократный обход. Это нигде не указано, но, казалось, неписаное ожидание, что 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 и сохранению только активных, мутативных операций непосредственно в коллекциях. Брайан Гетц объяснил причины этого.

Как насчет разрешения множественного обхода для конвейеров на основе сбора, но запрета его для конвейеров не на основе сбора? Это противоречиво, но разумно. Если вы читаете значения из сети, вы, конечно, не сможете снова их просмотреть. Если вы хотите пройти их несколько раз, вы должны явно включить их в коллекцию.

Но давайте рассмотрим возможность множественного обхода из конвейеров на основе коллекций. Допустим, вы сделали это:

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

( intoОперация теперь пишется collect(toList()).)

Если источник является коллекцией, то первый into()вызов создаст цепочку итераторов обратно к источнику, выполнит операции конвейера и отправит результаты в место назначения. Второй вызов into()создаст еще одну цепочку итераторов и снова выполнит конвейерные операции . Это, очевидно, не так, но имеет эффект повторного выполнения всех операций фильтра и отображения для каждого элемента. Я думаю, что многие программисты были бы удивлены таким поведением.

Как я упоминал выше, мы разговаривали с разработчиками Guava. Одна из замечательных вещей, которые у них есть, это кладбище идей, где они описывают функции, которые они решили не реализовывать, вместе с причинами. Идея ленивых коллекций звучит довольно круто, но вот что они должны сказать по этому поводу. Рассмотрим List.filter()операцию, которая возвращает List:

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

Чтобы взять конкретный пример, какова стоимость get(0)или size()в списке? Для часто используемых классов, таких ArrayListкак O (1). Но если вы вызываете один из них в лениво отфильтрованном списке, он должен запустить фильтр над вспомогательным списком, и вдруг эти операции выполняются O (n). Хуже того, он должен пересекать список поддержки на каждой операции.

Это казалось нам слишком большой ленью. Одно дело настроить некоторые операции и отложить фактическое выполнение до тех пор, пока вы не начнете. Другое дело - настроить все так, чтобы скрыть потенциально большое количество повторных вычислений.

Предлагая запретить нелинейные потоки или потоки «без повторного использования», Пол Сандос описал потенциальные последствия их разрешения как вызывающие «неожиданные или сбивающие с толку результаты». Он также упомянул, что параллельное выполнение сделает все еще сложнее. Наконец, я бы добавил, что конвейерная операция с побочными эффектами может привести к трудным и неясным ошибкам, если операция была неожиданно выполнена многократно или, по крайней мере, в другое число раз, чем ожидал программист. (Но Java-программисты не пишут лямбда-выражения с побочными эффектами, не так ли?

Таким образом, это является основным обоснованием разработки API Java 8 Streams, которая допускает обход в один прием и требует строго линейного (без разветвления) конвейера. Он обеспечивает согласованное поведение для нескольких различных потоковых источников, четко отделяет ленивые от активных операций и обеспечивает простую модель выполнения.


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

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

Мне кажется - и еще раз, я не эксперт по C # или .NET - что это приведет к тому, что некоторые вызовы безобидного вида, такие как выбор с помощью pivot 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, кажется, что вы правы относительно целых чисел. Первое вздутие живота по мере роста уровня рекурсии. Я полагаю, что это можно легко исправить, с нетерпением вычисляя «gt» и «lt» (собирая результаты с помощью ToArray). Это, как говорится, безусловно, подтверждает вашу точку зрения, что этот стиль программирования может повлечь за собой неожиданную цену производительности. (Продолжить во втором комментарии)
Виталий
18
С другой стороны, из моего опыта работы с C # (более 5 лет) я могу сказать, что вычеркнуть «лишние» вычисления не так уж сложно, как только вы столкнулись с проблемой производительности (или получили запрет, если кто-то сделал немыслимое и представил побочный эффект есть). Мне просто показалось, что слишком много компромиссов было сделано для обеспечения чистоты API за счет возможностей, подобных C #. Вы определенно помогли мне скорректировать мою точку зрения.
Виталий
7
@Vitaliy Спасибо за честный обмен идеями. Изучив и написав этот ответ, я немного узнал о C # и .NET.
Стюарт Маркс
10
Небольшой комментарий: ReSharper - это расширение Visual Studio, которое помогает с C #. С помощью приведенного выше кода QuickSort ReSharper добавляет предупреждение для каждого использованияints : «Возможное многократное перечисление IEnumerable». Использование одного и того же IEenumerableболее одного раза является подозрительным и его следует избегать. Я также хотел бы указать на этот вопрос (на который я ответил), который показывает некоторые предостережения с подходом .Net (помимо низкой производительности): List <T> и IEnumerable разница
Kobi
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, однако, сделанные улучшения были реализованы путем создания новых базовых концепций Lambdas и Streams, а затем также создания относительно тривиального способа преобразования из Iteratorsи Iterablesв Streams, и наоборот.

Таким образом, сравнение IEnumerable с концепцией Java в Stream является неполным. Вам нужно сравнить его с объединенными API потоков и коллекций в Java.

В Java потоки не совпадают с итерациями или итераторами

Потоки не предназначены для решения проблем так же, как итераторы:

  • Итераторы - это способ описания последовательности данных.
  • Потоки - это способ описания последовательности преобразований данных.

С помощью Iteratorвы получаете значение данных, обрабатываете его, а затем получаете другое значение данных.

В Streams вы объединяете последовательность функций вместе, затем передаете входное значение в поток и получаете выходное значение из объединенной последовательности. Обратите внимание, что в терминах Java каждая функция инкапсулирована в одном Streamэкземпляре. API-интерфейс Streams позволяет связывать последовательность Streamэкземпляров таким образом, чтобы связать последовательность выражений преобразования.

Чтобы завершить Streamконцепцию, вам нужен источник данных для подачи потока и функция терминала, которая потребляет поток.

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

A Streamтакже должен быть ленивым в том смысле, что он работает только тогда, когда вы запрашиваете у него значение.

Обратите внимание на следующие важные предположения и особенности потоков:

  • A Streamв Java - это механизм преобразования, он преобразует элемент данных в одном состоянии в другое состояние.
  • Потоки не имеют понятия порядка или положения данных, они просто преобразуют все, что им требуется.
  • потоки могут быть снабжены данными из многих источников, включая другие потоки, итераторы, итерации, коллекции,
  • Вы не можете «сбросить» поток, это было бы как «перепрограммирование преобразования». Сброс источника данных, вероятно, то, что вы хотите.
  • логически в потоке в любое время находится только 1 элемент данных «в полете» (если только поток не является параллельным потоком, и в этом месте на поток приходится 1 элемент). Это не зависит от источника данных, который может иметь больше, чем текущие элементы, «готовые» для подачи в поток, или от сборщика потока, который может потребоваться для агрегирования и уменьшения нескольких значений.
  • Потоки могут быть несвязанными (бесконечными), ограниченными только источником данных или сборщиком (который также может быть бесконечным).
  • Потоки «цепочечные», результат фильтрации одного потока - это другой поток. Значения, введенные в поток и преобразованные потоком, могут, в свою очередь, быть переданы другому потоку, который выполняет другое преобразование. Данные в своем преобразованном состоянии перетекают из одного потока в другой. Вам не нужно вмешиваться, извлекать данные из одного потока и подключать их к другому.

Сравнение C #

Если учесть, что поток Java является лишь частью системы снабжения, потока и сбора, а потоки и итераторы часто используются вместе с коллекциями, то неудивительно, что трудно соотнести те же понятия, которые почти все встроено в единую IEnumerableконцепцию в C #.

Части IEnumerable (и близкие связанные концепции) очевидны во всех концепциях Java Iterator, Iterable, Lambda и Stream.

Есть небольшие вещи, которые могут сделать концепции Java, которые сложнее в IEnumerable, и наоборот.


Вывод

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

Добавление потоков дает вам больше возможностей при решении проблем, которые справедливо классифицировать как «повышение мощности», а не «уменьшение», «отмена» или «ограничение».

Почему Java Streams разовые?

Этот вопрос ошибочен, потому что потоки - это последовательности функций, а не данные. В зависимости от источника данных, который передает поток, вы можете сбросить источник данных и передать тот же или другой поток.

В отличие от C # IEnumerable, где конвейер выполнения может выполняться столько раз, сколько мы хотим, в Java поток может быть «повторен» только один раз.

Сравнение IEnumerableс Streamошибочным. Контекст, который вы используете, чтобы сказать, IEnumerableможет быть выполнен столько раз, сколько вы хотите, лучше всего по сравнению с Java Iterables, который может повторяться столько раз, сколько вы хотите. Java Streamпредставляет собой подмножество IEnumerableконцепции, а не подмножество, которое предоставляет данные и, следовательно, не может быть «перезапущено».

Любой вызов терминальной операции закрывает поток, делая его непригодным для использования. Эта «особенность» отнимает много энергии.

Первое утверждение в некотором смысле верно. Заявление «отнимает власть» - нет. Вы все еще сравниваете потоки это IEnumerables. Терминальная операция в потоке похожа на условие break в цикле for. Вы всегда можете иметь другой поток, если хотите, и если вы можете повторно предоставить данные, которые вам нужны. Опять же, если вы считаете, что IEnumerableэто больше похоже на Iterable, для этого утверждения Java делает это просто отлично.

Я думаю, причина этого не техническая. Каковы были конструктивные соображения, стоящие за этим странным ограничением?

Причина техническая, и по той простой причине, что поток является подмножеством того, что он думает. Подмножество потока не контролирует подачу данных, поэтому следует сбросить подачу, а не поток. В этом контексте это не так странно.

Пример быстрой сортировки

Ваш пример быстрой сортировки имеет подпись:

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

Вы рассматриваете ввод IEnumerableкак источник данных:

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

Кроме того, возвращаемое значение IEnumerableтакже является источником данных, и, поскольку это операция сортировки, порядок этих значений является значительным. Если вы считаете, что Java- Iterableкласс подходит для этого, в частности, для Listспециализации Iterable, поскольку List является источником данных, который имеет гарантированный порядок или итерацию, то эквивалентный 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-коде используются data source ( List) и потоковые концепции в разных точках, и что в C # эти две «личности» могут быть выражены просто IEnumerable. Кроме того, хотя я использовал Listбазовый тип, я мог бы использовать более общий Collection, и с небольшим преобразованием итератора в поток я мог бы использовать еще более общийIterable

rolfl
источник
9
Если вы думаете о «повторении» потока, вы делаете это неправильно. Поток представляет состояние данных в определенный момент времени в цепочке преобразований. Данные поступают в систему в источнике потока, затем перемещаются из одного потока в другой, изменяя состояние по мере продвижения, пока не будут собраны, сокращены или выгружены в конце. А Stream- это понятие момента времени, а не «операция цикла» .... (продолжение)
rolfl
7
С потоком у вас есть данные, входящие в поток, выглядящий как X, и выходящий из потока, выглядящий как Y. Есть функция, которую выполняет поток, который выполняет это преобразование f(x). Поток инкапсулирует функцию, он не инкапсулирует данные, которые проходят через
rolfl
4
IEnumerableможет также предоставлять случайные значения, быть несвязанным и становиться активным до того, как данные существуют.
Артуро Торрес Санчес
6
@Vitaliy: Многие методы, которые получают ожидание, IEnumerable<T>будут представлять конечную коллекцию, которая может повторяться несколько раз. Некоторые вещи, которые являются итеративными, но не удовлетворяют этим условиям, реализуются, IEnumerable<T>потому что никакой другой стандартный интерфейс не отвечает требованиям, но методы, которые ожидают, что конечные коллекции, которые могут быть повторены несколько раз, склонны к сбою, если даны итеративные вещи, которые не соответствуют этим условиям ,
суперкат
5
Ваш quickSortпример мог бы быть намного проще, если бы он возвращал a Stream; это спасло бы два .stream()звонка и один .collect(Collectors.toList())звонок. Если вы замените Collections.singleton(pivot).stream()с Stream.of(pivot)кодом становится почти читаемым ...
Хольгер
22

Streams строятся вокруг Spliterators, которые являются изменяемыми объектами с состоянием. У них нет действия «перезагрузки», и, фактически, требование поддержать такое действие перемотки «отнимает много сил». Как бы Random.ints()обрабатывать такой запрос?

С другой стороны, для Streams, которые имеют прослеживаемое происхождение, легко создать эквивалент Streamдля повторного использования. Просто поместите шаги, сделанные для создания Streamмногоразового метода. Имейте в виду, что повторение этих шагов не является дорогостоящей операцией, поскольку все эти шаги являются ленивыми операциями; фактическая работа начинается с работы терминала и в зависимости от фактической работы терминала может выполняться совершенно другой код.

Вы, автор такого метода, можете указать, что означает вызов метода дважды: воспроизводит ли он точно такую ​​же последовательность, как это делают потоки, созданные для неизмененного массива или коллекции, или создает поток с похожая семантика, но разные элементы, такие как поток случайных целых чисел или поток строк ввода с консоли и т. д.


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


Кажется, что большая путаница проистекает из ошибочного сравнения IEnumerableс Stream. An IEnumerableпредставляет возможность предоставить фактическое IEnumerator, так что это как Iterableв Java. Напротив, a Streamявляется своего рода итератором и сопоставим с, IEnumeratorпоэтому неправильно утверждать, что этот тип данных может использоваться несколько раз в .NET, поддержка для IEnumerator.Resetкоторого необязательна. В обсуждаемых здесь примерах скорее используется тот факт, что an IEnumerableможет использоваться для получения новых IEnumerator s, и это также работает с Java Collection; Вы можете получить новый Stream. Если разработчики Java решили добавить Streamоперации Iterableнапрямую, промежуточные операции возвращают другоеIterable, это было действительно сопоставимо, и это могло бы работать так же.

Однако разработчики решили против этого и решение обсуждается в этом вопросе . Самым большим моментом является путаница в нетерпеливых операциях Collection и отложенных операциях Stream. Глядя на .NET API, я (да, лично) нахожу это оправданным. Несмотря на то, что это выглядит разумно, если смотреть IEnumerableотдельно, в конкретной коллекции будет много методов, непосредственно манипулирующих этой коллекцией, и множество методов, возвращающих ленивый тип IEnumerable, в то время как особая природа метода не всегда интуитивно распознается. Худший пример, который я нашел (в течение нескольких минут, которые я посмотрел на него), это List.Reverse()чье имя в точности совпадает с именем унаследованного (это правильный конец для методов расширения?) Enumerable.Reverse()При совершенно противоположном поведении.


Конечно, это два разных решения. Первый, который делает Streamтип отличным от Iterable/, Collectionи второй, чтобы сделать Streamсвоего рода одноразовый итератор, а не другой тип итерируемого. Но эти решения были приняты вместе, и может быть так, что разделение этих двух решений никогда не рассматривалось. Он не был создан с учетом того, что можно сравнить с .NET.

Фактическим решением разработки API было добавление улучшенного типа итератора Spliterator. Spliterators могут быть предоставлены старыми Iterables (то есть, каким образом они были модифицированы) или совершенно новыми реализациями. Затем Streamбыл добавлен как высокоуровневый интерфейс к довольно низкому уровню Spliterators. Вот и все. Вы можете обсудить, будет ли другой дизайн лучше, но он не продуктивен, он не изменится, учитывая то, как они спроектированы сейчас.

Есть еще один аспект реализации, который вы должны рассмотреть. Streams не являются неизменяемыми структурами данных. Каждая промежуточная операция может возвращать новый Streamэкземпляр, инкапсулирующий старую, но она также может вместо этого манипулировать своим собственным экземпляром и возвращать себя (что не препятствует выполнению даже обоих для одной и той же операции). Общеизвестными примерами являются операции, подобные parallelили unorderedкоторые не добавляют еще один шаг, а манипулируют всем конвейером). Наличие такой изменчивой структуры данных и попыток повторного использования (или, что еще хуже, одновременного использования нескольких раз) не очень хорошо ...


Для полноты вот ваш пример быстрой сортировки, переведенный на 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());
}
Holger
источник
1
Ну, потребляет или нет, попытка потребить его снова вызывает исключение, что поток уже был закрыт , а не использовался. Что касается проблемы со сбросом потока случайных целых чисел, как вы сказали - автор библиотеки должен определить точный контракт операции сброса.
Виталий
2
Нет, сообщение «поток уже был обработан или закрыт», и мы не говорили об операции «сброс», а о вызове двух или более терминальных операций, Streamтогда как сброс исходных элементов Spliteratorподразумевается. И я совершенно уверен, что если бы это было возможно, на SO возникали вопросы, такие как: «Почему повторный вызов count()дважды Streamдает разные результаты каждый раз» и т. Д.
Хольгер,
1
Это абсолютно верно для count (), чтобы дать разные результаты. count () является запросом к потоку, и если поток является изменяемым (или, если быть более точным, поток представляет собой результат запроса к изменяемой коллекции), то он ожидается. Взгляните на C # API. Они занимаются всеми этими вопросами изящно.
Виталий
4
То, что вы называете «абсолютно действительным», является нелогичным поведением. В конце концов, это основная мотивация, когда вы спрашиваете об использовании потока несколько раз для обработки результата, который, как ожидается, будет одинаковым, по-разному. Каждый вопрос о SO, касающийся одноразовой природы Streams до сих пор, связан с попыткой решить проблему путем многократного вызова терминальных операций (очевидно, в противном случае вы этого не замечаете), что привело к безмолвному решению, если 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просто вернут false, и невозможно восстановить его в начале. Если вы хотите снова выполнить итерации по числам, вам нужно будет создать еще один экземпляр перечислителя.


Чтобы лучше проиллюстрировать, что 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).

Кроме того, что означает ваша быстрая сортировка на основе перечисления применительно к a RandomNumberStream?


Вывод

Итак, самое большое отличие состоит в том, что .NET позволяет вам повторно использовать IEnumerable, неявно создавая новый IEnumeratorв фоновом режиме всякий раз, когда ему потребуется доступ к элементам в последовательности.

Это неявное поведение часто полезно (и «мощно», как вы заявляете), потому что мы можем многократно перебирать коллекцию.

Но иногда это неявное поведение может вызвать проблемы. Если ваш источник данных не является статичным или требует больших затрат (например, база данных или веб-сайт), то многие предположения о нем IEnumerableследует отбросить; повторное использование не так просто

Эндрю Верми
источник
2

Можно обойти некоторые из защит «запустить один раз» в Stream API; например, мы можем избежать 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. Это более сложный и сложный для реализации, но если вы не возражаете против использования сторонних библиотек, в циклоп-реакции есть 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

как и ожидалось.

Джон МакКлин
источник