IdempotentEnumerable
IdempotentEnumerable
is like Ruby core's Enumerable
but tries to preserve the class of the
collection it included in, where reasonable.
Features/Showcase
require 'set'
s = Set.new(1..5)
# => #<Set: {1, 2, 3, 4, 5}>
s.reject(&:odd?)
# => [2, 4] -- FFFUUUU
require 'idempotent_enumerable'
Set.include IdempotentEnumerable
s.reject(&:odd?)
# => #<Set: {2, 4}> -- Nice!
IdempotentEnumerable
relies on fact your each
method returns an instance of Enumerator
(or
other Enumerable
object) when called without block. Which, honestly, it should do anyways.
To construct back an instance of original class, IdempotentEnumerable
relies on the fact
OriginalClass.new(array)
call will work. But, if your class provides another way for construction
from array, you can still use the module:
h = {a: 1, b: 2, c: 3}
# => {:a=>1, :b=>2, :c=>3}
h.first(2)
# => [[:a, 1], [:b, 2]]
Hash.include IdempotentEnumerable
# To make hash from array of pairs, one should use `Hash[array]` notation.
Hash.idempotent_enumerable.constructor = :[]
h.first(2)
# => {:a=>1, :b=>2}
IdempotentEnumerable
also supports complicated collections, with each
accepting additional
arguments, out of the box (daru used as an example):
require 'daru'
Daru::DataFrame.include IdempotentEnumerable
df = Daru::DataFrame.new([[1,2,3], [4,5,6], [7,8,9]])
# #<Daru::DataFrame(3x3)>
# 0 1 2
# 0 1 4 7
# 1 2 5 8
# 2 3 6 9
# :column argument would be passed to DataFrame#each, so we are selecting columns
df.select(:column) { |col| col.sum > 6 }
# #<Daru::DataFrame(3x2)>
# 0 1
# 0 4 7
# 1 5 8
# 2 6 9
Reasons
IdempotentEnumerable
can be used as:
- soft patch to existing Ruby collections (like
Set
orHash
); - custom reimplementations of generic collections (some
FasterArray
); - custom specialized collection, like Nokogiri::XML::NodeSet,
which quacks like
Array
, but also provides XML/CSS navigation methods. Unfortunately, if you'll do something likedoc.search('a').reject { |a| a.text.include?('Google') }
, you'll receive regularArray
that haven't any useful#at
/#search
methods anymore.
Installation and usage
gem install idempotent_enumerable
or gem 'idempotent_enumerable'
in your Gemfile
.
Then follow examples in this README.
List of methods redefined
Methods that return single collection
- drop;
- drop_while;
- first (when used with argument);
- grep;
- grep_v (RUBY_VERSION >= 2.3);
- max (when used with argument, RUBY_VERSION >= 2.2);
- max_by (when used with argument, RUBY_VERSION >= 2.2);
- min (when used with argument, RUBY_VERSION >= 2.2);
- min_by (when used with argument, RUBY_VERSION >= 2.2);
- reduce;
- reject;
- select;
- sort;
- sort_by;
- take;
- take_while;
- uniq (RUBY_VERSION >= 2.4).
Methods that return (or emit) several collections
For methods like partition that
somehow split an enumerable sequence into several, IdempotentEnumerable
preserves the type of
internal sequence. E.g.:
Set.include IdempotentEnumerable
set = Set.new(1..5)
set.partition(&:odd?)
# => [#<Set: {1, 3, 5}>, #<Set: {2, 4}>]
set.each_slice(3).to_a
# => [#<Set: {1, 2, 3}>, #<Set: {4, 5}>]
- chunk;
- chunk_while (RUBY_VERSION >= 2.3);
- each_cons;
- each_slice;
- group_by (returns hash with keys being group keys and values being original collection type);
- partition;
- slice_after (RUBY_VERSION >= 2.2);
- slice_before;
- slice_when (RUBY_VERSION >= 2.2).
Optionally redefined methods
Generally speaking, map
and flat_map
can return collection of anything, probably not coercible
to original collection type, so they are not redefined by default.
But they can be redefined with optional idempotent_enumerable.redefine_map!
call:
Set.include IdempotentEnumerable
set = Set.new(1..5)
set.map(&:to_s)
# => ["1", "2", "3", "4", "5"]
Set.idempotent_enumerable.redefine_map!
set.map(&:to_s)
# => #<Set: {"1", "2", "3", "4", "5"}>
redefine_map!
has two options:
-
only:
(by default[:map, :flat_map]
) to specify that you want to redefine only one of those methods; -
all:
to specify which condition all elements of produced collection should satisfy to coerce.
Example of the latter:
Hash.include IdempotentEnumerable
Hash.idempotent_enumerable.constructor = :[]
# only convert back to hash if `map` have returned array of pairs
Hash.idempotent_enumerable.redefine_map! all: ->(e) { e.is_a?(Array) && e.count == 2 }
{a: 1, b: 2}.map(&:join)
# => ["a1", "b2"] -- no coercion
{a: 1, b: 2}.map { |k, v| [k.to_s, v.to_s] }
# => {"a"=>"1", "b"=>"2"} -- coercion
Performance penalty
...is, of course, present, yet not that awful (depends on your standards).
require 'benchmark/ips'
set1 = Set.new((1..100))
class SetI < Set
include IdempotentEnumerable
end
set2 = SetI.new((1..100))
Benchmark.ips do |x|
x.report('Enumerable') { set1.reject(&:odd?) }
x.report('IdempotentEnumerable') { set2.reject(&:odd?) }
x.compare!
end
Output:
Warming up --------------------------------------
Enumerable 10.681k i/100ms
IdempotentEnumerable 4.035k i/100ms
Calculating -------------------------------------
Enumerable 112.134k (± 3.5%) i/s - 566.093k in 5.055148s
IdempotentEnumerable 42.197k (± 4.1%) i/s - 213.855k in 5.078339s
Comparison:
Enumerable: 112134.2 i/s
IdempotentEnumerable: 42196.6 i/s - 2.66x slower
Author
License
MIT