Самые популярные функции высшего порядка - это map, filter и reduce. Мы все используем их, так как думаем, что синтаксис намного лучше, и писать их даже быстрее, чем старый способ for-in loop. Но так ли это на самом деле? Задумывались ли вы когда-нибудь о производительности этих встроенных функций? Они встроенные, поэтому, естественно, они должны быть лучше, не правда ли? Давайте погрузимся в эти функции вместе, чтобы выяснить, так ли это на самом деле.
Примечание
Если вы найдёте статью интересной, то в этом канале я пишу об iOS-разработке.
Предварительные условия
Данные размером 10 000 000 элементов.
Каждый тестовый пример был выполнен 30 раз.
Для представления каждого тестового случая использовалась медиана, а не среднее, чтобы убедиться, что на результат не влияют выбросы.
Данные
Вот как выглядят данные: есть два массива. Один - массив fahrenheit, который представляет собой 1D массив равномерно распределенных случайных чисел из диапазона <32.0;113.0> и следующий массив, fahrenheits, который представляет собой 2D массив массивов равномерно распределенных случайных чисел из диапазона <32.0;113.0>.
let elementsCount = 10_000_000
let fahrenheit = Array(repeating: 0.0, count: elementsCount).map { _ -> Double in
return Double.random(in: 32.0 ... 113.0)
}
var fahrenheits = [[Double]]()
for _ in 0 ..< totalElements {
fahrenheits.append(Array(repeating: 0.0, count: 1).map { _ -> Double in
return Double.random(in: 32.0 ... 113.0)
})
}
Функция: Map
Давайте начнем с простого примера. У нас есть массив fahrenheit, и мы хотим преобразовать каждый его элемент в градусы Цельсия. Поэтому мы собираемся сравнить производительность функции map с циклом for-in.
// Swift
let celsius = fahrenheit.map { (degreesFahrenheit) -> Double in
return (degreesFahrenheit - 32.0) / 1.8
}
// For-in loop
var celsius = [Double]()
for degreesFahrenheit in fahrenheit {
celsius.append((degreesFahrenheit - 32.0) / 1.8)
}
Производительность
Как видно из рисунка, встроенная функция map намного быстрее чем цикл for-in. Если быть точным, то в 1,63 раза быстрее. Несомненно, нам следует использовать встроенную функцию map.
Статический и динамический массив.
Я не стал выделять массив с заранее заданным размером, потому что нет никакой разницы между производительностью статического и динамического выделенного массива. Я провел быстрый тест и вот результат. Это немного удивительно.
Функция: Filter
Следующая функция - это filter. Итак, давайте отфильтруем «холодные градусы» (cold degrees).
Почему я называю их холодными? Я считаю температуру меньше или равную 20 градусам Цельсия (68 градусов по Фаренгейту) холодной, и это причина, по которой я назвал переменную colds.
// Swift
let colds = fahrenheit.filter { (degreesFahrenheit) -> Bool in
return degreesFahrenheit <= 68.0
}
// For-in loop
var colds = [Double]()
for degreesFahrenheit in fahrenheit {
if degreesFahrenheit <= 68.0 {
colds.append(degreesFahrenheit)
}
}
Производительность
Разница между встроенной функцией фильтра и циклом for-in в данном случае не так очевидна. Встроенная функция немного быстрее (1.11x), чем цикл for-in. В любом случае, она все равно быстрее, поэтому имеет смысл использовать ее.
Функция: Reduce
Последняя функция - reduce. Для этого сложим все числа вместе. Например, мы можем использовать полученный результат для вычисления средней температуры.
// Swift
let sum = fahrenheit.reduce(0.0) { (result, degreesFahrenheit) -> Double in
return result + degreesFahrenheit
}
// For-in loop
var sum: Double = 0.0
for degreesFahrenheit in fahrenheit {
sum += degreesFahrenheit
}
Производительность
Результат немного удивляет. Встроенная функция reduce немного медленнее, чем цикл for-in. Встроенная функция reduce в цикле for-in быстрее в 1,05 раза.
Функция: FlatMap
Прежде чем мы рассмотрим цепочку функций, давайте сравним функцию flatMap с циклом for-in. У нас есть массив fahrenheits из массивов double и мы хотим сделать массив double.
// Swift
let fahrenheit = fahrenheits.flatMap({ (fahrenheit) -> [Double] in
return fahrenheit
})
// For-in loop
var fahrenheit = [Double]()
for lFahrenheit in fahrenheits {
fahrenheit.append(contentsOf: lFahrenheit)
}
Производительность
Результат очень похож на функцию reduce. Встроенная функция flatMap немного медленнее, чем цикл for-in. Встроенная функция flatMap в цикле for-in быстрее в 1,06 раза.
Цепочка (map+filter+reduce)
Пришло время для большего веселья. Как мы видели до сих пор, встроенные функции работают быстрее или чуть медленнее, чем пользовательский цикл for-in. Допустим, мы хотим преобразовать градусы Фаренгейта в градусы Цельсия, затем отфильтровать температуру простуды, затем сложить все отфильтрованные числа.
Это означает, что в начале у нас есть массив чисел fahrenheit, затем вызывается функция map, затем вызывается функция filter, затем вызывается функция reduce.
// Swift
let sum = fahrenheit.map({ (degreesFahrenheit) -> Double in
return (degreesFahrenheit - 32.0) / 1.8
}).filter({ (degreesCelsius) -> Bool in
return degreesCelsius <= 20.0
}).reduce(0.0) { (result, degreesCelsius) -> Double in
return result + degreesCelsius
}
// For-in loop
var sum: Double = 0.0
for degreesFahrenheit in fahrenheit {
let degreesCelsius = (degreesFahrenheit - 32.0) / 1.8
if degreesCelsius <= 20.0 {
sum += degreesCelsius
}
}
Производительность
Упс, как мы видим на фотографии, есть проблема.
Пользовательский цикл for-in в 2,37 раза быстрее. Когда мы используем цепочку, мы должны избегать встроенных функций и писать пользовательское решение с помощью цикла for-in.
Мы можем спросить: «Почему это происходит?», «Что не так с цепочкой функций?».
Я думаю, что очевидно, что происходит. Когда мы используем цепочку функций, нам приходится выполнять итерации над каждым результатом предыдущей функции. В теории информатики существует временная сложность (time complexity), которая описывает количество времени, необходимое для выполнения алгоритма.
В нашем случае, в начале у нас есть массив fahrenheit, и мы вызываем функцию map. В этот момент сложность равна O(n), линейное время, где n - размер массива fahrenheit. Затем мы применяем результат функции map к функции filter. Сложность функции filter также равна O(n). На данный момент общая временная сложность составляет O(2n). Последним шагом является функция reduce. Мы применяем результат функции фильтра к функции reduce. Сложность функции reduce снова равна O(n), потому что обозначение O является верхней границей скорости роста функции. Другими словами, в худшем случае результат функции filter может быть O(n), что означает, что ничего не было отфильтровано, и этот результат используется в качестве входа функции reduce. В итоге, временная сложность составляет O(3n).
Однако если мы используем цикл for-in, то все необходимые действия (map. filter, reduce) мы можем выполнить в одном цикле for-in. Благодаря этому факту, в конечном итоге сложность цикла for-in составляет O(n).
Map, Filter и Reduce в RxSwift
Для сравнения рассмотрим тот же пример в RxSwift. Мы будем использовать функции map, filter и reduce, как и в предыдущем примере.
Observable.from(fahrenheit)
.map({ (degreesFahrenheit) -> Double in
return (degreesFahrenheit - 32.0) / 1.8
})
.filter({ (degreesCelsius) -> Bool in
return degreesCelsius <= 20.0
})
.reduce(0.0, accumulator: ({ (result, degreesCelsius) -> Double in
return result + degreesCelsius
}))
.subscribe(onNext: { sum in
print(sum)
})
.disposed(by: disposeBag)
Перформанс
О, это совсем не хорошо!
Как видно из рисунка, RxSwift намного медленнее, чем встроенные функции и цикл for-in. Решение встроенных функций в 5,14 раз быстрее, чем RxSwift, а решение цикла for-in - в 12,21 раз быстрее, чем RxSwift.
Пожалуйста, не используйте RxSwift, если вам нужно работать с огромным количеством данных!
Заключение
Согласно тестовым примерам, нет ничего плохого в использовании функций высокого порядка, если нам НЕ нужно выстраивать их в цепочку. Производительность намного выше (1,63x быстрее) при использовании встроенной функции map или немного лучше/хуже при использовании встроенного filter/reduce.
Если нам нужны цепочки функций высокого порядка, мы должны подумать о том, чтобы не использовать их и реализовать их как решение для цикла for-in. Производительность намного выше, в 2,37 раза (в примерах этой статьи), чем у встроенных функций.
Нет необходимости всегда использовать современные элементы, структуры или функции современного языка только потому, что синтаксис выглядит лучше или все его используют. Временная и пространственная сложность гораздо важнее современного синтаксиса, и в конечном итоге, модный синтаксис не делает нас лучшими разработчиками, хотя нам так и может показаться.
Больше историй, подходов к реализации и инструментов для iOS-разработчика можно найти в авторском канале об iOS-разработке.
Комментарии (4)
YGeorge
15.04.2022 13:24+1Интересно было бы сравнить результаты на другом количестве элементов в массивах, вангую что цифры могут удивить)
Tap0k
Можно значительно улучшить производительность на чейнинге, если использовать lazy оператор. Скорость выполнения становится практически одинаковой.
let sum = fahrenheit
.lazy
.map({ (degreesFahrenheit) -> Double in
return (degreesFahrenheit - 32.0) / 1.8
})
.filter({ (degreesCelsius) -> Bool in
return degreesCelsius <= 20.0
})
.reduce(0.0) { (result, degreesCelsius) -> Double in
return result + degreesCelsius
}
Проверил тем же способом, только используя в 5 раз больше данных
let elementsCount = 50_000_000
let fahrenheit = Array(repeating: 0.0, count: elementsCount).map { _ -> Double in
return Double.random(in: 32.0 ... 113.0)
}
Запускал в релизной конфигурации с выключенным дебагом. Результат вот:
Chaining lazily average time = 9.5367431640625e-08
For-in average time = 3.5762786865234374e-08
Tap0k
Но с реактивщиной, конечно, да, всё грустно :)
AlexWoodblock
Угу, потому что сравнили палец с другой известной частью тела. В реактивщине куча всего другого происходит просто потому, что она, блин, не для маппинга статических данных предназначена. Гениальное сравнение - берется фреймворк, у которого другое предназначение, находятся ключевые слова, делающие похожие вещи, а затем для чего-то сравнивается. А если бы элементы с разностью в секунду выдавались в реактивном потоке - то разница уже в тысячи бы раз была, да?