Object-oriented languages are good at adding new data; functional languages are good at adding new behavior. Can we find a happy medium?
The answer: Sorta.
First I’ll explain what I’m talking about, with examples. At the bottom of this article I’ll talk about the lambda calculus. Oh and before that I’ll talk about something relevant to normal Ruby programming, in case that’s something you’re still into.
The problem
But first let’s start even simpler: we are going to represent addition in Ruby.
class Literal
def initialize(n)
@n = n
end
def evaluate
@n
end
end
class Addition
def initialize(a, b)
@a = a
@b = b
end
def evaluate
@a.evaluate + @b.evaluate
end
end
No magic going on here. We can evaluate an expression:
ruby-1.9.2-p290> Addition.new(Literal.new(2), Addition.new(Literal.new(3), Literal.new(5))).evaluate
=> 10
So the claim is that it’s easy to add new data. What that means is that we can add a new class and use it quickly:
class Boolean
def initialize(bool)
@bool = bool
end
def evaluate
@bool
end
end
class IfThenElse
def initialize(b, t, f)
@b = b
@t = t
@f = f
end
def evaluate
if @b.evaluate
@t.evaluate
else
@f.evaluate
end
end
end
Above we have added Booleans and conditionals to our language. We haven’t added the concept of “less than” or “equal to zero” or anything like that, so for now we can only hard-code truth and lies.
ruby-1.9.2-p290> IfThenElse.new(
Boolean.new(true),
Addition.new(Literal.new(5),
Addition.new(Literal.new(3), Literal.new(2))),
Literal.new(0)).evaluate
=> 10
Further, the claim is that it’s hard to add new behavior. As an example of what
that means, consider adding to_s
for our little arithmetic language. Here are
some ways to do that:
- Open each class and add a
to_s
method. This can be done either by modifying the file directly (very possible if it’s in our codebase), or by using Ruby’s open classes. Ruby’s open classes are brittle when dealing with private data, so we should consider avoiding that. - Define subclasses, either via composition or inheritance, that add the desired
debugging behavior. This would mean re-writing every use of
IfThenElse.new
withDebuggingIfThenElse.new
, as needed.
Both of those options are OK, and will absolutely work perfectly in some situations. Let’s explore another option: abstract factories.
A solution
The abstract factory pattern is described like this:
Provide an interface for creating families of related or dependent objects without specifying their concrete classes.
We can define (and name!) our system as an abstract factory:
class AdditionAndNumbers
def literal(n)
Literal.new(n)
end
def addition(a, b)
Addition.new(a, b)
end
end
And we can make use of this as before:
ruby-1.9.2-p290> system = AdditionAndNumbers.new
ruby-1.9.2-p290> system.addition(system.literal(1), system.literal(3)).evaluate
=> 10
We can add new data types to our system:
class BooleansWithAdditionAndNumbers < AdditionAndNumbers
def boolean(b)
Boolean.new(b)
end
def if_then_else(b, t, f)
IfThenElse.new(b, t, f)
end
end
And make use of those, too:
ruby-1.9.2-p290> system = BooleansWithAdditionAndNumbers.new
ruby-1.9.2-p290> system.if_then_else(
system.boolean(true),
system.addition(
system.literal(5),
system.addition(
system.literal(3),
system.literal(2))),
system.literal(0)).evaluate
=> 10
And we can add our desired stringification. Here’s where the “sorta” in my introduction shines:
class ShowLiteral
def initialize(n)
@n = n
end
def to_s
@n.to_s
end
end
class ShowAddition
def initialize(a, b)
@a = a
@b = b
end
def to_s
"#{@a.to_s} + #{@b.to_s}"
end
end
class ShowAdditionAndNumbers
def literal(n)
ShowLiteral.new(n)
end
def addition(a, b)
ShowAddition.new(a, b)
end
end
Here I’ve defined a new abstract factory that produces objects that respond to
to_s
usefully. The code that uses this system does not change, as it still
calls literal
and addition
as before, except now it calls to_s
instead of
evaluate
.
ruby-1.9.2-p290> system = ShowAdditionAndNumbers.new
ruby-1.9.2-p290> system.addition(system.literal(1), system.literal(3)).to_s
=> "1 + 3"
In fact, it doesn’t even need to be that way: the to_s
method could instead
have been called evaluate
. However, this produces a String
instead of a
Fixnum
, so that’s weird.
The complaint
That’s a lot of small classes!
Boo hoo. Don’t use this pattern unless you need it.
Another example
Factory Bot has a bunch of strategies for factorying-up some data. Here are some massive simplifications, for example:
class Build
def initialize(class_name, attributes)
@class_name = class_name
@attributes = attributes
end
def run
@class_name.to_s.camelize.constantize.send(:new, @attributes)
end
end
class Create
def initialize(class_name, attributes)
@class_name = class_name
@attributes = attributes
end
def run
@class_name.to_s.camelize.constantize.send(:create, @attributes)
end
end
New strategies are easy to add, but new behavior is not. Again I’ll use the
example of inspecting into a String
, but this time I’ll make use of the
existing run
method instead of a new to_s
method. Again, just an example.
We start by making an abstract factory:
class Strategy
def build(class_name, attributes)
Build.new(class_name, attributes).run
end
def create(class_name, attributes)
Create.new(class_name, attributes).run
end
end
Here we’re going to go on a tangent to add an extension point to Factory Bot. This is because, given the above change, Factory Bot will have some code like:
class FactoryBot
def build(class_name, attributes = {})
strategy.build(class_name, attributes)
end
def create(class_name, attributes = {})
strategy.create(class_name, attributes)
end
def plugin(strategy_name, class_name, attributes = {})
strategy.send(strategy_name, class_name, attributes)
end
private
def strategy
Strategy.new
end
end
That private method is brutal. So it instead needs to be:
class FactoryBot
def self.strategy=(s)
@@strategy = s
end
# ...
private
def strategy
if defined?(@@strategy)
@@strategy
else
Strategy.new
end
end
end
Whew. Tangent over.
This allows us to define StubbingStrategy
:
require 'mocha'
class Stub
def initialize(class_name, attributes)
@class_name = class_name
@attributes = attributes
end
def run
class_object.new.tap do |s|
@attributes.each do |method, result|
s.stubs(method).returns(result)
end
end
end
private
def class_object
@class_name.to_s.camelize.constantize
end
end
class StubbingStrategy < Strategy
def stub(class_name, attributes)
Stub.new(class_name, attributes).run
end
end
And then use it:
FactoryBot.strategy = StubbingStrategy.new
user_stub = FactoryBot.new.plugin(:stub, :user)
Add behavior
We’re back to where we started; great. But, now we can add behavior on a whim:
class DebuggingBuild
def initialize(class_name, attributes)
@class_name = class_name
@attributes = attributes
end
def run
"#{@class_name.to_s.camelize}.new(#{@attributes.inspect})"
end
end
class DebuggingCreate
def initialize(class_name, attributes)
@class_name = class_name
@attributes = attributes
end
def run
"#{@class_name.to_s.camelize}.create(#{@attributes.inspect})"
end
end
class DebuggingStrategy
def build(class_name, attributes)
DebuggingBuild.new(class_name, attributes).run
end
def create(class_name, attributes)
DebuggingCreate.new(class_name, attributes).run
end
end
This is a strategy that causes the normal build and create strategies to produce strings instead of the normal data. We don’t need to change anything else: just set this as the strategy and use it:
ruby-1.9.2-p290> FactoryBot.strategy = DebuggingStrategy.new
ruby-1.9.2-p290> FactoryBot.new.create(:user)
=> "User.create({})"
Read more
The gory details, including the arithmetic system example, come from this year’s ECOOP in a paper titled “Extensibility for the Masses: Practical Extensibility with Object Algebras” (PDF) by Bruno C.d.S. Oliveira and William R. Cook.
I recommend reading the paper for more cool examples, such as concurrent computation of the arithmetic system and a DSL for batch processing. They also relate Church encodings to the visitor pattern, and abstract factories to F-algebras. Throughout the paper they talk about static type analysis, too.
Disclaimer:
Looking for FactoryGirl? The library was renamed in 2017. Project name history can be found here.