Еще один трюк с Enumerator
Недавно открыл для себя один элегантный трюк в Ruby. Приятно узнавать что-то новое о хорошо знакомом инструменте. Этот трюк, или даже идиома, помогает создать enumerator из существующего уже метода-итератора. Довольно редкая задача если пилишь какой-то коммерческий проект - поэтому трюк пригодится скорее при разработке библиотек.
Конвенции Ruby
Вначале немножко теории. Особо нетерпеливым можно пропустить и перейти к следующему разделу.
В Ruby метод, который принимает блок, называют методом-итератором.
Наверное потому, что такие методы как each
или each_slice
, которые
принимают блок, формально относятся к внутренним итераторам.
В противовес внешним итераторам, таким как класс Enumerator
из
corelib. Вероятней всего поэтому термин и перешел на все методы с
аргументом-блоком.
Так вот, в corelib Ruby есть конвенция - любой метод-итератор можно вызвать без блока. В результате вернется enumerator, который можно отложить в сторону, подождать, а затем продолжить работать как с обычной коллекцией.
Давайте разберем это на примере. Рассмотрим метод each_slice
, который
разбивает массив на подмассивы заданной длины:
(1..10).each_slice(3) { |a| p a }
[1, 2, 3]
[4, 5, 6]
[7, 8, 9]
[10]
=> nil
Если его вызвать без блока, то вернется enumerator, который можно обработать позднее.
enum = (1..10).each_slice(3)
=> #<Enumerator: 1..10:each_slice(3)>
enum.to_a
=> [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]
enum.map(&:sum)
=> [6, 15, 24, 10]
enum.map { |e| e.to_a.reverse }.reduce(:+)
=> [3, 2, 1, 6, 5, 4, 9, 8, 7, 10]
Это настолько распространенная конвенция, что даже методы из Enumerable
для которых это бессмысленно, например find
,detect
, group_by
,
max_by
/min_by
или sort_by
тоже можно вызвать без блока.
Более того, эта практика вышла за пределы corelib. Ее можно встретить и в сторонних библиотеках и фреймворках. Ей следуют, например, в Rails. Вот несколько таких методов:
find_each
иfind_in_batches
из ActiveRecordeach_pair
,each_value
,transform_values
,transform_keys
из ActionController::Parametersindex_by
,index_with
из Enumerable extensioneach_record
,each
из ActiveRecord::Batches::BatchEnumerator
Заглянем под капот
Вернемся к нашему трюку. Наткнулся я на него не совсем случайно - мне хотелось разобраться как такие методы-итераторы реализованы в Rails. И заглянув в исходники я понял как это работает далеко не сразу.
Давайте посмотрим на метод transform_keys!
из
ActionController::Parameters
(source):
# Performs keys transformation and returns the altered
# <tt>ActionController::Parameters</tt> instance.
def transform_keys!(&block)
return to_enum(:transform_keys!) unless block_given?
@parameters.transform_keys!(&block)
self
end
Метод преобразует ключи в ассоциативном массиве
(ActionController::Parameters
). Если метод вызвали с блоком, то
выполняется основное действие:
@parameters.transform_keys!(&block)
Но если блок не передали, то метод возвращает enumerator:
return to_enum(:transform_keys!) unless block_given?
Вот это и есть тот самый трюк. Метод to_enum
создает enumerator,
который внутри себя вызывает метод с именем :transform_keys!
но
уже передает блок. Просто и элегантно!
Метод to_enum
to_enum
- это метод класса Object
и доступен везде, так как
практически все классы наследуются от Object. Используя to_enum
можно
сделать Enumerator
из любого метода-итератора.
Пример #1
Проиллюстрируем это на примере класса String
и метода each_byte
.
Этот метод работает с байтовым представлением строки и конечно же, если
не указал блок, он и сам вернет enumerator. Но в нашем примере (взял
отсюда)
мы добьемся того же используя to_enum
:
string = "xyz"
enum = string.to_enum(:each_byte)
enum.each { |b| puts b }
# => 120
# => 121
# => 122
Здесь происходит следующее. Нам дана строка и мы хотим работать с ее
байтовым представлением. Удобнее всего работать с коллекцией, которая
подключает модуль Enumerable
и нам доступна целая куча удобных методов -
map
, select
, reduce
итд.
Но класс String
это не только коллекция байт. Есть и другие
представления - символы, codepoint‘ы итд. Поэтому в String
есть
методы для работы с этими данными - each_byte
, each_char
,
each_codepoint
, each_line
и each_grapheme_cluster
. Но нет метода
each
так как нет основного представления, а есть несколько
равноправных.
Но ведь нам нужны байты. Нам нужен объект с методом each
, который
будет итерировать по байтам строки. Именно с этим и помогает метод
to_enum
.
Пример #2
Можно придумать и более изощренное применение. Например, возьмем
модель ActiveRecord. Метод create
принимает опциональный блок, а
поэтому мы можем использовать to_enum
.
Обычный вызов метода create
с блоком:
Account.create do |a|
a.name = 'b'
end
А теперь пример с to_enum
:
enum = Account.to_enum(:create)
enum.each { |a| a.name = 'b' }
В чем же разница? В первую очередь разница в абстракции. Во втором примере у нас есть коллекция пусть всего из одного элемента.
Более того enumerator делает это выражение ленивым. Метод create
будет вызван и сохранит данные в базу только после вызова метода each
:
enum = Account.to_enum(:create)
=> #<Enumerator: Account(id: integer, name: string):create>
enum.each { |a| a.name = 'b' }
# (0.2ms) SAVEPOINT active_record_1
# Account Create (0.3ms) INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "b"]]
# (0.1ms) RELEASE SAVEPOINT active_record_1
=> #<Account id: 3, name: "b">
Реализация to_enum
В методе to_enum
нет никакой магии. Это просто обертка над
конструктором класса Enumerator
. Приведу
реализацию
из проекта TruffleRuby:
def to_enum(method=:each, *args, &block)
Enumerator.new(self, method, *args).tap do |enum|
enum.__send__ :size=, block if block_given?
end
end
alias_method :enum_for, :to_enum
Заключение
Я столкнулся c to_enum
совершенно случайно, когда надо было добавить
метод-итератор (очередной each_smth) в своей библиотечке и я начал
искать примеры в исходниках Rails. Удивительно, но до сих пор не
встречал даже упоминания об этой технике. Это интересный и красивый
трюк, который поможет написать идиоматичный и выразительный код.