Download as pdf or txt
Download as pdf or txt
You are on page 1of 76

Software design and

refactoring
Cracking complexity by sending messages and building pipelines
Self introduction
Let’s start with a puzzle...
Happy company
class Employee; end

class Engineer < Employee


def do_work; end
end

class ProductAnalyst < Employee


def do_work; end
end

class Company
attr_accessor :members
end

def main
thong_ng = Engineer.new
khai = ProductAnalyst.new
company = Company.new
company.members = [thong_ng, khai, ...]
company.members.each(&:do_work)
end
How about Son?
class Engineer; def write_code; end; end
class ProductAnalyst; def design_product; end; end

company.engineers.each(&:write_code)
company.product_analysts.each(&:design_product)

# How about Son = ???


son = SometimesEngineerSometimesProductAnalyst.new
son.write_code
son.design_product

# 3 months later...
son.becomes(ProductAnalyst) # ???
Agenda

1. OOP is not about class and inheritance


2. Software design is about cracking complexity
3. Functional programming is about reducing complexity
4. Functional core, imperative shell
● Traditional way of thinking about OOP is
incomplete
OOP is not about class and inheritance
Class and inheritance are language features and
implementation details, not essential parts of object-
oriented design.
Can we use OOP without class or inheritance?
OOP without class
module Employee
def do_work; end
end

module Engineer
def write_code; end
end

module ProductAnalyst
def design_product; end
end

son = Object.new.extend(Employee)
son.extend(Engineer)
son.extend(ProductAnalyst)
OOP without inheritance
class Engineer; end
class ProductAnalyst; end

thong_ng = Employee.new(roles: [Engineer.new])


son = Employee.new(roles: [Engineer.new, ProductAnalyst.new])

“Favor composition over inheritance”


Alan Kay’s OOP
“OOP to me means only messaging, local retention and protection and hiding of state-
process, and extreme late-binding of all things.” -- Alan Kay

● Message passing -> Interface is the only dependency between objects


● Local retention, protection and hiding of state-process -> Objects don’t know
the internal details of each other
● Extreme late-binding of all things -> Object behaviors can be changed at any
point in time

No mention of class or inheritance at all!


Ruby’s messages - everything is an object
1.to_s # returns "1"
1.send(:to_s) # returns "1"

1 + 1 == 1.send(:+, 1)

# There is no Boolean class in Ruby


true.class # returns TrueCLass

if (1 == 1)
'true'
end

# There is no if/else in Smalltalk - control structures are messages


(1 == 1).ifTrue do
'true'
end

(1 == 1).send(:ifTrue, &Proc.new { 'true' })

son.send(:extend, ProductAnalyst)
Class A; end
A.is_a?(Object) # returns true - classes are objects

A whole program is just about objects sending messages to each other.


What this means for software design

When you design a program, don't confuse class with role (interface).

● Class: language feature to construct object with certain roles. An object can
be created from a single class.
● Role: Messages an object with this role can reply to. An object can have
multiple roles. An object’s roles can change during run time.

Start with roles first: Employee role, Engineer role, ProductAnalyst role,
Persistable role, Movable role, etc.
The messages

Think about the behaviors the program must support, in other words, the
messages of each role:

● do_work → WorkingRole
● write_code → EngineeringRole
● design_product → ProductAnalysisRole

And then, when we want concrete behavior, we supply them via Factory, Class or
whatever the mechanism the language supports to construct objects. In pure
OOP, class is just a type of factory.
Recap
● OOP is not about class and inheritance
● OOP is about message sending
Software design is about
Part II cracking
complexity
Software Design and Refactoring
Writing software is complex...
How complex?
Example: Slack notification flowchart
As your software grows, complexity increases
Complexity is the root of all evil

● Readability: It makes it hard to understand and


reason about a program
● Reliability: Hard to reason about the program →
hard to debug
● Reusability: Hard to turn code into reusable
component
● Scalability: Hard to separate code that can run in
parallel
● Testability: Hard to test code with too many
dependencies
Two types of complexity
● Essential complexity
○ Complexity inherent to the problem which can’t be removed e.g. Slack
notification
● Accidental complexity
○ Complexity due to the choices made from a particular software design to
solve the problem
5 aspects of complexity
● Shared mutable state
● Side-effects
● Dependencies
● Control flow
● Code size
1st aspect of complexity - mutable state
● Hard to reason about
● Implicit time
dependency
● Explosion of state
space
● Hard to parallelize
2nd aspect of complexity - side effects
● Async call
● UI render
● Network call, etc.

Main problems: asynchronous


and fallible
3rd aspect of complexity - Dependencies
● Class dependency: object creation
dependency
● Interface dependency: messages
dependency
● Temporal dependency: mutable
state/side-effect dependency

Golden rule of dependency management:

Things that change more should depend


on things that change less often
4th aspect of complexity - Control flow
Control flow: hard to understand, reason
about, more cases to test

● Branching: if/else, case/when, etc.


● Looping: for/while loop, recursion, etc.
● Error handling: try/catch
5th aspect of complexity - Code size
5 aspects of complexity
● Shared mutable state
● Side-effects
● Dependencies
● Control flow
● Code size
Two ways to manage complexity
● Reduce complexity
● Isolate complexity
OOP helps with isolating complexity
OOP isolates shared mutable state
Objects don’t mutate state of each other directly but send messages to each other
instead
OOP isolate dependencies
Objects only depend on each other’s interface, not implementation which
generally changes more often
OOP isolate control flow
Objects’ internal control flow are isolated from each other. External control flow is
just about objects sending messages to each other.

Branching can be converted to polymorphism and isolated to object creation


phase.
But OOP does not reduce complexity!
OOP may even increase complexity!
The main problem with OOP is that it does not reduce global complexity, it just packages
complexity into isolated, smaller boxes.

● Increase global complexity by adding indirection to behavior


○ Raw data is inherently less complex than objects with behavior
● Increase dependencies: Adding dependencies between objects
● Increase control flow: As the control flow now are the messages between
objects
● Increase code size
Recap
● Writing software is about fighting complexity
● 2 types of complexity
● 5 aspects of complexity
● How OOP isolates complexity but does not reduce it
Seeking complexity Partreduction
II with
functional programming
Software Design and Refactoring
Functional programming helps reduce complexity

● Reduce shared mutable state


● Reduce side effects
● Reduce dependencies
● Reduce control flow
What is functional programming?

Programming with pure, composable functions

● Pure: Data flows from inputs to outputs


○ No mutations
○ No side-effects
● Composable: Functions are first-class, which means they can be treated as
data and transformed like data.
How to make tiramisu
1. Begin by assembling four large egg yolks, 1/2 cup sweet marsala wine, 16 ounces mascarpone
cheese, 12 ounces espresso, 2 tablespoons cocoa powder, 1 cup heavy cream, 1/2 cup granulated
sugar, and enough lady fingers to layer a 12x8 inch pan twice (40).
2. Stir two tablespoons of granulated sugar into the espresso and put it in the refrigerator to chill.
3. Whisk the egg yolks
4. Pour in the sugar and wine and whisked briefly until it was well blended.
5. Pour some water into a saucepan and set it over high heat until it began to boil.
6. Lowering the heat to medium, place the heatproof bowl over the water and stirred as the mixture began
to thicken and smooth out.
7. Whip the heavy cream until soft peaks.
8. Beat the mascarpone cheese until smooth and creamy.
9. Poured the mixture onto the cheese and beat
10. Fold in the whipped cream
11. Assemble the tiramisu.
○ Give the each ladyfinger cookie a one second soak on each side and then arrange it on the
pan
○ After the first layer of ladyfingers are done, use a spatula to spread half the cream mixture
over it.
○ Cover the cream layer with another layer of soaked ladyfingers.
○ The rest of the cream is spread onto the top and cocoa powder sifted over the surface to
cover the tiramisu.
12. The tiramisu was now complete and would require a four hour chill in the refrigerator.
Imperative: mutates data
def make_tiramisu(eggs, sugar1, wine, cheese, cream, fingers, espresso, sugar2, cocoa)
mixture = whisk(eggs)
beat(mixture, sugar1, wine)
whisk(mixture) # over steam
whip(cream)
beat(cheese)
beat(mixture, cheese)
fold(mixture, cream)
dissolve(sugar2, espresso)
soak(fingers, espresso, seconds: 2)
assemble(mixture, fingers)
sift(mixture, cocoa)
refrigerate(mixture)

mixture # it's now a tiramisu


end
OOP: mutates data
def make_tiramisu(eggs, sugar1, wine, cheese, cream, fingers, espresso, sugar2, cocoa)
mixture = eggs.whisk
mixture.beat(sugar1, wine) # mutates itself
mixture.whisk(steam: true)
cream.whip
cheese.beat
mixture.beat(cheese)
mixture.fold(cream)
espresso.dissolve(sugar2)
fingers.soak(espresso, seconds: 2)
mixture.assemble(fingers)
mixture.sift(cocoa)
mixture.refrigerate

mixture # it's now a tiramisu


end
Problems

● What are the states of the input arguments after


running this function?

● If I have two people to make this tiramisu, which


parts can be done in parallel?
● The steps are too complex, how do I refactor the
steps to sub-functions?
● If we miss a step, how do we debug the wrong
mixture state?
What if we can make tiramisu without mutating data?
The core of Functional Programming is thinking
about data-flow rather than control-flow
Functional: nested functions
def make_tiramisu(eggs, sugar1, wine, cheese, cream, fingers, espresso, sugar2, cocoa)
refrigerate(
sift(
assemble(
fold(
beat(
whisk( # over steam
beat(beat(eggs), sugar1, wine)
),
beat(cheese)
),
whip(cream)
),
soak(fingers, dissolve(sugar2, espresso), seconds: 2)
),
cocoa
)
)
end
Which parts can be done in parallel?
Which parts must be done sequentially?
Functional: named constants
def make_tiramisu(eggs, sugar1, wine, cheese, cream, fingers, espresso, sugar2, cocoa)
beaten_eggs = beat(eggs)
mixture = beat(beaten_eggs, sugar1, wine)
whisked = whisk(mixture)
beaten_cheese = beat(cheese)
cheese_mixture = beat(whisked, beaten_cheese)
whipped_cream = whip(cream)
folded_mixture = fold(cheese_mixture, whipped_cream)
sweet_espresso = dissolve(sugar2, espresso)
wet_fingers = soak(fingers, sweet_espresso, seconds: 2)
assembled = assemble(folded_mixture, wet_fingers)
complete = sift(assembled, cocoa)
ready_tiramisu = refrigerate(complete)
ready_tiramisu
end
Benefits
No hidden state → explicit dependency → obvious error
Benefits (cont.)
● Each step can be tested in isolation without mocking
● Easier to debug, just put a breakpoint between 2 steps and check the input
and output of each function
● The pipeline can be parallelized and refactored easily. Any group of steps will
have a clear set of inputs and outputs.
Functional programming helps reduce complexity

● Reduce shared mutable state: pure functions don’t mutate state


● Reduce side effects: pure functions don’t create side effects
● Reduce dependencies: zero time dependency, explicit dependencies
between steps
● Reduce control flow: no loop, no if/else
Also, can functional and object oriented be mixed?
Functional: OO syntax - object returns new version of itself
def make_tiramisu(eggs, sugar1, wine, cheese, cream, fingers, espresso, sugar2, cocoa)
whisked = eggs
.beat(sugar1)
.beat(wine)
.whisk

cheese_mixture = cheese.beat(whisked)

folded_mixture = cream
.whip
.fold(cheese_mixture)

wet_fingers = espresso
.dissolve(sugar2)
.soak(fingers, seconds: 2)

ready_tiramisu = folded_mixture
.assemble(wet_fingers)
.sift(cocoa)
.refrigerate

ready_tiramisu
end
Part II
Combining theDesign
Software best of
andOOP and FP
Refactoring
Part IV
Functional core, object oriented shell
Refactoring is about reducing or isolating complexity

-- Thanh Dinh

Object oriented programming makes code understandable


by encapsulating (isolating) moving parts (complexity).

Functional programming makes code understandable by


minimizing (reducing) moving parts (complexity).

-- Michael Feathers
What if we can get the best of both worlds?
All code can be classified into two distinct roles: code that
does work (algorithms) and code that coordinates work
(coordinators).

-- John Sonmez
Example
We need to design a game that has

● Human has health


● Monster can attack human
● When a group of humans is near a monster, the monster will find human with
least health to attack
● When a human is attacked, its health is reduced based on monster's Attack
attribute - human’s Defense attribute

The game also needs to handle physics, input and rendering, etc.
Traditional OO design
class Monster
attr_accessor id, position, health, attributes

def attack(humans)
attacked_human = humans.min { |h| abs(h.position - monster.position) }
attacked_human.health -= min(attributes.atk - attacked_human.attributes.dfs, 0)
end

def render; end


def physics; end
end

class Human
attr_accessor id, position, health, attributes

def render; end


def physics; end
end
Problems
● Mutable states make it very hard to debug and reason about
● Tangled dependencies: Monster class now depends on Human class
○ As most real world requirements involve multiple objects, this is unavoidable
● Bloated classes: more requirements mean the classes get bigger and bigger
Functional programming deals with values; imperative
programming deals with objects

-- Alex Stepanov, Elements of Programming


Functional Core - immutable values
class PositionData < Value.new(x, y); end
class HealthData < Value.new(health); end
class AttributesData < Value.new(atk, dfs); end

class Monster < Value.new(


id: Integer,
position: PositionData,
health: HealthData,
attributes: AttributesData
)
end

class Human < Value.new(


id: Integer,
position: PositionData,
health: HealthData,
attributes: AttributesData
)
end
Functional core - pure functions for behaviors
class DamageService
def self.attack(monsters, humans)
monsters.reduce([]) do |attacked_humans, monster|
attacked_human = humans.min { |h| abs(h.position - monster.position) }
attacked_humans << attacked_human
.merge_new(health: attacked_human.health - min(monster.atk - attacked_human.dfs, 0))
attacked_humans
end
return humans.merge(attacked_humans)
end
end
OOP shell - objects
require DamageService, InputHandler, Renderer, PhysicsProcessor # etc.

class Environment # Storing states and communicate with other objects


attr_accessor humans: HumanCollection,
monsters: MonsterCollection

def process_damage
humans = DamageService.attack(monsters, humans)
end

def process_input; end # Coordinate with InputHandler


def process_physics; end # Coordinate with PhysicsProcessor
def render; end # Coordinate with Renderer

def run
loop do
process_input
process_damage
process_physics
render
end
end
end
Refactoring: reducing/isolating complexity
Mutations, side effects, dependencies, control flow can either be minimized or
isolated. We are doing both with this architecture.

● Reduce mutations by changing the core to functional. The only parts that still
have mutations are in the OO shell → Mutations are isolated
● Reduce unnecessary side effects by changing the core to functional. The
only parts that still have side effects are in the OO shell → Side effects are
isolated
● Reduce dependencies by moving to functional. Isolate dependencies to OO
shell.
● Number of control flows: Isolated inside the functional core
Benefits

● Testability: Trivial and very fast to unit test the core, minimal number of
integration tests needed for the coordinators
● Reliability: Validation can be easily added to value classes, ensure the
system is always in a valid state. Side effects and exceptions are isolated
within coordinator classes.
● Readability: Functional core has no dependency and self-contained → very
easy to read and understand in isolation
Benefits (cont.)

● Extensibility: Very easy to add new or update existing behavior without


worrying about changing the behavior since the core has no side effects and
no dependency.
● Scalability: Functional core can be put on parallel threads easily as there is
no shared data. Coordinator objects can also be moved to
distributed/microservice model easily as messaging is done through
serializable values.
Takeaways
● OOP is not about class and inheritance
● Software design is complex
● 2 ways to manage complexity: reduce and isolate
● OOP helps isolate complexity
● FP helps reduce complexity
● Best of both world: Functional Core Imperative Shell architecture
References
● The forgotten history of OOP (https://medium.com/javascript-scene/the-forgotten-history-of-oop-
88d71b9b2d9f)

● OO programming, a personal disaster (https://medium.com/@brianwill/object-


oriented-programming-a-personal-disaster-1b044c2383ab)
● What functional programming is all about
(http://www.lihaoyi.com/post/WhatsFunctionalProgrammingAllAbout.html)
● Boundaries (https://www.destroyallsoftware.com/talks/boundaries)
● Oh composable worlds (https://www.youtube.com/watch?v=SfWR3dKnFIo)
Questions?

You might also like