Kotlin stdlib operatios против циклов

Я написал следующий код:

val src = (0 until 1000000).toList()
val dest = ArrayList<Double>(src.size / 2 + 1)    

for (i in src)
{
    if (i % 2 == 0) dest.add(Math.sqrt(i.toDouble()))
}

IntellJ (в моем случае AndroidStudio) спрашивает меня, хочу ли я заменить цикл for на операции из stdlib. Это приводит к следующему коду:

val src = (0 until 1000000).toList()
val dest = ArrayList<Double>(src.size / 2 + 1)
src.filter { it % 2 == 0 }
   .mapTo(dest) { Math.sqrt(it.toDouble()) }

Теперь я должен сказать, мне нравится измененный код. Мне легче писать, чем для циклов, когда я сталкиваюсь с подобными ситуациями. Однако, прочитав, что делает функция filter, я понял, что это намного более медленный код по сравнению с циклом for. Функция filter создает новый список, содержащий только элементы из src, которые соответствуют предикату. Таким образом, есть еще один список и еще один цикл в stdlib-версии кода. Ofc для небольших списков это может быть не важно, но в целом это не похоже на хорошую альтернативу. Особенно, если нужно объединить больше таких методов, вы можете получить много дополнительных циклов, которых можно было бы избежать, написав цикл for.

Мой вопрос - это то, что считается хорошей практикой в ​​Котлине. Должен ли я придерживаться циклов или я что-то упускаю, и это не работает, поскольку я думаю, что это работает.

Ответ 1

Если вас беспокоит производительность, вам нужно Sequence. Например, ваш код будет

val src = (0 until 1000000).toList()
val dest = ArrayList<Double>(src.size / 2 + 1)
src.asSequence()
    .filter { it % 2 == 0 }
    .mapTo(dest) { Math.sqrt(it.toDouble()) }

В приведенном выше коде filter возвращает еще один Sequence, который представляет собой промежуточный шаг. Пока ничего не создано, нет создания объекта или массива (кроме новой оболочки Sequence). Только когда mapTo вызывается оператор терминала, создается ли результирующий набор.

Если вы узнали поток java 8, вы можете найти приведенное выше объяснение несколько знакомым. На самом деле, Sequence является примерно котлинским эквивалентом java 8 Stream. Они обладают сходными характеристиками и характеристиками. Единственное отличие: Sequence не предназначен для работы с ForkJoinPool, поэтому гораздо проще реализовать.

Когда задействовано несколько шагов или коллекция может быть большой, она предложила использовать Sequence вместо plain .filter {...}.mapTo{...}. Я также предлагаю вам использовать форму Sequence вместо вашей императивной формы, потому что ее легче понять. Императивная форма может стать сложной, поэтому ее трудно понять, когда в обработке данных есть 5 или более шагов. Если есть только один шаг, вам не нужен Sequence, потому что он просто создает мусор и не дает вам ничего полезного.

Ответ 2

Вам что-то не хватает.: -)

В этом конкретном случае вы можете использовать IntProgression:

val progression = 0 until 1_000_000 step 2

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

// may make the list larger than necessary
// its internal array is copied each time the list grows beyond its capacity
// code is very straight forward
progression.map { Math.sqrt(it.toDouble()) }

// will make the list the exact size needed
// no copies are made
// code is more complicated
progression.mapTo(ArrayList(progression.last / 2 + 1)) { Math.sqrt(it.toDouble()) }

// will make the list the exact size needed
// a single intermediate list is made
// code is minimal and makes sense
progression.toList().map { Math.sqrt(it.toDouble()) }

Ответ 3

Мой совет - выбрать какой бы стиль кодирования вы ни выбрали. Котлин - объектно-ориентированный и функциональный язык, что означает, что оба ваши предложения верны.

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

Ответ 4

Преобразованный код не нуждается в ручном создании списка адресатов и может быть упрощен до:

val src = (0 until 1000000).toList()

val dest = src.filter { it % 2 == 0 }
              .map { Math.sqrt(it.toDouble()) }

И как упоминалось в превосходном ответе @glee8e, вы можете использовать последовательность для ленивой оценки. Упрощенный код для использования последовательности:

val src = (0 until 1000000).toList()

val dest = src.asSequence()                      // change to lazy
              .filter { it % 2 == 0 }
              .map { Math.sqrt(it.toDouble()) }
              .toList()                          // create the final list

Обратите внимание, что добавление toList() в конце - это переход от последовательности к окончательному списку, который является одной копией, сделанной во время обработки. Вы можете опустить этот шаг, чтобы оставаться в виде последовательности.

Важно подчеркнуть комментарии @hotkey, говорящие, что вы не должны всегда предполагать, что другая итерация или копия списка ухудшают производительность, чем ленивая оценка. @hotkey говорит:

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

И выдержка из этой ссылки:

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

@glee8e говорит, что существуют сходства между последовательностями Kotlin и потоками Java 8, для подробных сравнений см.: Какие эквиваленты Java 8 Stream.collect доступны в стандартной библиотеке Kotlin?