Потоки и последовательности в Kotlin

Потоки Java 8 доступны для использования в коде Kotlin при ориентации на JDK 8 или более позднюю версию. Мы используем Kotlin для наших бэкэнд-сервисов на Faire, поэтому частым вопросом было, использовать ли потоки или последовательности.

Мы проанализировали оба варианта с трех точек зрения, чтобы определить их сильные и слабые стороны:

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

В дополнение к нашим серверным службам мы также используем Kotlin для нашего приложения для Android. Хотя 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) в приведенном выше примере, компилятор не заставляет нас правильно использовать опциональные оболочки. Даже если бы мы использовали 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 { p1, p2 -> p2.age.compareTo(p1.age) }
  .get()
  .name

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

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

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

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

  • Примитивная обработка (логическое, символьное, байтовое, короткое, целое, длинное, плавающее и двойное)
  • Дополнительные значения
  • создание лямбды

Примитивное обращение

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

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

// Sequence
people.asSequence()
  .map { it.weight } // Auto-box 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 вместо того, чтобы отображать значение, а затем суммировать его как отдельный шаг. Они уменьшают автобоксинг, а также упрощают код.

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

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

Дополнительные значения

Потоки создают обертки Optional, когда значения могут отсутствовать (например, с 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.

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

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

// 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) } // 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 unnecessary autoboxing
val adultAgesSquared = people.asSequence()
  .filter { it.age >= 18 }
  .map { it.age * it.age } // single autobox
  .toList()

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

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

Общие выводы

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

Преимущества трансляции:

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

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

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

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

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

Мы нанимаем! faire.com/карьера