Last active
August 28, 2020 17:36
-
-
Save parsonsmatt/f64393b9349592b6f1c1 to your computer and use it in GitHub Desktop.
class composition in ruby
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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