Потоки Java 8 доступны для использования в коде Kotlin при нацеливании на JDK 8 или новее. Среди разработчиков, использующих Kotlin для внутренней разработки, часто возникает вопрос, использовать ли потоки или последовательности. Хотя разработчики Android не могут настроить таргетинг на JDK 8, я включил несколько сюрпризов, которые влияют на то, как мы работаем с последовательностями и структурируем наш код.

В этой статье оба варианта анализируются с трех точек зрения, чтобы определить их сильные и слабые стороны:

  • Нулевая безопасность
  • Читаемость и простота
  • Накладные расходы на производительность

Нулевая безопасность

Использование потоков Java в коде Kotlin приводит к типам платформы при использовании непримитивных значений. Например, следующий результат оценивается как List<Person!> вместо List<Person>, поэтому он становится менее строго типизированным:

people.stream()
    .filter { it.age > 18 }
    .toList() // evaluates to List<Person!>

При работе со значениями, которые могут отсутствовать, последовательности возвращают типы, допускающие значение NULL, тогда как потоки заключают результат в Optional:

// Sequence
val nameOfAdultWithLongName = people.asSequence()
    ...
    .find { it.name.length > 5 }
    ?.name
// Stream
val nameOfAdultWithLongName = people.stream()
    ...
    .filter { it.name.length > 5 }
    .findAny()
    .get() // unsafe unwrapping of Optional
    .name

Хотя мы могли бы использовать orElse(null) в приведенном выше примере, компилятор не заставляет нас правильно использовать Optional оболочки. Даже если бы мы действительно использовали orElse(null), результирующее значение было бы типом платформы, поэтому компилятор не обеспечивает безопасное использование. Этот шаблон несколько раз укусил нас, поскольку в потоковой версии будет выброшено исключение времени выполнения, если никто не будет найден. Однако последовательности используют типы Kotlin, допускающие значение NULL, поэтому безопасное использование обеспечивается во время компиляции.

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

Читаемость и простота

Использование коллекторов для терминальных операций делает потоки более подробными:

// Sequence
val adultsByGender = people.asSequence()
    .filter { it.age >= 18 }
    .groupBy { it.gender }
// Stream
val adultsByGender = people.stream()
    .filter { it.age >= 18 }
    .collect(Collectors.groupingBy<Person, Gender> { it.gender })

Хотя приведенное выше не является слишком сложным, мне потребовалось несколько минут, чтобы скомпилировать версию с потоками, поскольку требовались общие типы. Я ожидал, что карта будет от Gender до List<Person>, поэтому я изо всех сил пытался правильно указать типы (обратите внимание, что порядок также является обратным). Мне пришлось вытащить подпись функций collect & groupingBy, чтобы увидеть, как они связаны, прежде чем я наконец смог их скомпилировать.

Последовательности могут быть короче за счет специализированных действий:

// Sequence
people.asSequence()
    .mapNotNull { it.testScore } // map & filter in 1 action
    ...
// Stream
people.stream()
    .map { it.testScore }
    .filter { it != null }
    ...

Последовательности имеют более чистые агрегаты:

// Sequence
val nameOfOldestHealthyPerson = people.asSequence()
    .filter { it.isHealthy() }
    .maxBy { it.age }
    ?.name
// Stream
val nameOfOldestHealthyPerson = people.stream()
    .filter { it.isHealthy() }
    .max { person1, person2 ->  person2.age.compareTo(person1.age)}
    .get()
    .name

Потоки делают этот пример более неуклюжим, поскольку мне нужно было предоставить компаратор (здесь также есть скрытый дефект). Кроме того, операторы safe-call и Elvis не работают с Optional оболочками, что приводит к более подробному коду с потоками.

Следовательно, последовательности короче, проще и приводят к более идиоматическому коду.

Накладные расходы на производительность

Есть 3 основных аспекта, которые влияют на накладные расходы производительности последовательностей и потоков:

  • Примитивная обработка (логическое значение, char, byte, short, int, long, float и double)
  • Необязательные значения
  • Создание лямбды

Примитивная обработка

Хотя Kotlin не раскрывает примитивные типы в своей системе типов, он использует примитивы за кулисами, когда это возможно. Например, Double (Double?), допускающий значение NULL, сохраняется как java.lang.Double за кулисами, тогда как Double, не допускающий значения NULL, по возможности сохраняется как примитив double.

У потоков есть примитивные варианты, чтобы избежать автобокса, но последовательности не:

// Sequence
people.asSequence()
    .map { it.weight } // Autobox non-nullable Double
    ...
// Stream
people.stream()
    .mapToDouble { it.weight } // DoubleStream from here onwards
    ...

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

// Stream
val testScores = people.stream()
    .filter { it.testScore != null }
    .mapToDouble { it.testScore!! } // Very bad! Use map { ... }
    .toList() // Unnecessary autoboxing because we unboxed them

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

Когда автобоксирование происходит в результате последовательностей, это приводит к очень эффективному шаблону использования кучи. Последовательности (и потоки) передают каждый элемент через все действия последовательности до достижения терминальной операции перед переходом к следующему элементу. Это приводит к тому, что в любой момент времени имеется только один достижимый объект с автоматической коробкой. Сборщики мусора разработаны так, чтобы работать с недолговечными объектами, поскольку перемещаются только уцелевшие объекты, поэтому автобоксирование, возникающее в результате последовательностей, является наилучшим из возможных / наименее затратным типом использования кучи. Память этих недолговечных автоматически запакованных объектов не будет затоплять оставшиеся места, поэтому это будет использовать эффективный путь сборщика мусора, а не вызывать полные сборки.

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

Необязательные значения

Потоки создают необязательные оболочки, когда значения могут отсутствовать (например, с min, max, reduce, find и т. Д.), Тогда как последовательности используют типы, допускающие значение NULL:

// Sequence
people.asSequence()
    ...
    .find { it.name.length > 5 } // returns nullable Person
// Stream
people.stream()
    ...
    .filter { it.name.length > 5 }
    .findAny() // returns Optional<Person> wrapper

Поэтому последовательности более эффективны с необязательными значениями, поскольку они избегают создания объекта-оболочки Optional.

Создание лямбда

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

// Sequence
people.asSequence()
    .mapNotNull { it.testScore } // create lambda instance
    ...
// Stream
people.stream()
    .map { it.testScore } // create lambda instance
    .filter { it != null } // create another lambda instance
    ...

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

people.asSequence()
    .filter { it.age >= 18 }
    .forEach { println(it.name) } // forEach inlined at compile time

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

Выводы о накладных расходах

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

Обратите внимание, что порядок операций может существенно повлиять на количество вхождений автобокса:

// Before
val adultAgesSquared = people.asSequence()
    .map { it.age } // autobox non-nullable age
    .filter { it >= 18 } // throw away some autoboxed values
    .map { it * it } // square and autobox again
    .toList()
// After - No unnecesarry autoboxing
val adultAgesSquared = people.asSequence()
    .filter { it.age >= 18 } 
    .map { it.age * it.age } // single autobox
    .toList()

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

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

Общие выводы

Ни одно решение не является лучшим во всех возможных сценариях, поэтому я суммировал результаты ниже.

Преимущества потоковой передачи:

  • Используйте примитивные варианты, чтобы избежать ненужного автобокса
  • Обеспечивает простой параллелизм через параллельные потоки (но обратите внимание, что в этих трех статьях рекомендуется избегать параллельных потоков: первый, второй, третий)

Преимущества последовательности:

  • Не вводите типы платформ
  • Используйте нулевую безопасность во время компиляции и хорошо взаимодействуйте с операторами safe-call и Elvis
  • Короче из-за меньшего количества операций
  • Иметь более простые агрегаты
  • Упростите терминальные операции
  • Не создавайте объекты-оболочки для значений, которые могут отсутствовать
  • Создайте меньше экземпляров лямбда-выражений, и большинство операций терминала будут встроены, что приведет к повышению эффективности за счет меньшего количества косвенных обращений.
  • Имейте более простую ментальную модель

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

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