sandre89
sandre89

Reputation: 5908

Ruby: partially share initialization logic using Module

I want to share some initialization logic between two classes that DO NOT inherit from one another (so I can't invoke super inside initialize).

For example, notice that both Person and Dog class share the age and name kwargs and initialization logic:

class Person

  def initialize(age: , name: , height: )
    @age = age.to_i
    @name = name.to_sym
    @height = height
  end

end

class Dog

  def initialize(age: , name: , breed: )
    @age = age.to_i
    @name = name.to_sym
    @breed = breed
  end

end

To keep code DRY, I don't want to repeat this in both classes; instead I'd like to move that shared logic to a Module and include it on both classes.

However, I don't want to change the initialization params to a options = {} (hash), so I'd like to still use keyword arguments for the initialize method on both classes. In a way, we would need to merge the shared kwargs with the class specific ones on def initialize.

How one could share this initialization logic (keyword arguments and initialize method) between two different classes?

UPDATE

One way to achieve half of the goal (sharing the initialization logic) could be by using binding:

module AgeAndNameInitializationConcern

  def self.included(base)

    base.class_eval do

      attr_reader :name, :age

    end

  end

  def initialize_age_and_name(binding)

    code_string = <<~EOT

      @age = age.to_i
      @name = name.to_sym

    EOT

    eval(code_string, binding)

  end

end

class Person

  include AgeAndNameInitializationConcern

  def initialize(age: , name: , height: )
    initialize_age_and_name(binding)
    @height = height
  end

end

class Dog

  include AgeAndNameInitializationConcern

  def initialize(age: , name: , breed: )
    initialize_age_and_name(binding)
    @breed = breed
  end

end

Upvotes: 0

Views: 370

Answers (2)

Schwern
Schwern

Reputation: 165198

super works just fine with modules. Use ** to ignore additional keyword parameters.

module Being
  def initialize(age: , name: , **)
    @age = age.to_i
    @name = name.to_sym
  end  
end

class Person
  include Being
  
  def initialize(height:, **)
    super
    
    @height = height
  end
end

class Dog
  include Being

  def initialize(breed: , **)
    super
    
    @breed = breed
  end
end

#<Dog:0x00007fb0fe80f7f8 @age=6, @name=:"Good Boy", @breed="Good Dog">
#<Person:0x00007fb0fe80f2a8 @age=42, @name=:Bront, @height="6' 2\"">
p Dog.new(age: 6, name: "Good Boy", breed: "Good Dog")
p Person.new(age: 42, name: "Bront", height: %q{6' 2"})

You can get yourself into some trouble mixing super with Modules because it's not always clear which ancestor method super will call. You can check your full inheritance tree with Module#ancestors. This includes Classes because all Classes are Modules.

# [Dog, Being, Object, Kernel, BasicObject]
# [Person, Being, Object, Kernel, BasicObject]
p Dog.ancestors
p Person.ancestors

To avoid this, use composition. Compose your class of several different objects and delegate method calls to them. In this case, have a Being object and delegate method calls to it. We'll use Forwardable to forward method calls to a Being object.

require 'forwardable'

class Being
  attr_accessor :age, :name
  
  def initialize(age:, name:)
    @age = age.to_i
    @name = name.to_sym
  end  
  
  def greeting
    "Hello, my name is #{name} and I am #{age} years old."
  end
end

class Person
  extend Forwardable
  
  def_delegators :@being, :greeting

  def initialize(height:, **args)
    @being = Being.new(**args)
    @height = height
  end
  
  def to_s
    self
  end
end

class Dog
  extend Forwardable
  
  def_delegators :@being, :greeting

  def initialize(breed:, **args)
    @being = Being.new(**args)
    @breed = breed
  end
  
  def to_s
    self
  end
end

#<Dog:0x00007fb87702c060 @being=#<Being:0x00007fb87702e400 @age=6, @name=:"Good Boy">, @breed="Good Dog">
#<Person:0x00007fb87a02f870 @being=#<Being:0x00007fb87a02f7f8 @age=42, @name=:Bront>, @height="6' 2\"">
p dog = Dog.new(age: 6, name: "Good Boy", breed: "Good Dog")
p person = Person.new(age: 42, name: "Bront", height: %q{6' 2"})

# Hello, my name is Good Boy and I am 6 years old.
# Hello, my name is Bront and I am 42 years old.
puts dog.greeting
puts person.greeting

# [Dog, Being, Object, Kernel, BasicObject]
# [Person, Being, Object, Kernel, BasicObject]
p Dog.ancestors
p Person.ancestors

def_delegators :@being, :greeting says that when greeting is called, call @being.greeting instead.


Inheritance is easy, but it can lead to hard to find complications. Composition takes a bit more work, but it is more obvious what is happening, and it allows for more flexible classes. You can swap out what is being delegated to.

For example, say you need to fetch things off the web. You could inherit from Net::HTTP. Or you could delegate to a Net::HTTP object. Then in testing you can replace the Net::HTTP object with one that does dummy network calls.

Upvotes: 2

nwnoll
nwnoll

Reputation: 136

Here is my solution:

module Initializable
    @@classes_that_redefined_initialize = []
    
    def init(params)
        if @@classes_that_redefined_initialize.include?(self)
            new(params)
        else
            create_initialize
            @@classes_that_redefined_initialize.push(self)
            new(params)
        end
    end

    def create_initialize
        define_method(:initialize) do |params|
            merged_attributes = self.class.shared_attributes.merge(self.class.exclusive_attributes)
            
            ## this checks if all attributes are set
            unless (params.keys & merged_attributes.keys) == merged_attributes.keys
                raise ArgumentError, "missing keywords: #{(merged_attributes.keys - (params.keys & merged_attributes.keys)).join(' ')}"
            end

            params.each do |key, value|
                if merged_attributes.keys.include?(key)
                    param = value.respond_to?(merged_attributes[key]) ? value.public_send(merged_attributes[key]) : value
                    instance_variable_set("@#{key}", param )
                end 
            end
        end
    end

    ## Hash with keys as attributes that should be shared
    ## between classes and values as conversion methods
    def shared_attributes
        { age: "to_i", name: "to_sym" }
    end 
end


class Person
    extend Initializable

    ## attributes exclusive for Class 
    def self.exclusive_attributes
        { height: "" }
    end
end

class Dog
    extend Initializable

    def self.exclusive_attributes
        { breed: "" }
    end
end

p Person.init(age: 25, name: "Nik", bla: "Bla", height: "2")
p Dog.init(age: 3, name: "Lassy", foo: "bar", breed: "whatever", height: "1")
p Dog.init(age: 1, name: "Spencer", foo: "bar", breed: "whatever", height: "2")

Output:

#<Person:0x000055d9dc2e9030 @age=25, @name=:Nik, @height="2">
#<Dog:0x000055d9dc2e2ca8 @age=3, @name=:Lassy, @breed="whatever">
#<Dog:0x000055d9dc2e21e0 @age=1, @name=:Spencer, @breed="whatever">

The module Initializable has all the attributes that should be shared across the classes inside of shared_attributes. There are also the functions init and create_initialize. create_initialize generates the initialize function with the shared and exclusive attributes. The init function can be used for direct object instantiation like new.

UPDATE: I added the class @@classes_that_redefined_initialize to the Initialize module after the comment from @engineersmnky. This checks now if the initialize method has been redefined.

Upvotes: 1

Related Questions