Elixir introduced the concept of behaviours. The quote from the official docs:
Protocols are a mechanism to achieve polymorphism in Elixir. Dispatching on a protocol is available to any data type as long as it implements the protocol.
What is it all about? Well, Elixir entities, aka “terms,” are all immutable.
While in ruby we tend to declare methods on objects, that simply mutate the
objects, in Elixir it is impossible. Everybody had seen the Animal
example
explaining the polymorphism in a nutshell for any of so-called OO languages:
class Animal
def sound
raise "I am an abstract animal, I keep silence (and mystery.)"
end
end
class Dog < Animal
def sound
puts "[LOG] I’m a dog, I bark"
"woof"
end
end
class Cat < Animal
def sound
puts "[LOG] I’m a cat, I am meowing"
"meow"
end
end
Now we are safe to call sound
method on any animal, without bothering
to determine what exact type of animal we are facing. In Elixir, on the other
hand, we do not have “methods defined on objects.” The approach to achieve
more or less same functionality (the most typical example of where it’s really
handy is, for instance, the string interpolation,) would be to declare
the protocol.
Sidenote: another approach would be to use behaviours, but for the sake of our task we would stick to protocols in this post.
The protocol is a pure interface, declared with defprotocol
keyword.
For the animalistic example above it would be:
defprotocol Noisy do
@doc "Produces a sound for the animal given"
def sound(animal)
end
The implementation goes into defimpl
clause:
defimpl Noisy, for: Dog do
def sound(animal), do: "woof"
end
defimpl Noisy, for: Cat do
def sound(animal), do: "meow"
end
Now we can use the protocol, without actual care who the animal we have:
ExtrernalSource.animal
|> Noisy.sound
OK. Why would we want to have this pattern in ruby? We indeed already have
polymorphism, right? Yes. And no. The most evident example would be classes,
coming from different external third-party sources, but still having
something in common. The rails approach, widely spread into ruby world by DHH,
would be to monkeypatch everything. The irony here is that I personally love
monkeypatching. Yet in some cases I find the protocol
approach being more
robust. That way, instead of re-opening Integer
class for declaring date-aware
methods, one might declare the protocol, having to_days
method.
It in turn might be used as DateGuru.to_days(something)
instead of
something.to_days
. That way all the code, responsible for the date
conversions/operations, would be placed together, providing sorta guarantee
that there are no conflicts, no accidental unintended monkeypatches etc.
I am not advocating this approach is better; it is just different.
To try it, we would need to provide some DSL to make it easy to declare protocols in pure ruby. Let’s do it. We are to start with tests.
module Protocols::Arithmetics
include Dry::Protocol
defprotocol do
defmethod :add, :this, :other
defmethod :subtract, :this, :other
defmethod :to_s, :this
def multiply(this, other)
raise "We can multiply by integers only" unless other.is_a?(Integer)
(1...other).inject(this) { |memo,| memo + this }
end
end
defimpl Protocols::Arithmetics, target: String do
def add(this, other)
this + other
end
def subtract(this, other)
this.gsub /#{other}/, ''
end
def to_s
this
end
end
defimpl target: [Integer, Float], delegate: :to_s, map: { add: :+, subtract: :- }
end
Let’s dig a bit into the code above. We have declared the protocol Arithmetics
,
responsible for adding and subtracting values. Once two operations above
are implemented for instances of some class, we have multiply
method for granted.
The usage of this protocol would be Arithmetics.add(42, 3) #⇒ 45
.
Our DSL support method delegation, mapping and explicit declaration.
This contrived example does not make much sense as is, but it provides a good test case for our DSL. Let’s write tests.
expect(Protocols::Adder.add(5, 3)).to eq(8)
expect(Protocols::Adder.add(5.5, 3)).to eq(8.5)
expect(Protocols::Adder.subtract(5, 10)).to eq(-5)
expect(Protocols::Adder.multiply(5, 3)).to eq(15)
expect do
Protocols::Adder.multiply(5, 3.5)
end.to raise_error(RuntimeException, "We can multiply by integers only")
Yay, it’s time to finally implement this DSL. This is easy.
The whole implementation fits one single module. We would call it BlackTie
,
since it’s all about protocols. In the first place tt will hold the maps of
declared protocols to their implementations.
module BlackTie
class << self
def protocols
@protocols ||= Hash.new { |h, k| h[k] = h.dup.clear }
end
def implementations
@implementations ||= Hash.new { |h, k| h[k] = h.dup.clear }
end
end
Sidenote: the trick with default_proc
in hash declarations
(Hash.new { |h, k| h[k] = h.dup.clear }
) produces the hash that has
a deep default_proc
, returning an empty hash.
defmethod
is the most trivial method here, it simply stores the
declaration under respective name in the global @protocols
hash:
def defmethod(name, *params)
BlackTie.protocols[self][name] = params
end
Declaration of the protocol
is a bit more cumbersome (some details are
omitted here for the sake of clarity, see
the full code here.)
def defprotocol
raise if BlackTie.protocols.key?(self) || !block_given?
ims = instance_methods(false)
class_eval(&Proc.new)
(instance_methods(false) - ims).each { |m| class_eval { module_function m } }
singleton_class.send :define_method, :method_missing do |method, *args|
raise Dry::Protocol::NotImplemented.new(:method, self.inspect, method)
end
BlackTie.protocols[self].each do |method, *|
singleton_class.send :define_method, method do |receiver = nil, *args|
impl = receiver.class.ancestors.lazy.map do |c|
BlackTie.implementations[self].fetch(c, nil)
end.reject(&:nil?).first
raise Dry::Protocol::NotImplemented.new(:protocol, self.inspect, receiver.class) unless impl
impl[method].(*args.unshift(receiver))
end
end
end
Basically, the code above has four block. First of all, we check the conditions
the protocol must meet. Then we execute a block given, recording what methods
were added by this block, and exposing them with module_function
.
In the third block we declare the generic method_missing
to provide
meaningful error messages on erroneous calls. And, lastly, we declare methods,
either delegating them to respective implementation (when exists,) or throwing
the descriptive exception is there is no implementation for this particular
receiver.
OK, the only thing left is to declare defimpl
DSL. The code below is a bit
simplified.
def defimpl(protocol = nil, target: nil, delegate: [], map: {})
raise if target.nil? || !block_given? && delegate.empty? && map.empty?
# builds the simple map out of both delegates and map
mds = normalize_map_delegates(delegate, map)
Module.new do
mds.each(&DELEGATE_METHOD.curry[singleton_class]) # delegation impl
singleton_class.class_eval(&Proc.new) if block_given? # block takes precedence
end.tap do |mod|
mod.methods(false).tap do |meths|
(BlackTie.protocols[protocol || self].keys - meths).each_with_object(meths) do |m, acc|
logger.warn("Implicit delegate #{(protocol || self).inspect}##{m} to #{target}")
DELEGATE_METHOD.(mod.singleton_class, [m] * 2)
acc << m
end
end.each do |m|
[*target].each do |tgt|
BlackTie.implementations[protocol || self][tgt][m] = mod.method(m).to_proc
end
end
end
end
module_function :defimpl
Despite the amount of LOCs, the code above is fairly simple: we create an
anonymous module, declare methods on it and supply it as the target of
method delegation from the main protocol class methods. Once we have called
Arithmetics.add(5, 3)
, the receiver (5
) would be used to lookup the
respective implementation (defimpl Arithmetics, target: Integer
) and
it’s method :+
(because of defimpl target: [Integer, ...], ..., map: { add: :+, ... }
,
add
is mapped to :+
) would be called. That’s it.
Whether you still think, this is a redundant of-no-practival-use garbage,
imagine the Tax
protocol. That might be implemented for: ItemToSell
,
Shipment
, Employee
, Lunch
etc.
❖ dry-behaviour
repo. Enjoy!