Software design using OOP + FP — Part 1
Federico Lochbaum
Posted on June 27, 2024
Software design using OOP + FP — Part 1
Let’s do crazy solutions combining OOP and FP :)
The idea for this article is to summarize several patterns and provide examples of how to solve certain problem cases by mixing Object-Oriented Programming (OOP) and Functional Programming (FP) to get better solutions.
Throughout our professional lives, we can move between different programming paradigms. Each has its strengths and weaknesses and there are more than just two. We will explore OOP and FP because they are the most commonly used. The key is always knowing when and how to apply each to get better solutions.
Let’s get a quick review of the main ideas/concepts of each paradigm
Object-oriented programming features
Encapsulation: Hiding internal details and exposing only what’s necessary.
Inheritance: Creating new classes based on existing ones.
Polymorphism: Different classes can be treated through a common interface.
Abstraction: Simplifying complex systems by hiding unnecessary details.
Dynamic Binding: Determining which method to invoke at runtime rather than compile time.
Message Passing: Objects communicate with one another by sending and receiving messages.
Functional programming features
Immutability: Data doesn’t change once created.
Pure functions: Always return the same result for the same inputs.
Function composition: Combining simple functions to create more complex ones.
Recursion: Solving problems by breaking them down into smaller cases of the same problem.
High order: Functions that take other functions as arguments or return functions as results.
Referential transparency: An expression can be replaced with its value without changing the program’s behaviour.
Now that we have reviewed the principal paradigms topics, we are ready to check some common smell patterns but before that, I want to present my top premises when I am coding
Minimize ( as much as possible ) the amount of used syntax.
Avoid unnecessary parameter definitions.
Keep functions/methods small and focused on single-purpose ( inline if possible ).
Design using immutability and leave mutability just for special cases ( encapsulated ).
Use descriptive names for functions, methods, classes and constants.
Avoid using built-in iteration keywords ( unless using a high-order function makes the solution worse ).
Keep in mind the computational cost all the time.
The code must explain itself.
Note: I will use *NodeJs *for these examples because of facilities although these ideas should be easily implemented using any other programming language that allows us to use OOP and FP paradigms.
Complex if then else and switch cases
Several times I see code with many nested conditions or switch cases that make the logic hard to understand and maintain
const reactByAction = action => {
switch (action.type) {
case 'A':
return calculateA(action)
case 'B':
return calculateB(action)
case 'C':
return calculateC(action)
default:
return // Do nothing
}
}
Here we can choose between two approaches. We can decide to use the classic *Polymorphism *on OOP, delegating the responsibility of that computation to every action representation
const reactByAction = action => action.calculate()
Where each action knows how to be calculated
` abstract class Action {
abstract calculate()
}
class ActionA extends Action {
calculate() {
...
}
}
class ActionB extends Action {
calculate() {
...
}
}
...`
But, indeed, sometimes our solutions weren’t thought to be treated like instance objects. In this case, we can use classical object mapping and Referential transparency, which works like an indirect switch case, but allows us to omit complex matches
const calculateAction = {
A: action => ...,
B: action => ...,
C: action => ...,
}
const reactByAction = action => calculateAction?.[action.type](action)
Of course, this has a limitation, each time you need to “identify” an action type, you will need to create a new constant object mapper.
Whatever the solution is chosen, you always want to keep it simple, delegating the responsibilities the best way possible, distributing the count of lines of your solution and using a good declarative name to simplify the understanding/scalability.
If you can’t provide an instance of every action, I suggest to implement a melted implementation
` class Handler {
constructor() {
this.actionMap = {
A: action => new ActionA(action),
B: action => new ActionB(action),
C: action => new ActionC(action),
};
}
reactByAction = action => this.actionMap[action.type]?.(action).calculate()
}`
Unnecessary parameter definitions
Sometimes, we implement functions/methods with many parameters that could be derived from a shared context, here is a simple example
`
const totalPrice = (price, taxRate, discountRate) => {
const taxAmount = price * taxRate
const discountAmount = price * discountRate
return price + taxAmount - discountAmount
}
const totalForRegularCustomer = price => totalPrice(price, 0.1, 0)
const totalForPremiumCustomer = price => totalPrice(price, 0.1, 0.1)`
The definitions are right but we can simplify the number of responsibilities by decoupling the function, using the concept of currying and classes to encapsulate the details and create more specialized methods
Here is a simple suggestion using OOP
` class Customer {
taxAmount = price => price * this.taxRate()
discountAmount = price => price * this.discountRate()
totalPrice = price => price + this.taxAmount(price) - this.discountAmount(price)
}
class RegularCustomer extends Customer {
taxRate = () => 0.1
discountRate = () => 0
}
class PremiumCustomer extends Customer {
taxRate = () => 0.05
discountRate = () => 0.1
}`
Note that I used the pattern template method :)
And here is another option using FP
` const tax = taxRate => price => price * taxRate
const discount = discountRate => price => price * discountRate
const regularTaxAmout = tax(0.1)
const premiumTaxAmout = tax(0.05)
const regularDiscount = discount(0)
const premiumDiscount = discount(0.1)
const calculationWithRates = (taxRateFunc, discountRateFunc) => price =>
price + taxRateFunc(price) - discountRateFunc(price)
const totalForRegularCustomer = calculationWithRates(regularTaxAmout, regularDiscount)
const totalForPremiumCustomer = calculationWithRates(premiumTaxAmout, premiumDiscount)`
Note that I am currying the functions to provide more abstractions increasing the declarativity
Again, we can mix both paradigms in a synthesized design :)
` class Customer {
constructor(taxRate, discountRate) {
this.taxRate = taxRate
this.discountRate = discountRate
}
totalPrice = price => price + this.calculateTax(price) - this.calculateDiscount(price)
calculateTax = price => price * this.taxRate
calculateDiscount = price => price * this.discountRate
}
const regularCustomer = new Customer(0.1, 0)
const premiumCustomer = new Customer(0.05, 0.1)
const total = customer => customer.totalPrice
const totalForRegularCustomer = total(regularCustomer)
const totalForPremiumCustomer = total(premiumCustomer)`
Excessive long method/functions
Another classic I’m used to seeing are methods and functions with a lot of responsibilities, too long, with hardcoded conventions and complex business rules
` const tokenize = code => {
let tokens = []
let currentToken = ''
let currentType = null
for (let i = 0; i < code.length; i++) {
let char = code[i]
if (char === ' ' || char === '
' || char === ' ') {
if (currentToken !== '') {
tokens.push({ type: currentType, value: currentToken })
currentToken = ''
currentType = null
}
} else if (char === '+' || char === '-' || char === '*' || char === '/') {
if (currentToken !== '') {
tokens.push({ type: currentType, value: currentToken })
currentToken = ''
}
tokens.push({ type: 'operator', value: char });
} else if (char >= '0' && char <= '9') {
if (currentType !== 'number' && currentToken !== '') {
tokens.push({ type: currentType, value: currentToken })
currentToken = ''
}
currentType = 'number'
currentToken += char
} else if ((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')) {
if (currentType !== 'identifier' && currentToken !== '') {
tokens.push({ type: currentType, value: currentToken })
currentToken = ''
}
currentType = 'identifier'
currentToken += char
}
}
if (currentToken !== '') {
tokens.push({ type: currentType, value: currentToken })
}
return tokens
}`
Too complex, right? It is impossible to understand just by reading the code
Let’s see a reimplementation of this using OOP creating an abstraction and encapsulating the mutability of the context
` const binaryOperators = ['+', '-', '*', '/']
const SPECIAL_CHARS = {
EMPTY_STRING: '',
...
}
class Context {
constructor(code) {
this.code = code
this.tokens = []
this.currentToken = ''
this.currentType = null
}
...
}
class Tokenizer {
constructor(code) { this.context = new Context(code) }
getCurrentToken() { this.context.getCurrentToken() }
tokenize() {
this.context.getCode().forEach(this.processChar)
this.finalizeToken()
return this.context.getTokens()
}
processChar(char) {
if (this.isWhitespace(char)) {
this.finalizeToken()
} else {
...
}
}
isEmptyChar() {
return this.getCurrentToken() == SPECIAL_CHARS.EMPTY_STRING
}
finalizeToken() {
if (!isEmptyChar()) {
this.addToken(this.context.currentType(), this.currentToken())
this.resetCurrentToken()
}
}
addToken(type, value) {
this.context.addToken({ type, value })
}
resetCurrentToken() {
this.context.resetCurrentToken()
}
processDigit(char) {
if (!this.context.currentIsNumber()) {
this.finalizeToken();
this.context.setNumber()
}
this.context.nextChar()
}
processLetter(char) {
...
}
isWhitespace = char => char === SPECIAL_CHARS.SPACE || char === SPECIAL_CHARS.JUMP || char === SPECIAL_CHARS.TAB
isOperator = binaryOperators.includes
...
}`
Or we can think of a solution using some other FP concepts
` const isWhitespace = [' ', '
', ' '].includes
const isOperator = ['+', '-', '*', '/'].includes
...
const createToken = (type, value) => ({ type, value })
const initialState = () => ({ tokens: [], currentToken: EMPTY_STRING, currentType: null })
const processChar = (state, char) => {
if (isWhitespace(char)) return finalizeToken(state)
if (isOperator(char)) return processOperator(state, char)
if (isDigit(char)) return processDigit(state, char)
if (isLetter(char)) return processLetter(state, char)
return state
}
const finalizeToken = state => isEmptyString(currentToken) ? state : ({
...state,
tokens: [ ...state.tokens, createToken(state.currentType, state.currentToken) ],
currentToken: '',
currentType: null
})
const processOperator = (state, char) => ({
...finalizeToken(state),
tokens: [...state.tokens, createToken(TYPES.op, char)]
})
const processDigit = (state, char) => ({
...state,
currentType: TYPES.Number,
currentToken: isNumber(state.currentType) ? state.currentToken + char : char
})
...
const tokenize = code => finalizeToken(code.reduce(processChar, initialState())).tokens
Again, we think in encapsulation, abstractions and declarativeness
And, of course, we can provide a mixed solution ;)
const SPECIAL_CHARS = {
SPACE: ' ',
NEWLINE: '
',
TAB: ' '
}
const binaryOperation = ['+', '-', '*', '/']
class Tokenizer {
constructor(code) {
this.code = code
this.tokens = []
this.currentToken = EMPTY_STRING
this.currentType = null
}
tokenize() {
this.code.split('').forEach(this.processChar.bind(this))
this.finalizeToken()
return this.tokens
}
processChar = char => {
const processors = [
{ predicate: this.isWhitespace, action: this.handleWhitespace },
{ predicate: this.isOperator, action: this.handleOperator },
{ predicate: this.isDigit, action: this.handleDigit },
{ predicate: this.isLetter, action: this.handleLetter }
]
const { action } = processors.find(({ predicate }) => predicate(char)) || EMPTY_OBJECT
action?.(this, char)
}
isWhitespace = Object.values(SPECIAL_CHARS).includes
isOperator = binaryOperation.includes
isDigit = /[0-9]/.test
isLetter = /[a-zA-Z]/.test
handleWhitespace = this.finalizeToken
handleOperator = char => {
this.finalizeToken()
this.addToken(TYPES.operation, char)
}
handleDigit = char => {
if (!this.isNumber()) { this.finalizeToken(); this.currentType = TYPES.number }
this.currentToken += char
}
handleLetter = char => {
if (!this.isIdentifier()) { this.finalizeToken(); this.currentType = TYPES.id }
this.currentToken += char
}
finalizeToken() {
if (!this.isEmpty()) {
this.addToken(this.currentType, this.currentToken)
this.currentToken = SPECIAL_CHARS.empty
this.currentType = null
}
}
addToken(type, value) {
this.tokens.push({ type, value })
}
}
const tokenize = code => new Tokenizer(code).tokenize()
`
Let's finish this first article about OOP + FP software design, I would like to write more about this perspective reviewing much more complex examples and patterns, proposing melted solutions and exploring deeply the ideas of both paradigms.
Let me know if something is strange, or if you find something in this article that could be improved. Like our software, this is an incremental iterative process, improving over time 😃.
Thanks a lot for reading!
Posted on June 27, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.