Skip to content

Instantly share code, notes, and snippets.

@parsonsmatt
Last active August 28, 2020 17:36
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save parsonsmatt/f64393b9349592b6f1c1 to your computer and use it in GitHub Desktop.
Save parsonsmatt/f64393b9349592b6f1c1 to your computer and use it in GitHub Desktop.
class composition in ruby
# In Haskell, you write a function like this:
# f :: a -> b
# which reads as "f is a function from type a to b"
#
# Function composition allows you to chain functions together,
# building more powerful and complex composite functions from
# simpler component functions.
#
# OOP does not have a similar mechanism. Composition in OOP seems
# to mostly be limited to aggregation and delegation, neither of
# which have the nice composable property of FP's functions.
# I have an inkling of an idea that might get OOP a bit of the
# way there, though.
# Class F takes an object that has the interface `a` and itself
# has interface `b`. We can think of the "signature" of the method
# F.new as a transformation between an object of type a to an
# object of type b.
# F :: a -> b
class F
attr_reader :b
def initialize(x)
@b = x.a
end
end
# class G takes an object with *any* interface and gives access
# to it with the interface "a"
# G : _ -> a
class G
attr_reader :a
def initialize(x)
@a = x
end
end
# We can compose the creation of objects now:
F.new(G.new(5))
# F :: a -> b
# G :: _ -> a
# Something that is really nice is an object whose initialize
# method takes an object of the same interface that it implements
# itself. Consider H:
# H :: a -> a
class H
attr_reader :a
def initialize(x)
@a = x.a
end
end
A = Struct.new(:a)
a = A.new(5)
H.new(H.new(H.new(H.new(a))))
# Now, that's kind of a ridiculous example. However, when you have
# this sort of structure, you can actually simplify the above:
[H, H, H, H].reduce(a) do |previous, next_item|
next_item.new(previous)
end
# In Haskell, composition looks like this:
# (f . g) x = f (g x)
# Can we get that (.) operator? What is it's type signature?
# (.) :: (b -> c) -> (a -> b) -> (a -> c)
# Compose1 :: ... how do we write that with two params?
class Compose1
def initialize(f, g)
@f = f
@g = g
end
def new(x)
@f.new(@g.new(x))
end
end
FG = Compose1.new(F, G)
f_g = FG.new(5)
# G :: _ -> a
# F :: a -> b
# f_g :: _ -> b
f_g.b
# => 5
# So this feels a little dirty -- I defined a method new on the
# instance method of a class. But this does allow us to pass an
# instance of compose to compose.
# H :: a -> a
# G :: _ -> a
# (H . G) :: (_ -> a) -> (a -> a) -> (_ -> a)
HG = Compose1.new(H, G)
# (F . HG) :: (_ -> a) -> (a -> b) -> (_ -> b)
FHG = Compose1.new(F, HG)
# or,
FHG = Compose1.new(F, Compose1.new(H, G))
f_h_g = FHG.new(5)
f_h_g.b
# => 5
# Describing classes like:
# ClassName :: (Interface of param) -> (Interface of object)
# is nice. We can very easily import ideas from Haskell and
# functional programming if we can describe classes like this.
# Compose1 doesn't fit this. It takes two parameters. Can we rewrite
# it to follow that pattern? we can! We'll express an interface
# with multiple methods in braces.
# Compose2 :: [f, g] -> [f, g, new]
class Compose2
attr_reader :f, :g
def initialize(x)
@f = x.f
@g = x.g
end
def new(x)
f.new(g.new(x))
end
end
Pair = Struct.new(:f, :g)
pair = Pair.new(F, G)
Compose2.new(pair).new(5).b
# => 5
# Ah! Compose2 expects its parameter to have the interface "f and g"
# and itself has interface "f and g". It's now possible to chain
# the new methods:
Compose2.new(Compose2.new(pair))
# ... except there's no way to actually introduce a new function,
# since we can only pass one thing to `Compose2`. Haskell does
# function currying, which means that all functions really only
# take one argument.
# Ruby's also capable of making functions look like they take
# multiple arguments, but only take one: splat args! Can we
# compose an arbitrary number of classes with this? Perhaps...
class Compose3
def initialize(*fs)
@fs = fs
end
def new(x)
@fs.reduce(x) { |a, e| e.new(a) }
end
end
Compose3.new(G, F).new(5).b
# => 5
# I actually really like this. We can compose an arbitrary amount
# of classes, as long as their "signatures" match up.
# Furthermore, it reads left-to-right, like English. Normal function
# composition reads right-to-left, which can throw people off.
# Can we pass Compose3 to itself? Let's make a class b -> c and
# find out!
# C :: b -> c
class C
attr_reader :c
def initialize(x)
@c = x.b
end
end
Compose3.new(Compose3.new(G, H, F), C).new(5).c
# => 5
# Neat! But typing that all out gets pretty old... And mostly,
# really, we just want to compose two things at a time. Let's
# make operators.
class Class
def *(other)
Compose3.new(other, self)
end
def |(other)
Compose3.new(self, other)
end
end
# * is class composition like you might expect from Haskell.
(F * G).new(5).b == F.new(G.new(5)).b
# => true
# | is pipe operator, like you know from *nix shell scripting.
(G | F).new(5).b == F.new(G.new(5)).b
# => true
# Unfortunately, we can't chain these. So, we can't do:
# F * H * G
# because F * H is a Compose3, and * isn't defined on Compose3.
# So let's correct that:
class Compose3
def *(other)
Compose3.new(other, self)
end
def |(other)
Compose3.new(self, other)
end
end
F * H * G
# => #<Compose3 ... @fs=[G, #<Compose3... @fs=[H, F]>]>
(F * H * G).new(5).b
# => 5
(G | H | F).new(5).b
# => 5
# So, now we can compose classes about as easily as Haskell
# can compose functions. We don't have the type safety of Haskell,
# but perhaps we can even implement that.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment