Nowhere Fast
Nowhere Fast

Reputation: 109

Maintaining same class using delegation in Ruby

I'm trying to wrap my head around delegation vs. inheritance so I'm manually delegating a version of Array. One of the specific reasons I read to do this is because when you use things like enumerables, your returned value on the inherited methods reverts back to the parent class (i.e. Array). So I did this:

module PeepData
  # A list of Peeps
  class Peeps
    include Enumerable

    def initialize(list = [])
      @list = list
    end

    def [](index)
      @list[index]
    end

    def each(...)
      @list.each(...)
    end

    def reverse
      Peeps.new(@list.reverse)
    end

    def last
      @list.last
    end

    def join(...)
      @list.join(...)
    end

    def from_csv(csv_table)
      @list = []
      csv_table.each { |row| @list << Peep.new(row.to_h) }
    end

    def include(field, value)
      Peeps.new(select { |row| row[field] == value })
    end

    def exclude(field, value)
      Peeps.new(select { |row| row[field] != value })
    end

    def count_by_field(field)
      result = {}
      @list.each do |row|
        result[row[field]] = result[row[field]].to_i + 1
      end
      result
    end

    protected

    attr_reader :list
  end
end

When I instantiate this, my include and exclude function great and return a Peeps class but when using an enumerable like select, it returns Array, which prevents me from chaining further Peeps specific methods after the select. This is exactly what I'm trying to avoid with learning about delegation.

p = Peeps.new
p.from_csv(csv_generated_array_of_hashes)
p.select(&:certified?).class

returns Array

If I override select, wrapping it in Peeps.new(), I get a "SystemStackError: stack level too deep". It seems to be recursively burying the list deeper into the list during the select enumeration.

def select(...)
  Peeps.new(@list.select(...))
end

Any help and THANKS!

Upvotes: 3

Views: 136

Answers (3)

nPn
nPn

Reputation: 16768

I would recommend using both Forwardable and Enumerable. Use Forwardable to delegate the each method to your list (to satisfy the Enumerable interface requirement), and also forward any Array methods you might want to include that are not part of the Enumerable module, such as size. I would also suggest not overriding the behavior of select as it is supposed to return an array and would at the very least lead to confusion. I would suggest something like the subset provided below to implement the behavior you are looking for.

require 'forwardable'

class Peeps
  include Enumerable
  extend Forwardable

  def_delegators :@list, :each, :size

  def initialize(list = [])
    @list = list
  end

  def subset(&block)
    selected = @list.select(&block)
    Peeps.new(selected)
  end
  
  protected
  attr_reader :list

end

Example usage:

peeps = Peeps.new([:a,:b,:c])
subset = peeps.subset {|s| s != :b}
puts subset.class 
peeps.each do |peep|
   puts peep
end
puts peeps.size
puts subset.size

produces:

Peeps
a
b
c
3
2

Upvotes: 3

Tony Arra
Tony Arra

Reputation: 11119

As mentioned in the other answer, this isn't really proper usage of Enumerable. That said, you could still include Enumerable and use some meta-programming to override the methods that you want to be peep-chainable:

module PeepData
  class Peeps
    include Enumerable
 
    PEEP_CHAINABLES = [:map, :select]

    PEEP_CHAINABLES.each do |method_name|
      define_method(method_name) do |&block|
        self.class.new(super(&block))
      end
    end

    # solution for select without meta-programming looks like this:
    # def select
    #   Peeps.new(super)
    # end
  end
end

Just so you know, this really has nothing to do with inheritance vs delegation. If Peeps extended Array, you would have the exact same issue, and the exact solution above would still work.

Upvotes: 3

Jared Beck
Jared Beck

Reputation: 17538

I think that if Peeps#select will return an Array, then it is OK to include Enumerable. But, you want Peeps#select to return a Peeps. I don't think you should include Enumerable. It's misleading to claim to be an Enumerable if you don't conform to its interface. This is just my opinion. There is no clear consensus on this in the ecosystem. See "Examples from the ecosystem" below.

If we accept that we cannot include Enumerable, here's the first implementation that comes to my mind.

require 'minitest/autorun'

class Peeps
  ARRAY_METHODS = %i[flat_map map reject select]
  ELEMENT_METHODS = %i[first include? last]

  def initialize(list)
    @list = list
  end

  def inspect
    @list.join(', ')
  end

  def method_missing(mth, *args, &block)
    if ARRAY_METHODS.include?(mth)
      self.class.new(@list.send(mth, *args, &block))
    elsif ELEMENT_METHODS.include?(mth)
      @list.send(mth, *args, &block)
    else
      super
    end
  end
end

class PeepsTest < Minitest::Test
  def test_first
    assert_equal('alice', Peeps.new(%w[alice bob charlie]).first)
  end

  def test_include?
    assert Peeps.new(%w[alice bob charlie]).include?('bob')
  end

  def test_select
    peeps = Peeps.new(%w[alice bob charlie]).select { |i| i < 'c' }
    assert_instance_of(Peeps, peeps)
    assert_equal('alice, bob', peeps.inspect)
  end
end

I don't normally use method_missing, but it seemed convenient.

Examples from the ecosystem

There doesn't seem to be a consensus on how strictly to follow interfaces.

  • ActionController::Parameters used to inherit Hash. Inheritance ceased in Rails 5.1.
  • ActiveSupport::HashWithIndifferentAccess still inherits Hash.

Upvotes: 3

Related Questions