Dry Behaviour aka Protocol Pattern in Ruby

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:

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:

defprotocolNoisydo@doc"Produces a sound for the animal given"defsound(animal)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.

moduleProtocols::ArithmeticsincludeDry::Protocoldefprotocoldodefmethod:add,:this,:otherdefmethod:subtract,:this,:otherdefmethod:to_s,:thisdefmultiply(this,other)raise"We can multiply by integers only"unlessother.is_a?(Integer)(1...other).inject(this){|memo,|memo+this}endenddefimplProtocols::Arithmetics,target:Stringdodefadd(this,other)this+otherenddefsubtract(this,other)this.gsub/#{other}/,''enddefto_sthisendenddefimpltarget:[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)).toeq(8)expect(Protocols::Adder.add(5.5,3)).toeq(8.5)expect(Protocols::Adder.subtract(5,10)).toeq(-5)expect(Protocols::Adder.multiply(5,3)).toeq(15)expectdoProtocols::Adder.multiply(5,3.5)end.toraise_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.

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.

defdefimpl(protocol=nil,target:nil,delegate:[],map:{})raiseiftarget.nil?||!block_given?&&delegate.empty?&&map.empty?# builds the simple map out of both delegates and mapmds=normalize_map_delegates(delegate,map)Module.newdomds.each(&DELEGATE_METHOD.curry[singleton_class])# delegation implsingleton_class.class_eval(&Proc.new)ifblock_given?# block takes precedenceend.tapdo|mod|mod.methods(false).tapdo|meths|ms=BlackTie.protocols[protocol||self].keys-methsms.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<<mendend.eachdo|m|[*target].eachdo|tgt|BlackTie.implementations[protocol||self][tgt][m]=mod.method(m).to_procendendendendmodule_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.