There is! Instead of using inheritance, you can solve this problem with composition - aka “mixins”.
We want to extract the common options into a module, and include
that module into each command.
If you want to see how this would work, just skip to the section Solution
. If you’d like a brief journey on how you might have figured this out yourself, read on
Attempt #1: Just Put The Code In a Module
If we try this naively, it won’t work:
module CommonOptions
option :host, type: :string, default: 'localhost', desc: 'set the host'
end
class A < Dry::CLI::Command
...
include CommonOptions
end
# .my-cli a --host "8.8.8.8"
# 🔥 undefined method `option' for CommonOptions:Module (NoMethodError)
option
is actually a method call, and the module gets evaluated outside a Dry::CLI::Command
. so it has no idea what that method should be! We want the module code to be evaluated only when inside the Command object.
Attempt #2: eval
?
Our instincts tell us that this might require some kind of eval
, and our instincts would be right. So we try this:
module CommonOptions
self.instance_eval do
option :host, type: :string, default: 'localhost', desc: 'set the host'
end
end
# ...
# 🔥 undefined method `option' for CommonOptions:Module (NoMethodError)
but this doesn’t help. (In fact, conceptually, this is almost exactly the same as Attempt 1! Why is that? ) Calling instance_eval
on ourself doesn’t work - it’s too early! We’re still operating within the context of the module, and the module still doesn’t know what option
means. We want this to be executed later, in the context of the including Command. But how would we know which Command
that is?
Solution: Module.included
Modules in Ruby can define callback methods, which are called whenever certain events happen. One of those is included
, which is called whenever (you guessed it) its module gets include
d by something - exactly what we want!
If we define this method for our Options module, we can evaluate that call to option
inside the Command, where it’s defined:
module CommonOptions
# Called when CommonOptions is included somewhere.
# mod is the object (a Class, or Module) doing the
# including
def self.included(mod)
mod.instance_eval do
option :host, type: :string, default: 'localhost', desc: 'set the host'
end
end
end
And this works!
class A < Dry::CLI::Command
include CommonOptions
def call(host:)
puts "A: #{host}"
end
end
class B < Dry::CLI::Command
include CommonOptions
def call(host:)
puts "B: #{host}"
end
end
# my-cli b --host "8.8.8.8"
# ✅ => "B: 8.8.8.8"
# my-cli a
# ✅ => "A: localhost"
PS: Side notes
Some things to ponder: (perhaps! I don’t know what you already know - this is not meant to be at all condescending!)
- What’s the difference between just putting the code in a module (Attempt 1) and wrapping the code in
self.instance_eval
(Attempt 2)? What does this tell you about what instance_eval
means?
- We noticed that each
option
line is actually a method call. But on what object? And if it’s a method, how did it get defined?