No commit activity in last 3 years
No release in over 3 years
IdempotentEnumerable is like Enumerable, but tries to preserve original collection type when possible.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

 Project Readme

IdempotentEnumerable

Gem Version Build Status Coverage Status

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 or Hash);
  • 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 like doc.search('a').reject { |a| a.text.include?('Google') }, you'll receive regular Array 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

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}>]

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

Victor Shepelev

License

MIT