Чому filter () після flatMap () "не зовсім" ледачий у потоках Java?


75

У мене є такий зразок коду:

System.out.println(
       "Result: " +
        Stream.of(1, 2, 3)
                .filter(i -> {
                    System.out.println(i);
                    return true;
                })
                .findFirst()
                .get()
);
System.out.println("-----------");
System.out.println(
       "Result: " +
        Stream.of(1, 2, 3)
                .flatMap(i -> Stream.of(i - 1, i, i + 1))
                .flatMap(i -> Stream.of(i - 1, i, i + 1))
                .filter(i -> {
                    System.out.println(i);
                    return true;
                })
                .findFirst()
                .get()
);

Вихід такий:

1
Result: 1
-----------
-1
0
1
0
1
2
1
2
3
Result: -1

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

Я намагаюся зрозуміти, чому він поводиться так, а не здається після обчислення першого елемента, як у першому випадку. Будемо вдячні за будь-яку корисну інформацію.


11
@PhilippSander: Тому що якби він поводився ліниво - як це робиться в першому випадку - він оцінив би фільтр лише один раз.
Джон Скіт,

4
Зверніть увагу, що ви також можете використовувати peek: Stream.of(1, 2, 3).peek(System.out::println).filter(i -> true)...
Alexis C.

4
Зауважте, що я створив загальний обхідний шлях
Холгер

9
Було порушено проблему OpenJDK у день, коли було задано це запитання: bugs.openjdk.java.net/browse/JDK-8075939 . Це було призначено, але все ще не виправлено, майже через рік :(
MikeFHay

5
@MikeFHay JDK-8075939 призначений для Java 10. Пор. mail.openjdk.java.net/pipermail/core-libs-dev/2017-December/... для потоку огляду core-libs-dev та посилання на перший webrev.
Stefan Zobel

Відповіді:


65

TL; DR, це було розглянуто в JDK-8075939 та виправлено в Java 10 (і повернуто до Java 8 у JDK-8225328 ).

Розглядаючи реалізацію ( ReferencePipeline.java), ми бачимо метод [ посилання ]

@Override
final void forEachWithCancel(Spliterator<P_OUT> spliterator, Sink<P_OUT> sink) {
    do { } while (!sink.cancellationRequested() && spliterator.tryAdvance(sink));
}

який буде викликаний для findFirstроботи. Особливе, про що слід подбати, це те, sink.cancellationRequested()що дозволяє закінчити цикл на першому матчі. Порівняти із [ посиланням ]

@Override
public final <R> Stream<R> flatMap(Function<? super P_OUT, ? extends Stream<? extends R>> mapper) {
    Objects.requireNonNull(mapper);
    // We can do better than this, by polling cancellationRequested when stream is infinite
    return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
                                 StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT | StreamOpFlag.NOT_SIZED) {
        @Override
        Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
            return new Sink.ChainedReference<P_OUT, R>(sink) {
                @Override
                public void begin(long size) {
                    downstream.begin(-1);
                }

                @Override
                public void accept(P_OUT u) {
                    try (Stream<? extends R> result = mapper.apply(u)) {
                        // We can do better that this too; optimize for depth=0 case and just grab spliterator and forEach it
                        if (result != null)
                            result.sequential().forEach(downstream);
                    }
                }
            };
        }
    };
}

Метод просування одного елемента закінчується викликом forEachпідпотоку без можливості попереднього завершення, а коментар на початку flatMapметоду навіть розповідає про цю відсутність функції.

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


Для ілюстрації наслідків, хоч і Stream.iterate(0, i->i+1).findFirst()працює, як очікувалося, Stream.of("").flatMap(x->Stream.iterate(0, i->i+1)).findFirst()опиниться в нескінченному циклі.

Що стосується специфікації, більшу частину її можна знайти в

розділ "Потокові операції та трубопроводи" специфікації пакету :

...

Проміжні операції повертають новий потік. Вони завжди ліниві ;

...

... Лінощі також дозволяють уникати вивчення всіх даних, коли це не потрібно; для таких операцій, як "знайти перший рядок довшим за 1000 символів", необхідно лише вивчити достатньо рядків, щоб знайти той, який має бажані характеристики, не вивчаючи всі рядки, доступні з джерела. (Ця поведінка стає ще важливішою, коли вхідний потік нескінченний і не просто великий.)

...

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

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


27
Це помилка. Хоча може бути правдою, що специфікація підтримує таку поведінку, ніхто не очікує, що отримання першого елемента нескінченного потоку викине StackOverflowError або потрапить у нескінченний цикл, незалежно від того, надходить він безпосередньо від джерела конвеєра або з вкладеного потоку за допомогою функції зіставлення. Про це слід повідомляти як про помилку.
fps

5
@ Вадим С. Хондар: подання звіту про помилку - це гарна ідея. Щодо того, чому хтось раніше цього не помічав, я бачив багато помилок типу "не можу повірити, що я перший, хто помітив це". Якщо не задіяні нескінченні потоки, ця помилка має лише вплив на продуктивність, який може залишатися непоміченим у багатьох випадках використання.
Holger

7
@Marko Topolnik: властивість «не починається, поки не буде виконана термінальна робота трубопроводу», не заперечує інших властивостей ледачих операцій. Я знаю, що не існує декларації з одним реченням обговорюваного майна, інакше я його цитував. У в StreamAPI док йдеться , що «Потоки ліниві; обчислення вихідних даних виконується лише тоді, коли ініційована операція терміналу, і елементи джерела споживаються лише за необхідності . "
Holger

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

5
JDK-8075939 прогресує вже зараз. Дивіться mail.openjdk.java.net/pipermail/core-libs-dev/2017-December/…, щоб переглянути потік огляду core-libs-dev та посилання на перший webrev. Здається, ми побачимо це на Java 10.
Стефан Зобель

17

Елементи вхідного потоку ліниво споживаються один за одним. Перший елемент, 1перетворюється двома flatMaps в потік -1, 0, 1, 0, 1, 2, 1, 2, 3, так що весь потік відповідає лише першому вхідному елементу. Вкладені потоки охоче матеріалізуються трубопроводом, потім сплющуються, потім подаються на filterсцену. Це пояснює ваш результат.

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

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


2
@MarkoTopolnik, дякую за вашу відповідь. Насправді занепокоєння, викликане Холгером, насправді є причиною мого здивування. Чи означає другий випадок, що я не можу використовувати flatMap для нескінченних потоків?
Вадим С. Хондар

Так, я впевнений, що вкладений потік не може бути нескінченним.
Marko Topolnik

8

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

Поки наступне працює, як очікувалося, роздруковує нескінченну послідовність цілих чисел

Stream.of("x").flatMap(_x -> Stream.iterate(1, i -> i + 1)).forEach(System.out::println);

наступний код друкує лише "1", але все одно не закінчується:

Stream.of("x").flatMap(_x -> Stream.iterate(1, i -> i + 1)).limit(1).forEach(System.out::println);

Я не можу собі уявити прочитання специфікації, в якій це не було помилкою.


6

У своїй безкоштовній бібліотеці StreamEx я представив колектори короткого замикання. При збиранні послідовного потоку з короткозамкненим колектором (як MoreCollectors.first()) рівно один елемент споживається від джерела. Внутрішньо це реалізовано досить брудно: використання спеціального винятку для розриву потоку управління. Використовуючи мою бібліотеку, ваш зразок можна переписати таким чином:

System.out.println(
        "Result: " +
                StreamEx.of(1, 2, 3)
                .flatMap(i -> Stream.of(i - 1, i, i + 1))
                .flatMap(i -> Stream.of(i - 1, i, i + 1))
                .filter(i -> {
                    System.out.println(i);
                    return true;
                })
                .collect(MoreCollectors.first())
                .get()
        );

Результат такий:

-1
Result: -1


0

Я згоден з іншими людьми, що це помилка, відкрита на JDK-8075939 . І оскільки це все ще не виправлено більше ніж через рік. Я хотів би порекомендувати вам: AbacusUtil

N.println("Result: " + Stream.of(1, 2, 3).peek(N::println).first().get());

N.println("-----------");

N.println("Result: " + Stream.of(1, 2, 3)
                        .flatMap(i -> Stream.of(i - 1, i, i + 1))
                        .flatMap(i -> Stream.of(i - 1, i, i + 1))
                        .peek(N::println).first().get());

// output:
// 1
// Result: 1
// -----------
// -1
// Result: -1

Розкриття інформації: Я розробник AbacusUtil.


0

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

 stream(spliterator).map(o -> o).flatMap(Stream::of).flatMap(Stream::of).findAny()

Для хлопців, котрі не можуть чекати ще пару років для переходу на JDK-10, існує альтернативний справжній ледачий потік. Він не підтримує паралельність. Він був призначений для перекладу JavaScript, але у мене це вийшло, оскільки інтерфейс той самий.

StreamHelper базується на колекції, але легко адаптувати Spliterator.

https://github.com/yaitskov/j4ts/blob/stream/src/main/java/javaemul/internal/stream/StreamHelper.java

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