0

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

3
  • 1
    I assume @name = sex.to_sym should be @name = name.to_sym? Also when including a Module you can call super because the Module is injected into the class Hierarchy. so something as simple as module A; def initialize(age:,name:,**); @age = age; @name = name; end; end would work with class B; include A; def initialize(age:,name:,height:); super; @height = height; end; end Commented Aug 31, 2021 at 17:38
  • 2
    the call to super works perfectly fine the ** at the end will collect and ignore all other "kwargs". That being said we can modify the class B definition to be class B; include A; def initialize(height:,**rest); super(**rest); @height = height; end; end problem solved. Commented Aug 31, 2021 at 20:02
  • @engineersmnky Thanks for the **rest insight. I tried following that path but it took me a problem I specified in another question. If you're interested: stackoverflow.com/q/69824456/1290457 Commented Nov 3, 2021 at 12:04

2 Answers 2

2

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.

Sign up to request clarification or add additional context in comments.

3 Comments

super appears to serve no purpose in the second example and @being seems superfluous. @being has no reader and its methods are all exposed through delegation, so in this case I would likely prefer __setobj__(Being.new(**args)).
@engineersmnky Yes, that super doesn't technically do anything. However, its a good practice to always throw one in regardless rather than getting bitten if inheritance changes. docs.rubocop.org/rubocop/1.20/cops_lint.html#lintmissingsuper You're probably right about using setobj, feel free to edit. I'm on a phone.
@Schwern Thanks for the **rest insight. I tried following that path but it took me a problem I specified in another question. If you're interested in looking it any further: stackoverflow.com/q/69824456/1290457
1

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.

4 Comments

This is very clever, but unnecessary. super works fine with modules.
Calling init to initialize objects will redefine the initialize method each time it is called?
@engineersmnky Yes, I realized that lateron, but had no time to change it. I updated the answer.
@nwnoll could @@classes_that_redefined_initialize be a class instance variable @classes_that_redefined_initialize instead?

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.