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