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