Reputation: 5908
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
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
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