Как создать Enumerable-коллекцию
Когда работаешь со внешними источниками данных - файлом, HTTP API или базой данных, иногда удобно от него абстрагироваться и работать с данными как с ленивой Enumerable-коллекцией, скрывая детали реализации - механизмы загрузки или парсинга данных. Ведь хочется простоты и абстракций.
Возьмем, например, внешний HTTP API. Сервисы вроде Facebook любят отдавать данные постранично. Приходится писать простой но объемный код для загрузки этих страниц, проверки признака завершения итд. Страничная природа данных может проникнуть с нижнего уровня HTTP-запросов на самый верх. Абстракция ленивой Enumerable-коллекции так и напрашивается сама собой.
Или рассмотрим другой пример - работа с большими CSV/XML файлами. Вместо того, чтобы загрузить в память и распарсить сразу весь файл, его можно обрабатывать по частям (строчкам или узлам) потребляя значительно меньше памяти.
Давайте разберем это на примере парсинга большого CSV-файла.
Парсим CSV-файл
Disclaimer: Есть уже готовый метод
CSV#each
, который отвечает всем нашим требованиям. Поэтому на практике можно и нужно использовать именно его.
Можно пойти простым путем и загрузить весь файл в память:
CSV.read('data.csv', headers: true)
На файле в 12 МБ у меня это занимает 4.3 секунды и 265 МБ оперативной памяти (бралась разница между объемом памяти Ruby-процесса до и после парсинга). Очевидно, что пиковое значение могло быть и больше ведь результат искажается работой сборщика мусора. Если его отключить, то потребление памяти выростает до 566 МБ.
Посмотрим как можно лениво парсить CSV-файл обрабатывая по одной строчке за раз:
File.open('data.csv', 'r') do |file|
csv = CSV.new(file, headers: true)
while row = csv.shift
puts row
end
end
Каждый раз вызывая метод shift
мы загружаем из файла и парсим
очередную строчку CSV-файла. Это занимает такое же время - 4.4
секунды, но потребляет всего 24 МБ.
Способ #1 - подключить модуль Enumerable
Это классический подход - создать новый класс и подмешать в него модуль
Enumerable
:
class LazyCSVCollection
include Enumerable
def initialize(path, options = nil)
@path = path
@options = options || {}
end
def each
File.open(@path, 'r') do |file|
csv = CSV.new(file, @options)
while row = csv.shift
yield row
end
end
end
end
seq = LazyCSVCollection.new('data.csv', headers: true)
seq.map { |r| r.to_h }
Способ #2 - создать новый enumerator
В самом деле, класс Enumerator
уже подмешивает в себя Enumerable
и
создать enumerable можно разными способами. Один из них - использовать
конструктор Enumerator.new
. Это даже проще чем первый вариант - можно
обойтись без нового класса:
seq = Enumerator.new do |y|
File.open('data.csv', 'r') do |file|
csv = CSV.new(file, headers: true)
while row = csv.shift
y << row
end
end
end
seq.map { |r| r.to_h }
Способ #3 - использовать Object#to_enum
Возможно, уже есть подходящий метод-итератор, который принимает блок но, к
сожалению, не возвращает enumerator. Остается только превратить
метод-итератор в объект-enumerator. Для этого нам пригодится метод
to_enum
.
В случае с CSV нам подходит метод CSV.foreach
, который принимает блок
и проходится по строчкам файла:
CSV.foreach('data.csv', headers: true) do |row|
puts row.to_h
end
Чтобы получить enumerator, достаточно сделать следующее:
seq = CSV.to_enum(:foreach, 'data.csv', headers: true)
seq.map { |r| r.to_h }
Метод с foreach
аналогичен варианту с CSV#each
но немного удобнее,
ведь теперь не надо открывать и закрывать файл вручную. Кстати,
потребляется еще меньше памяти - 13 мб.