Бесплатный курс по Elixir. Зарегистрируйтесь для отслеживания прогресса →

Elixir: Стримы

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

В большинстве языков программирования вычисления происходят в момент их описания, а к примеру, в Haskell, все вычисления по умолчанию ленивы.

Наивная реализация ленивых вычислений - анонимная функция, сохраненная в переменную, вызов которой сделаем дальше по коду, основная сложность возникает, когда мы хотим сделать вызов комбинации из таких "ленивых функций". Для этого есть модуль Stream. В нем есть такие же функции работы с коллекциями как map, filter, reduce, но с некоторыми отличиями, которые мы рассмотрим дальше:

numbers = [1,2,3]

numbers
  |> Enum.map(&(&1 * &1))
  |> Enum.zip([:a, :b, :c])
  |> Enum.filter(fn({n, s}) -> n > 2 and s != :c end)
# => [{4, :b}]

В этом случае мы прошлись три раза по всему списку, так как промежуточные результаты передаются дальше по цепочке вызовов функций. Если бы мы обходили список в каком-нибудь императивном языке, то использовали бы примерно в 3 раза меньше памяти и времени CPU. Теперь используем модуль Stream:

numbers = [1,2,3]

numbers
  |> Stream.map(&(&1 * &1))
  |> Stream.zip([:a, :b, :c])
  |> Stream.filter(fn({n, s}) -> n > 2 and s != :c end)
  |> Enum.to_list
# => [{4, :b}]

Результат остался тем же, только в конце добавили функцию Enum.to_list, которая запускает цепочку ленивых вычислений. Без него результат был бы таким:

numbers = [1, 2, 3]

numbers
  |> Stream.map(&(&1 * &1))
  |> Stream.zip([:a, :b, :c])
  |> Stream.filter(fn({n, s}) -> n > 2 and s != :c end)
# => #Stream<[
# =>   enum: #Function<73.29975488/2 in Stream.zip_with/2>,
# =>   funs: [#Function<40.29975488/1 in Stream.filter/2>]
# => ]>

Мы получили некую структуру данных, в ней описаны вычисления, которые будут применены к этой структуре при вычислении.

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

Enum.map(1..10_000_000, &(&1 + 1)) |> Enum.take(5)
# => [2, 3, 4, 5, 6]

Stream.map(1..10_000_000, &(&1 + 1)) |> Enum.take(5)
# => [2, 3, 4, 5, 6]

Второй случай вычислится быстрее. Почему? В первом случае вычислится весь список, во втором только нужная часть списка, а точнее первые пять элементов, именно так Stream и работает.

Возьмем еще один пример. Допустим у нас есть большой файл, который содержит всю текстовую часть статей из Википедии, который весит примерно 20 Гб и мы хотим в нем найти самое длинное слово. Реализуем через Enum:

File.read!("data/wiki.txt")
  |> String.split("\n")
  |> Enum.map(fn(line) -> String.split(line, " ") end)
  |> Enum.map(fn(words) -> ...
  |> Enum.max_by

В этом случае мы загрузим в оперативную память весь файл, который весит 20 Гб, так еще и пройдемся по нему 4 раза!

Теперь воспользуемся Stream:

File.stream!("data/wiki.txt")
  |> Stream.map(fn(line) -> String.split(line, " ") end)
  |> Stream.map(fn(words) -> ...
  |> Enum.max_by

Функция File.stream! возвращает ленивую коллекцию, которая построчно читает файл. В этом случае мы загружаем в оперативную память данные небольшими порциями и выполняем только один проход по ним. Последний вызов Enum.max_by запускает вычисления. Не факт, что этот код будет быстрее, так как мы много раз обращаемся к диску, но он точно расходует намного меньше оперативной памяти.

Иногда коллекции бывает бесконечными. На такой случай Stream имеет функции cycle, repeatedly, iterate, unfold, resource. Рассмотрим cycle и unfold.

Stream.cycle принимает коллекцию и генерирует бесконечную коллекцию из повторений элементов переданной:

Stream.cycle([1, 2, 3, 4, 5]) |> Enum.take(20)
# => [1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 1, 2, 3, 4, 5]

Это можно использовать, например для генерации HTML элементов:

texts = ["foo", "bar", "hello", "hexlet"]

Stream.cycle(["red", "black", "green"])
  |> Stream.zip(texts)
  |> Enum.map(fn {color, text} -> "<p class=text-#{color}>#{text}</p>" end)

# => ["<p class=text-red>foo</p>", "<p class=text-black>bar</p>", "<p class=text-green>hello</p>", "<p class=text-red>hexlet</p>"]

Stream.unfold можно представить, как функцию, обратную свертке (fold или reduce). Если fold сворачивает список в одиночное значение, то unfold наоборот, из одиночного значения разворачивает список.

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

Рассмотрим пример с числами Фибоначчи:

initial_state = {0, 1}

unfolder = fn({val1, val2}) ->
  curr_val = val1
  new_state = {val2, val1 + val2}
  {curr_val, new_state}
end

Stream.unfold(initial_state, unfolder) |> Enum.take(20)
# => [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181]

Задание

Создайте функцию generate, которая случайно генерирует бесконечную последовательность чисел от 1 до 20 и берет n элементов от этой коллекции:

Solution.generate(5)
# => [2, 10, 20, 12, 11]
Solution.generate(2)
# => [7, 14]
Solution.generate(20)
# => [2, 9, 18, 1, 3, 16, 1, 1, 20, 20, 9, 11, 10, 14, 10, 15, 3, 3, 10, 8]
Упражнение не проходит проверку — что делать? 😶

Если вы зашли в тупик, то самое время задать вопрос в «Обсуждениях». Как правильно задать вопрос:

  • Обязательно приложите вывод тестов, без него практически невозможно понять что не так, даже если вы покажете свой код. Программисты плохо исполняют код в голове, но по полученной ошибке почти всегда понятно, куда смотреть.
В моей среде код работает, а здесь нет 🤨

Тесты устроены таким образом, что они проверяют решение разными способами и на разных данных. Часто решение работает с одними входными данными, но не работает с другими. Чтобы разобраться с этим моментом, изучите вкладку «Тесты» и внимательно посмотрите на вывод ошибок, в котором есть подсказки.

Мой код отличается от решения учителя 🤔

Это нормально 🙆, в программировании одну задачу можно выполнить множеством способов. Если ваш код прошел проверку, то он соответствует условиям задачи.

В редких случаях бывает, что решение подогнано под тесты, но это видно сразу.

Прочитал урок — ничего не понятно 🙄

Создавать обучающие материалы, понятные для всех без исключения, довольно сложно. Мы очень стараемся, но всегда есть что улучшать. Если вы встретили материал, который вам непонятен, опишите проблему в «Обсуждениях». Идеально, если вы сформулируете непонятные моменты в виде вопросов. Обычно нам нужно несколько дней для внесения правок.

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

Полезное


Нашли ошибку? Есть что добавить? Пулреквесты приветствуются https://github.com/hexlet-basics
Если вы столкнулись с трудностями и не знаете, что делать, задайте вопрос в нашем большом и дружном сообществе