Почему после выключения потоков в Java?


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

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

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

Edit: для того, чтобы продемонстрировать, что я есть говоря об этом, рассмотрим следующую реализацию быстрой сортировки в 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! Я даже не могу спросить поток, является ли он пустым, не делая его непригодным для использования.

5 212

5 ответов:

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

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

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

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

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

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

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

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

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

мы также наблюдали, как другие сталкиваются с этими проблемами. В JDK большинство итераций являются коллекциями или подобными коллекции объектами, которые позволяют несколько поперечный. Это нигде не указано, но, похоже, было неписаное ожидание, что Iterables позволяют многократный обход. Заметным исключением является NIO DirectoryStream интерфейс. Его спецификация включает в себя это интересное предупреждение:

в то время как DirectoryStream расширяет Iterable, он не является итерацией общего назначения, поскольку он поддерживает только один итератор; вызов метода итератора для получения второго или последующих бросков итератора IllegalStateException.

[жирным шрифтом в оригинале]

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

примерно в это время, Ан статья Брюса Экеля оказалось, что он описал место неприятностей, которые у него были со Скалой. Он написал такой код:

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

это довольно просто. Оно анализ строки текста Registrant объекты и печатает их в два раза. За исключением того, что на самом деле он печатает их только один раз. Оказывается, он думал, что registrants была коллекция, когда на самом деле это итератор. Второй звонок в foreach встречает пустой итератор, из которого все значения были исчерпаны, поэтому он ничего не печатает.

Брайан Гетц объясняет обоснование этого.

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

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

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

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

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

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

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

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

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

в предложении запретить нелинейные или" без повторного использования " потоки,Павел Сандос назвал возможные последствия о том, что они могут привести к "неожиданным или запутанным результатам"."Он также отметил, что параллельно будет еще сложнее. Наконец, я бы добавил, что операция конвейера с побочными эффектами приведет к сложным и неясным ошибкам, если операция неожиданно будет выполнена несколько раз или, по крайней мере, несколько раз, чем ожидал программист. (Но Java-программисты не пишут лямбда выражения с побочными эффектами, не так ли? ТАК ЛИ ЭТО??)

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


в отношении IEnumerable, я далек от эксперта по C# и .NET, так что Я был бы признателен за исправление (мягко), если я делаю какие-либо неправильные выводы. Однако, похоже, что IEnumerable позволяет многократному обходу вести себя по-разному с различными источниками; и это позволяет ветвящуюся структуру вложенных IEnumerable операции, которые могут привести к возникновению значительных перерасчет. Хотя я ценю, что разные системы делают разные компромиссы, это две характеристики, которые мы стремились избежать при проектировании потоков Java 8 ПРИКЛАДНОЙ ПРОГРАММНЫЙ ИНТЕРФЕЙС.

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

мне кажется - и еще раз, я не эксперт C# или .NET-что это вызовет определенные безобидные вызовы, такие как выбор pivot через ints.First(), чтобы быть дороже, чем они выглядят. На первом уровне, конечно, это O(1). Но рассмотрим раздел глубоко в дереве, на правом краю. Чтобы вычислить первый элемент этого раздела, весь источник должен быть пройден, O(N) операция. Но поскольку приведенные выше разделы ленивы, их необходимо пересчитать, требуя o(lg N) сравнений. Таким образом, выбор оси будет операцией O(N lg N), которая так же дорога, как и весь сорт.

но мы на самом деле не сортируем, пока не пройдем возвращенный IEnumerable. В стандартном алгоритме быстрой сортировки каждый уровень секционирования удваивает количество секций. Каждый раздел имеет только половину размера, поэтому каждый уровень остается на уровне сложности O(N). Дерево разделов За o(ГПВ Н) высокая, поэтому общий объем работы составляет o(n в компании LG Н).

С деревом ленивых IEnumerables, в нижней части дерева есть N разделов. Вычисление каждого раздела требует обхода N элементов, каждый из которых требует LG(N) сравнения вверх по дереву. Чтобы вычислить все разделы в нижней части дерева, требуется O (N^2 lg N) сравнения.

(это право? Я с трудом могу в это поверить. Кто-нибудь, пожалуйста, проверьте это для меня.)

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

фон

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

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

используя основные понятия, C# ' s IEnumerable понятие более тесно связано с Java Iterable, который способен создать как можно больше итераторы как вы хотите. IEnumerables создать IEnumerators. В Java Iterable создать Iterators

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

давайте сравним эту функцию: в обоих языки, если класс реализует IEnumerable/Iterable, то этот класс должен реализовать хотя бы один метод (для C# это GetEnumerator и для Java-это iterator()). В каждом случае экземпляр возвращается из этого (IEnumerator/Iterator) позволяет получить доступ к текущим и последующим членам данных. Эта функция используется в синтаксисе для каждого языка.

Выберите свою точку сравнения-расширенная функциональность

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

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

Итак, это второй момент. Усовершенствования, внесенные в C# были реализованы в качестве расширения к

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

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

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


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


кажется, что a много путаницы проистекает из ошибочного сравнения IEnumerable С Stream. Ан IEnumerable представляет возможность предоставить фактический IEnumerator, так как Iterable в Java. В отличие от этого,Stream является своего рода итератором и сравним с IEnumerator поэтому неправильно утверждать, что этот тип данных может использоваться несколько раз в .NET, поддержка IEnumerator.Reset не является обязательным. Примеры, обсуждаемые здесь, скорее используют тот факт, что IEnumerable можно использовать для извлечения новая IEnumerators и это работает с Java Collections Также; вы можете получить новый Stream. Если разработчики Java решили добавить Stream операции Iterable непосредственно, с промежуточными операциями, возвращающими другой Iterable, это было действительно сопоставимо, и это может работать так же.

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


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

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

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


для полноты, вот ваш пример quicksort переведен на Java Stream API. Это показывает, что на самом деле это не "отнимает много сил".

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

Я думаю, что есть очень мало различий между ними, когда вы смотрите достаточно внимательно.

в лицо,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();
    }
}

теперь у нас есть очень похожий код на предыдущий массив на основе enumerable, но со второй итерацией над 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 Поток.)

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


вывод

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

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

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

можно обойти некоторые из" запустить один раз " защиты в потоковом 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. лучшим решением этой проблемы (на мой взгляд) является создание новой копии любого состояния Spliterators используемый в 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

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