Setting the Stage
Duck typing is commonly used by Rubyists and other users of dynamic languages. We'll demonstrate duck typing by helping a concert lighting team set up the lighting for a band, which comes from my RubyConf 2020 talk about Ruby's Coverage module.
Stage Design
To light the stage for our concert, we have a wide range of lights to use.
We have our trusty can, or PAR, lights.
class CanLight
def trigger(color:, effect:)
...
end
end
We have spotlights tasked on each band member.
class Spotlight
def trigger(color:, effect:)
...
end
end
We have fancy moving lights for versatile coverage across the stage.
class MovingLight
def trigger(color:, effect:)
...
end
end
We even have a beam projector for a more powerful spotlight effect.
class BeamProjector
def trigger(color:, effect:)
...
end
end
What any of these lights do isn't important here. What is key to notice is that you operate them all by calling the trigger
method.
A Light Touch
The stage lighting technicians, just like the band, are performers in the concert. For every single note of every single song, they need to make sure that the visual aesthetic of the stage is set just so.
All of these lights are managed by a central controller, from which they can power on all the lights in preparation for a show.
class LightingController
def initialize
@powered_lights = {}
end
def turn_on_lights
@powered_lights[:beam_projector] = BeamProjector.new
@powered_lights[:can] = CanLight.new
@powered_lights[:moving_light] = MovingLight.new
@powered_lights[:spotlight] = Spotlight.new
end
end
As I mentioned, for every note of every song, they need to make sure the lights look exactly as they're supposed to. This is tracked as the lighting's composition.
class LightingComposition
attr_reader :light_name
attr_reader :color
attr_reader :effect
end
Ducking into Lights on Stage
As the band is playing the show, the lighting technicians follow note-for-note and need to apply the composition.
class Song
def play
@notes.map do |note|
composition = []
composition << Thread.new { @guitar.play(note) }
composition << Thread.new { @vocal.sing(note) }
composition << Thread.new { @drum.hit(note) }
composition << Thread.new { @keyboardist.program(note) }
composition << Thread.new { @lighting.set_lighting(note) }
composition.map(&:value)
end
end
end
Because each of our different lights respond to the same message (trigger
) with the same signature, the lighting controller doesn't need to care, or even know, about which light it's operating. All it knows is that it needs to send
it the trigger signal and apply the required composition.
class LightingController
def set_lighting(note)
lighting_composition = note.lighting
trigger(@powered_lights[lighting_composition.light_name], lighting_composition)
end
def trigger(light, composition)
light.trigger(
color: composition.color,
effect: composition.effect,
)
end
end
The LightingController
's trigger
method is taking advantage of duck typing. Ruby doesn't care what kind of object it's calling in its light
argument. All that matters is that it responds to trigger
. We also used duck typing when we discussed dependency injection. Our guitar didn't care how the amplifier made sound, or even if it did make sound. All that
matters at runtime to satisfy Ruby is that the object we pass in responds to play
and accepts an argument.
Static Lighting
If you're more familiar with static languages or different typing systems, and you need to define common behavior for what a set of classes do, you may be familiar with an interface. For example, let's use Java to define an interface
for our lights.
interface Light {
void trigger(Color color, LightingEffect effect)
}
Each of our lights would then implement this interface, defining their own implementation of what they do when the light is triggered.
class Spotlight implements Light {
@Override
public void trigger(Color color, LightingEffect effect) {
// Turn the light on or off
}
}
We can now set our LightingController
's trigger
method to accept any kind of light.
class LightingController {
public void trigger(Light light, LightingComposition composition) {
light.trigger(composition.color, composition.effect);
}
}
This satisfies Java's type system, because anything that implements the Light
interface is required to respond to the trigger
method accepting those types of arguments.
Because of duck typing in Ruby, defining this contract and enforcing it is unnecessary. However, Ruby 3 will be shipping with a way to define type
signatures, called RBS. RBS includes a mechanism to define interfaces, which you can read more about here.
Rock On
Duck typing is a core design feature of Ruby and other dynamic languages; however, it does require a degree of trust. Because everything is determined at runtime, there's nothing stopping you from passing in an object to a method that doesn't respond to the methods it needs to. That will generate an exception, but that may be too late to get that feedback. However, to many Rubyists, the flexibility this approach provides often outweighs the cost.
If you're coming from a static typing system, or desiring more direction or enforcement about what's expected to be provided as an argument, then investigate defining interface types with RBS, which will be part of Ruby 3.
Our concert series continues with a discussion on composition and delegation.
This post originally published on The Gnar Company blog.
Learn more about how The Gnar builds Ruby on Rails applications.