Генераторы (for) и ленивый Stream
Два дополнительных инструмента обработки: выразительный генератор for и ленивый модуль Stream, который обрабатывает данные «по требованию».
Comprehension собирает результат декларативно. Stream откладывает вычисления до момента, когда они реально нужны — и экономит память на больших данных.
Генератор for комбинирует обход, фильтрацию и преобразование в одном выражении:
for x <- 1..5, rem(x, 2) == 1, do: x * x
# => [1, 9, 25]
# Несколько генераторов — декартово произведение
for x <- [1, 2], y <- [10, 20], do: x * y
# => [10, 20, 20, 40]
Stream строит ленивый конвейер: операции не выполняются, пока результат не «материализуют» (например, через Enum):
1..1_000_000
|> Stream.filter(&(rem(&1, 2) == 0))
|> Stream.map(&(&1 * 3))
|> Enum.take(5) # вычислит ровно столько, сколько нужно
# => [6, 12, 18, 24, 30]
С Enum здесь создалось бы два промежуточных списка по миллиону элементов. Stream же проходит данные один раз и только до пятого результата.
Как работает под капотом (BEAM)
Stream не вычисляет ничего сразу — он строит описание конвейера (по сути, вложенные функции). Реальная работа начинается, когда вы зовёте «материализующую» функцию вроде Enum.take, Enum.to_list или Stream.run. Тогда элементы тянутся по одному через всю цепочку: для каждого элемента сразу применяются filter и map, и если уже набрано нужное число — обход останавливается. Это и есть экономия: ни промежуточных списков, ни лишних проходов. Поэтому Stream незаменим для больших или бесконечных последовательностей.
Enum (eager): данные -> [весь список] -> [весь список] -> ...
(память O(n) на каждый шаг)
Stream (lazy): элемент -> filter -> map -> собрать
берём по одному, останавливаемся когда хватит
Та же идея на Python ▶
В Python ленивость дают генераторы — прямой аналог Stream.
from itertools import islice
# Comprehension с фильтром, как for ... do
print([x * x for x in range(1, 6) if x % 2 == 1]) # [1, 9, 25]
# Ленивый конвейер = генераторы (как Stream)
def lazy_pipeline():
evens = (x for x in range(1, 1_000_001) if x % 2 == 0)
tripled = (x * 3 for x in evens)
return list(islice(tripled, 5)) # берём только 5
print(lazy_pipeline()) # [6, 12, 18, 24, 30]
# Ни одного промежуточного списка на миллион элементов не создано
Частые ошибки
- Использовать Stream для коротких коллекций. На малых данных ленивость даёт накладные расходы без выгоды — берите Enum.
- Забыть материализовать Stream. Без финального Enum/Stream.run ленивый конвейер не выполнится — «ничего не происходит».
- Думать, что
forленив. Comprehension энергичен; для ленивости нужен именно Stream.
Best practices
- Enum — по умолчанию; Stream — когда данные большие, бесконечные или нужен ранний выход (
take). - Завершайте Stream-конвейер материализующей функцией (
Enum.to_list,Enum.take). - Используйте
forдля компактных «собери список с фильтром» и декартовых произведений.
Итог. Comprehension даёт декларативную сборку, Stream — ленивую обработку без лишней памяти. С коллекциями мы разобрались. Пора к главному, ради чего выбирают Elixir, — процессам и модели акторов.
Бесконечные последовательности и потоки данных
По-настоящему сила Stream раскрывается на потенциально бесконечных источниках. Stream.iterate(0, &(&1 + 1)) описывает бесконечную последовательность натуральных чисел, не вычисляя ни одного, пока вы не возьмёте конкретное количество через Enum.take. Так же лениво можно читать огромный файл построчно через File.stream!, обрабатывая гигабайты данных при постоянном расходе памяти — строки тянутся по одной и не держатся все сразу.
У for-генератора, в свою очередь, есть приятные возможности сверх простого map+filter: опция into: позволяет собирать результат не в список, а, скажем, в map или строку, а несколько генераторов подряд дают вложенный обход (декартово произведение) в одну строку. Эти два инструмента дополняют друг друга: for — для декларативной сборки конечного результата с фильтрами, Stream — для ленивой обработки больших или бесконечных потоков. Вместе с Enum они покрывают практически все потребности в обходе данных, не вынуждая писать рекурсию руками.