Object-Oriented Programming (OOP) vs Functional Programming (FP)

This article takes a deep dive into two fundamental types of programming (Object-Oriented Programming (OOP) & Functional Programming) in a side-by-side comparison. The blog was written and contributed by Flux Technologies‘ software engineer Samvel Melkonyan.

Defining Object-Oriented Programming (OOP) & Functional Programming

Object-Oriented Programming (OOP)

Object-oriented programming (OOP) is a programming paradigm that is based on the concept of “objects”, which are instances of a class. These objects contain both data (attributes) and behavior (methods) that describe their characteristics and actions.

Functional Programming (FP)

Functional programming (FP) is a programming paradigm that is based on the concept of “functions” that take inputs and produce outputs. These functions are pure, meaning they don’t have side-effects, and are first-class citizens, meaning they can be passed around as arguments or returned as results.

Object-Oriented Programming (OOP)

Object-Oriented Programming OOP is based on the following four principlesEncapsulationInheritancePolymorphism, and Abstraction.

Encapsulation

Encapsulation allows you to hide the internal state and behavior of an object from the outside world. At the same time it allows the object to be accessed only through a well-defined interface. This means that the internal state of an object is not directly accessible from outside the object, and can only be modified or accessed through the object’s methods. It also allows for the implementation of an object to change without affecting the code that uses the object. This can make the code more robust and maintainable over time.

class BankAccount {
  constructor(balance) {
    this._balance = balance;
  }
  
  deposit(amount) {
    this._balance += amount;
  }
  
  withdraw(amount) {
    if (this._balance >= amount) {
      this._balance -= amount;
    } else {
      console.log("Insufficient funds");
    }
  }
  
  get balance() {
    return this._balance;
  }
}

const account = new BankAccount(1000);
console.log(account.balance); // 1000
account.deposit(500);
console.log(account.balance); // 1500
account.withdraw(300);
console.log(account.balance); // 1200

In this example, the BankAccount class has a private variable _balance which can only be accessed and modified through the public methods deposit() and withdraw(). The get accessor for the balance property allows for reading the value of the private _balance variable, but it can’t be set directly from outside the class.

Inheritance

Inheritance is a mechanism that allows a new class to be defined based on an existing class, inheriting its attributes and methods. This allows for code reuse and can make development more efficient.

class Animal {
  constructor(name, type) {
    this.name = name;
    this.type = type;
  }

  speak() {
    console.log(`${this.name} makes a sound.`);
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name, 'dog');
  }

  speak() {
    console.log(`${this.name} barks.`);
  }
}

class Cat extends Animal {
  constructor(name) {
    super(name, 'cat');
  }

  speak() {
    console.log(`${this.name} meows.`);
  }
}

const dog1 = new Dog('Fido');
dog1.speak(); // Fido barks.
const cat1 = new Cat('Whiskers');
cat1.speak(); // Whiskers meows.

In this example, the Animal class is the base class, and Dog and Cat classes are subclasses that inherit from the Animal class. The Dog and Cat classes have a speak() method that overrides the speak() method of the base class, so when speak() is called on an instance of Dog or Cat, it will execute the overridden method instead of the base class method.

Polymorphism

  • Lets objects of different classes to be treated as objects of a common base class, and to be used interchangeably.
  • Allows for more flexibility and can make code more reusable.
  • Enables the use of a common interface for different classes, making it possible to write code that can work with objects of different types without knowing their specific class.
class Shape {
    constructor(name) {
        this.name = name;
    }
    draw() {
        console.log(`Drawing a ${this.name}`);
    }
}

class Rectangle extends Shape {
    constructor(width, height) {
        super('rectangle');
        this.width = width;
        this.height = height;
    }
    draw() {
        console.log(`Drawing a ${this.name} with width ${this.width} and height ${this.height}`);
    }
}

class Circle extends Shape {
    constructor(radius) {
        super('circle');
        this.radius = radius;
    }
    draw() {
        console.log(`Drawing a ${this.name} with radius ${this.radius}`);
    }
}

let shape = new Shape('generic shape');
let rectangle = new Rectangle(5, 10);
let circle = new Circle(3);

let shapes = [shape, rectangle, circle];

for (let s of shapes) {
    s.draw();
}

In this example, we have a base class Shape and two subclasses Rectangle and Circle that inherit from it. The draw() method is defined in the base class and overridden in the subclasses. The key feature of polymorphism is that the draw() method can be called on objects of any of the three classes and it will produce the appropriate output. The shapes array contains an instance of each class and the for loop iterates over the array and calls the draw() method on each object.

Abstraction

  • Abstraction is the ability to focus on the essential features of an object, and to ignore non-essential details.
  • Allows for the creation of classes that are not tied to specific implementations, making the code more flexible and easy to maintain.
  • Makes it possible to work with objects of a class without knowing the details of their implementation, which can make the code more robust and less error-prone.
class Vehicle {
  constructor() {
    this._engine = null;
  }

  startEngine() {
    // implementation details are hidden
    // this._engine.start();
    console.log("Engine started");
  }

  stopEngine() {
    // implementation details are hidden
    // this._engine.stop();
    console.log("Engine stopped");
  }
}

class Car extends Vehicle {
  constructor() {
    super();
    this._engine = new Engine("V8");
  }
}

class Bike extends Vehicle {
  constructor() {
    super();
    this._engine = new Engine("Single Cylinder");
  }
}

class Engine {
  constructor(type) {
    this._type = type;
  }
}

const car = new Car();
car.startEngine(); // "Engine started"
car.stopEngine(); // "Engine stopped"

const bike = new Bike();
bike.startEngine(); // "Engine started"
bike.stopEngine(); // "Engine stopped"

In this example, Vehicle class provides an abstraction for different types of vehicles, hiding the specific details of each vehicle. The Car and Bike classes extend the Vehicle class and add their own properties and methods, but the user of the class only interacts with the Vehicle class and is unaware of the underlying implementation details. This allows for more flexibility and makes the code more modular and easier to maintain.

The user of the class only needs to know how to start and stop the engine of the vehicle, and doesn’t have to know the details of the engine type (V8, Single Cylinder). By abstracting away the implementation details, the user can focus on the essential features of the class and ignore non-essential details.

Functional Programming (FP)

Here are some of the main principles of functional programming:

Pure functions

In functional programming, functions are considered the building blocks of the program. and they should be pure, which means they should not have any side effects and should always return the same output given the same input.

// A pure function
function add(a, b) {
  return a + b;
}
console.log(add(2, 3)); // 5

// An impure function
let x = 0;
function addImpure(a) {
  x += a;
  return x;
}
console.log(addImpure(2)); // 2
console.log(addImpure(3)); // 5

Immutability

In functional programming, data is immutable, which means that once created, data cannot be modified. This is a key principle of functional programming, as it makes it easier to reason about the program and eliminates the risk of bugs caused by unexpected changes in the state of the program.

let x = [1, 2, 3];

// Modifying x in an impure way
x.push(4);
console.log(x); // [1, 2, 3, 4]

// Modifying x in a pure way
let y = [...x, 4];
console.log(y); // [1, 2, 3, 4]
console.log(x); // [1, 2, 3]

Avoid shared state

In functional programming, it is generally considered best practice to avoid shared state, which means that functions should not rely on variables or data that is external to the function. Instead, data should be passed as arguments to the function and returned as the result.

let counter = 0;

// A function that uses shared state
function incrementCounter() {
  counter++;
  return counter;
}
console.log(incrementCounter()); // 1
console.log(incrementCounter()); // 2

// A function that avoids shared state
function increment(n) {
  return n + 1;
}
let count = 0;
console.log(increment(count)); // 1
count = increment(count);
console.log(increment(count)); // 2

First-class citizens

Here, functions should be treated like any other data type, they can be assigned to variables, passed as arguments to other functions, and returned as values from other functions.

// Assign a function to a variable
const add = (x, y) => x + y;
const sum = add;
console.log(sum(1, 2)); // 3

// Pass a function as an argument to another function
const callFunction = (fn, x) => fn(x);
console.log(callFunction(add, 2, 3)); // 5

// Return a function from a function
function createCounter() {
  let count = 0;
  return function () {
    return count++;
  };
}
const counter = createCounter();
console.log(counter()); // 0
console.log(counter()); // 1

Higher-order functions

In functional programming, functions can be used as arguments to other functions or returned as the result of a function. This allows for powerful abstractions, such as functional composition and currying.

// A higher-order function
function operateOn(operation, x, y) {
  return operation(x, y);
}

// A function that takes two arguments and returns their sum
function add(x, y) {
  return x + y;
}

// A function that takes two arguments and returns their product
function multiply(x, y) {
  return x * y;
}

console.log(operateOn(add, 2, 3)); // 5
console.log(operateOn(multiply, 2, 3)); // 6

Recursion

In functional programming, recursion is often used as an alternative to loops. Recursive functions are functions that call themselves, and they are well-suited for solving problems that have a recursive structure.

// A recursive function
function factorial(n) {
  if (n === 0) {
    return 1;
  }
  return n * factorial(n - 1);
}
console.log(factorial(5)); // 120

Lazy evaluation

In functional programming, the evaluation of an expression is delayed until its value is needed, this is known as lazy evaluation. This can be useful to improve performance and save memory usage.

// A generator function that returns an infinite sequence of numbers
function* naturalNumbers() {
  let n = 1;
  while (true) {
    yield n++;
  }
}

const numbers = naturalNumbers();
console.log(numbers.next().value); // 1
console.log(numbers.next().value); // 2
console.log(numbers.next().value); // 3

Scalability of the two paradigms (Object-oriented programming (OOP) and functional programming (FP)

Both Object-oriented programming (OOP) and functional programming (FP) paradigms can be used to write scalable code, but they have different approaches and trade-offs when it comes to scalability.

OOP is based on the concept of objects, which can be used to model the problem domain, and it’s often used to create large, complex systems. OOP allows for the creation of reusable code by using inheritance and polymorphism, which can help to reduce the amount of code and increase the scalability of the system. However, OOP can also have some drawbacks when it comes to scalability, such as tightly coupled classes, complex class hierarchies, and the use of global state, which can make the code harder to understand, test and maintain.

FP, on the other hand, is based on the concept of functions and data transformations, which can be easily composed, and it’s often used to create small, reusable components. It is based on the principles of immutability and avoiding shared state, which can help to increase the scalability of the system by making the code more predictable and easy to reason about. This also allows for the use of higher-order functions and lazy evaluation, which can help to improve performance and save memory usage. However, FP can also have some drawbacks when it comes to scalability, such as the need for more memory when using immutability, and more complex code when using recursion.

Both OOP and FP can be used to create scalable code, but they have different trade-offs. OOP is more suitable for creating large, complex systems, while FP is more suitable for creating small, reusable components. It’s important to choose the right paradigm for the problem at hand and apply best practices and design patterns to increase scalability.

Key differences between the two paradigms

  1. OOP focuses on objects, which are instances of a class, and their interactions with each other. FP focuses on functions and their inputs and outputs.
  2. OOP is based on the principles of encapsulation, inheritance, polymorphism, and abstraction. FP is based on the principles of immutability, referential transparency, higher-order functions, and recursion.
  3. OOP uses encapsulation to hide the internal state and behavior of an object from the outside world, and to allow the object to be accessed only through a well-defined interface. FP uses immutability to ensure that data cannot be modified after it is created, and referential transparency to ensure that a function will always produce the same outputs given the same inputs.
  4. OOP uses classes and objects to model real-world entities and their behavior. FP uses functions to describe the transformation of data.
  5. OOP code often involves a lot of state changes and side-effects, while FP code is typically more predictable and deterministic because it avoids state changes and side-effects.
  6. OOP code is often more verbose and complex, while FP code can be more concise and elegant.
  7. OOP is often associated with imperative languages such as Java and C#, while FP is often associated with functional languages such as Haskell, Lisp, and Scheme.
  8. OOP is often used in object-oriented languages, that means it has a heavier focus on the object’s state, while FP is often used in functional languages, that means it has a heavier focus on the function’s behavior.

Benefits and drawbacks of each approach

Benefits of OOP

  1. OOP allows for the modeling of real-world entities and their behavior, making it easier to understand and reason about complex systems.
  2. OOP promotes the use of encapsulation, which helps to maintain the integrity of the internal state of an object and to prevent external code from making unintended changes.
  3. OOP allows for code reuse through inheritance, which can make development more efficient.
  4. OOP’s encapsulation and inheritance features can make it easier to maintain and extend code over time.

Drawbacks of OOP

  1. OOP can lead to code that is more verbose and complex, which can make it harder to understand and maintain.
  2. OOP’s emphasis on changing the state of objects can lead to code that is harder to reason about and test.
  3. OOP’s heavy use of side-effects and state changes can make it harder to reason about the order in which code is executed.
  4. OOP’s reliance on shared state can make it harder to write concurrent and parallel code.

Benefits of FP

  1. FP promotes the use of immutability, which helps to prevent unintended changes to data and to make code more predictable and deterministic.
  2. FP’s focus on functions as first-class citizens can make code more modular, reusable, and easier to test.
  3. FP’s use of recursion and higher-order functions can make it easier to write elegant and expressive code.
  4. FP’s emphasis on referential transparency can make it easier to reason about the behavior of code.

Drawbacks of FP

  1. FP can lead to code that is more complex and harder to understand, especially for developers who are not familiar with the functional programming concepts.
  2. FP’s focus on immutability can make it less efficient in certain scenarios, such as when working with large data sets.
  3. FP’s heavy use of recursion can make it harder to write concurrent and parallel code.
  4. FP’s use of higher-order functions and closures can make it more difficult to reason about the behavior of code.

Conclusion

Object-oriented programming (OOP) and functional programming (FP) are two popular programming paradigms that are widely used in industry. Each paradigm has its own strengths and weaknesses and they are suited for different types of problems and use cases.

OOP is more suitable for creating large, complex systems, while FP is more suitable for creating small, reusable components. It’s important to choose the right paradigm and apply best practices and design patterns to increase scalability.

BY Flux Team