Pull to refresh

Пишем Java Stream API на коленке за пару минут

Reading time 4 min
Views 22K
Stream API — замечательная вещь быстро завоевавшая популярность у джава программистов. Лаконичные однострочники обрабатывающие коллекции данных посредством цепочек простых операций map, filter, forEach, collect оказались очень удобны. Операции над парами ключ-значение, конечно, тоже не помешали бы, но увы.

В целом примерно понятно как это всё устроено, но все же зачастую ответ на вопрос «А как бы это написал я?» здорово помогает понять внутренние механизмы той или иной технологии. Так получилось, что внезапно для себя я ответил на этот вопрос применительно к Stream API, историей изобретения этого велосипеда и спешу с вами поделиться.

Пока я спокойно себе писал на свинге компоненты IDE, мир менялся — javascript захватывал сферу разработки UI. И захватил. Как ни крути, качественный рантайм на абсолютно каждой машине — сильный аргумент. Ничего не поделаешь, пришлось разбираться. В джава скрипте пользовательский код выполняется в одном потоке, поэтому все сколько нибудь длительные операции асинхронны. И если наша бизнес логика предполагает последовательность таких операций, то первой из них надо передать колбэк, который запустит вторую, которой передать колбэк, который выполнит третью и так далее. В общем, читается ужасно, поддерживается мучительно, js разработчики как-то пытаются с этим жить и придумывают обходные подходы, один из встретившихся мне головоломных вариантов — использование генераторов. Так я про них и узнал.

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

И так, если бы джава разработчик захотел сделать некое подобие генератора, что бы у него получилось? У меня получилось так:

    public interface Generator<T> {
        void generate(GeneratorContext<T> context);
    }
    
    public interface GeneratorContext<T> {
        void emit(T value);
    }

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

Определенно, данные генерируемые данным генератором образуют сущность, назовём её Dataset:

public class Dataset<T> {
    
    private final Generator<T> generator;
    
    private Dataset(Generator<T> generator) { 
          this.generator = generator; 
    }
}

И если в наличии есть набор данных, то неплохо бы иметь возможность что-нибудь сделать с каждым их элементом. Напечатать там или ещё что. Добавляем в класс Dataset метод forEach:

    public void forEach(Consumer<T> consumer) {
        generator.generate(value -> consumer.accept(value));
    }

Мы сформировали такой контекст генератора, что на каждый вызов метода еmit, он передаёт эмитированное значение в consumer, и запустили генерацию.

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

    public static <T> Dataset<T> of(Collection<T> collection) {
        return new Dataset<>(generatorContext -> 
               collection.forEach(item -> generatorContext.emit(item))
        );
    }

То же самое с помощью старого доброго цикла:

    public static <T> Dataset<T> of(Collection<T> collection) {
        return new Dataset<>(generatorContext -> {
               for (T item : collection) {
	           generatorContext.emit(item);
               }
        });
    }

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

        Dataset.of(Arrays.asList("foo", "bar")).forEach(System.out::println);

Вывод:
foo
bar

А теперь, по сути, та самая популярная задача с собеседований: дополним набор данных элементами ещё одной коллекции. Добавляем метод:

    public Dataset<T> union(Collection<T> collection) {
        return new Dataset<>(generatorContext -> {
            generator.generate(generatorContext);
            collection.forEach(item -> generatorContext.emit(item));
        });
    }

Создали новый датасет с таким генератором, который сначала эмитирует все значения текущего датасета, а потом все значения присоединяемой коллекции.

Фильтруем:

    public Dataset<T> filter(Predicate<T> predicate) {
        return new Dataset<>(generatorContext -> generator.generate(value -> {
            if (predicate.test(value)) {
                generatorContext.emit(value);
            }
        }));
    }

Здесь мы создали новый датасет с таким генератором, который получает значения от текущего генератора, но эмитирует дальше только те из них, что прошли проверку.

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

    public <R> Dataset<R> map(Function<T, R> function) {
        return new Dataset<>(generatorContext -> generator.generate(
                value -> generatorContext.emit(function.apply(value))
        ));
    }

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

Теперь запускаем всё вместе.

         Dataset.of(Arrays.asList("шла", "саша", "по", "шоссе"))
                .union(Arrays.asList("и", "сосала", "сушку"))
                .filter(s -> s.length() > 4)
                .map(s -> s + ", length=" + s.length())
                .forEach(System.out::println);

Вывод:
шоссе, length=5
сосала, length=6
сушку, length=5

Пора рефакторить. Первое, что бросается в глаза: интерфейс GeneratorContext можно заменить стандартным Consumer-ом. Заменяем. Местами и код сократится, так как ранее нам приходилось оборачивать Consumer-ы в GeneratorContext.

Тут стоит остановиться и обратить внимание на определенную схожесть нашего Dataset и java.util.stream.Stream, что наводит на мысли и о родстве нашего Generator-а с загадочными Spliterator-ами из платформы джава.

Велосипед готов. Надеюсь, вам тоже стали немного понятнее внутренние механизмы Stream API, роль Spliterator-а и способ организации ленивых вычислений.

PS. Git repo здесь.
Tags:
Hubs:
+18
Comments 12
Comments Comments 12

Articles