OOP Principles: What is Object Oriented Programming?

In this post, I'll explain with examples the four core principles of object-oriented programming: encapsulation, inheritance, polymorphism, and abstraction.

Throughout this post, I will be using Ruby for code examples, but these same examples could apply to various languages.

What is Object Oriented Programming?

Most software engineers spend most of their career having a general idea of what OOP is but couldn't answer this question succinctly in an interview. Frankly, it's not a great interview question if you ask me.

That's because there is no perfect answer to this question. OOP can be many things depending on your perspective:

  • OOP is a paradigm - in the broadest sense, OOP defines a collection of ideas on how to write better code which may include SOLID principles, DDD (domain-driven design), and more.
  • OOP is a language feature - not all programming languages are "object-oriented". To be an "OOP language", the language syntax itself must support things like classes, methods, inheritance, and more.
  • OOP is a method of writing code - in its most concrete definition, OOP is a way to design and write code that adheres to the principles that make up its "paradigm". It's a method of organizing your code into a collection of classes, each of which has methods.

What languages are object-oriented?

A better question to ask here would be, "What languages enable OOP in the highest degree?".

Languages run on a scale from "Pure OOP" to "OOP-ish". For example, when we think of OOP, Smalltalk (not widely used anymore) is often considered the "original" OOP language because everything in Smalltalk is an object. Whether we're talking about primitive data types like strings, booleans, and numbers to functions and classes, Smalltalk treats them all as objects.

Ruby was built on many of the principles of Smalltalk and thus, is considered one of the more "pure" OOP languages due to its native support for all of the principles of OOP that we will talk about in this post.

Here's a short list of OOP-enabled languages from "most OOP" to "least OOP".

  1. Ruby
  2. Java
  3. Python
  4. C#
  5. C++
  6. JavaScript* - JS supports many OOP features and treats everything as an object, but is rarely used in a "pure" OOP way (this could be an entirely separate post so I won't be diving into specifics here)

What are the alternatives to OOP?

It is important to note that most languages are multi-paradigm. For example, JavaScript, due to its widespread adoption has been used for object-oriented programming, functional programming, event-driven programming, or all of the above in the same codebase!

Think of each of these as "styles of programming". Depending on what they grew up writing, each programmer you meet will be pre-disposed to certain types of paradigms.

For example, if you drop a Ruby programmer of 20 years into a JavaScript codebase, they will reach for JavaScript's OOP features while someone who "grew up" writing JavaScript may be more predisposed to a functional programming approach.

The most common alternatives to OOP include:

  1. Procedural Programming: While not commonly used today, procedural programming is about telling the computer the exact steps it needs to take. It is often considered imperative by nature. Languages such as C and BASIC epitomize procedural programming, making it particularly suited for straightforward, computational tasks where complex object hierarchies are unnecessary.
  2. Functional Programming (FP): Functional programming is based on the concept of using pure functions that avoid shared state, mutable data, and side effects. Languages like JavaScript, Haskell, Erlang, and Scala offer robust support for functional programming. This paradigm is particularly powerful for tasks involving concurrency and distributed systems due to its stateless nature and emphasis on immutability.
  3. Event-Driven Programming: This paradigm is centered around the concept of events. An event-driven program is designed to react to events—user interactions like clicks or keypresses, sensor outputs, or messages from other programs. In event-driven programming, the flow of the program is determined by events. It is commonly used in developing interactive applications and interfaces, such as graphical user interfaces (GUIs) and real-time system applications. JavaScript is a prime example of a language that excels at event-driven programming, especially in the context of web applications where responsiveness to user actions is crucial.

Why is OOP a controversial topic?

As with any paradigm that has been around for a handful of decades, some practitioners have been criticized for taking OOP to an extreme, near-dogmatic level at the expense of simplicity.

While OOP can be an excellent tool for structuring programs, it can also introduce significant overhead and complexity where none is needed. For example, you'll find stateless utility functions like the one below in nearly every codebase you work in:

function calculateMortgage(principal, annualRate, term) {
    const monthlyRate = annualRate / 12 / 100;
    const numberOfPayments = term * 12;
    const monthlyPayment = (principal * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numberOfPayments));
    return monthlyPayment.toFixed(2);
}

This doesn't need object-oriented programming but a "pure OOP" practitioner may write it as part of a class:

class FinancialCalculator
  def self.calculate_mortgage(principal, annual_rate, term)
      monthly_rate = annual_rate.to_f / 12 / 100
      number_of_payments = term * 12
      monthly_payment = (principal * monthly_rate) / (1 - (1 + monthly_rate) ** -number_of_payments)
      monthly_payment.round(2)
  end
end

In this example, the class FinancialCalculator is merely a "wrapper" or "namespace" around the stateless utility function. We don't need this class, but because Ruby is a "pure OOP" language, you'll rarely find a function without a parent class (although technically, Ruby supports it).

While a contrived example, you can see how OOP can quickly add overhead to codebases where simpler solutions may have sufficed.

The Four Core Principles of OOP Languages

Nearly all modern programming languages support OOP in at least some form. Here are the four principles that a language must support to be considered an "OOP language".

Principle #1: Encapsulation

Encapsulation, also known as "information hiding" is how we group related data and methods together and protect them from the "outside world". Generally, the mechanism we do this with is a "class".

For example, an ATM machine demonstrates encapsulation like many other real-world devices do. We click buttons to see information about our bank account and withdraw money, but we cannot see what's going on inside of it:

Encapsulation Example

In the example below, I've constructed a class that demonstrates some of the key aspects of encapsulation. Notice a few things here:

  • Protecting data: the class stores a balance variable that is manipulated within the class but cannot be seen by anything outside the class except for through the show_balance public method
  • Protecting methods: the class only exposes 3 public methods. All validation of the bank balance is done via private methods only available within the class.
class Account
  # Initialize with a private variable to store the balance
  # This is an example of data hiding
  def initialize(balance = 0)
    @balance = balance
  end

  # Public method to deposit money into the account
  # Demonstrates interface to access data
  def deposit(amount)
    if valid_amount?(amount)
      @balance += amount
      puts "Deposited #{amount}. New balance is #{@balance}."
    else
      puts "Invalid amount."
    end
  end

  # Public method to withdraw money from the account
  # Another interface example that manipulates data
  def withdraw(amount)
    if valid_amount?(amount) && sufficient_balance?(amount)
      @balance -= amount
      puts "Withdrew #{amount}. Remaining balance is #{@balance}."
    elsif !sufficient_balance?(amount)
      puts "Insufficient balance."
    else
      puts "Invalid amount."
    end
  end

  # Public method to display the current balance
  # Allows controlled access to private data
  def show_balance
    puts "Current balance: #{@balance}"
  end

  private

  # Private method to check if the amount is valid
  # Protects data integrity by validating data
  def valid_amount?(amount)
    amount.is_a?(Numeric) && amount > 0
  end

  # Private method to check for sufficient balance
  # Helps maintain data integrity and encapsulates logic specific to withdrawals
  def sufficient_balance?(amount)
    @balance >= amount
  end
end

Principle #2: Abstraction

Similar to encapsulation, abstraction creates a "barrier" between one unit of code (usually a class) and the rest of the code in the system.

It takes a complex operation and exposes a simple public interface for a caller of the code to deal with.

Some key elements of abstraction include:

  1. Simplification: Abstraction helps in simplifying complex realities by modeling classes appropriate to the problem, while ignoring the irrelevant details or complexities that do not contribute to solving the problem.
  2. Focus on What, Not How: Abstraction allows programmers to focus on what an object does instead of how it does it. This is achieved through the use of methods that act as interfaces to the capabilities of the objects, letting details of their implementation remain hidden.
  3. Reusable Code: By focusing only on the necessary attributes and behaviors, abstraction helps in creating more general and reusable code. This is because more abstract representations are less tied to specific implementations.
  4. Interface Definition: It defines the interface to the object, that is, the methods and properties an object publicly exposes. The interface is distinct from the implementation, which is encapsulated and can vary independently of other parts of the system.

Abstraction Example

The weather forecasting example below is a perfect demonstration of abstraction. Within the class, WeatherForecaster is doing a lot of complex operations including fetching data from an external API and running calculations on that data.

But in the end, the only thing an outside caller of this class needs to know is that they can call forecast_temperature method to get a forecasted temperature. They are saved from all the messy internal details that have been "abstracted away".

class WeatherForecaster
  def initialize(location)
    @location = location
    @historical_data = load_weather_data(location)
  end

  # Public method to get the average forecasted temperature for a given day
  def forecast_temperature(date)
    daily_temperatures = select_relevant_data(date)
    calculate_average_temperature(daily_temperatures)
  end

  private

  # Simulate loading historical weather data from a database or API
  def load_weather_data(location)
    # Here we would normally fetch data from a database or an external API
    # For the purpose of this example, we'll simulate with static data
    {
      '2024-04-15' => [65, 67, 70, 66, 68],
      '2024-04-16' => [60, 62, 65, 64, 63],
      '2024-04-17' => [55, 57, 59, 58, 56]
    }
  end

  # Select data relevant to the specific date
  def select_relevant_data(date)
    # This could involve complex logic selecting relevant historical patterns based on date
    @historical_data[date]
  end

  # Calculate the average temperature from selected data
  def calculate_average_temperature(temperatures)
    return 0 if temperatures.nil? || temperatures.empty?
    temperatures.sum / temperatures.size.to_f
  end
end

Principle #3: Inheritance


Inheritance in object-oriented programming (OOP) allows a class to inherit properties and behaviors (methods) from another class, promoting code reusability and establishing a hierarchical relationship between classes. This concept enables developers to create new classes based on existing ones, enhancing code efficiency and reducing redundancy.

Here are some key points about inheritance:

  • Base and Derived Classes: Inheritance involves a base (or parent) class that provides properties and methods which are inherited by one or more derived (or child) classes.
  • Code Reusability: By inheriting from a base class, derived classes can use existing code without duplication, allowing developers to build on previous work efficiently.
  • Extensibility: Derived classes can not only inherit properties and methods from the base class but can also add or modify their functionalities, making it easy to extend the capabilities of existing code.

Inheritance Example

In its most simple form, you can see an example where a Dog and a Cat both "inherit" behavior from a parent Animal class (because they are both animals).

All animals can breathe (shared, "inherited behavior") while only a Dog can bark and only a Cat can meow (specific behavior).

class Animal
  def breathe
    puts "Breathing..."
  end
end

class Dog < Animal
  def bark
    puts "Woof!"
  end
end

class Cat < Animal
  def meow
    puts "Meow!"
  end
end

Inheritance vs. Composition

I could write an entire blog post dedicated to this topic because one of the primary ways that OOP is misused (and why it gets criticized) is through using too much inheritance.

Inherited relationships (especially when more than 1-level deep) can become complex and hard to modify. But first, let's look at the major difference between composition and inheritance:

  • Inheritance ("is a" relationship): This describes a relationship where the derived class is a specific type of the base class. For example, a Dog class inheriting from an Animal class means every Dog is an Animal.
  • Composition ("has a" relationship): This implies that a class contains one or more instances of other classes as part of its data structure. For example, a Car class might have objects like Engine, Wheel, which are separate classes. Here, a Car isn't an Engine or a Wheel, but it has an Engine and Wheels.

Composition is generally preferred over inheritance because it provides greater flexibility in code organization by reducing the dependencies between components. It allows for easier maintenance and modification since systems can be easily adapted or extended by replacing or modifying their components without needing to redesign the entire structure.

  • Modularity: Components can be developed independently and plugged together as needed.
  • Reusability: Individual components can be reused across different parts of a system without inheriting unnecessary properties.
  • Reduced Fragility: Changing one component generally doesn't affect others.

For example, take a look at this Ruby class which demonstrates composition:

class Engine
  def start
    puts "Engine starting"
  end
end

class Wheel
  def rotate
    puts "Wheel rotating"
  end
end

class Car
  def initialize
    @engine = Engine.new
    @wheel = Wheel.new
  end

  def drive
    @engine.start
    @wheel.rotate
  end
end
  • The Car class is composed of Engine and Wheel. It doesn't inherit from these classes but instead has instances of them.
  • The Car class controls when the engine starts or the wheels rotate by invoking methods on these components, demonstrating how composition enables classes to delegate responsibilities to other classes.

As you can see, we can "compose" classes together for added behaviors without any complex hierarchies of objects.

Principle #4: Polymorphism

Polymorphism in object-oriented programming (OOP) refers to the ability of different classes to respond to the same method call in their own unique ways. This concept allows objects of different types to be treated as objects of a common superclass, where they can each implement the same methods in different ways but can be interacted with in a uniform manner. This feature is fundamental in enabling an object to adopt many forms and behaviors, simplifying code management and enhancing its modularity and extensibility.

This comes in several forms:

  • Method Overriding: Derived classes can have their own implementation of a method already defined in their base class. This is known as overriding.
  • Method Overloading: Some languages allow the same method name to be used for different methods in the same class, differentiated by their parameter lists (notably more common in statically typed languages like Java, but less relevant in Ruby due to its dynamic typing).
  • Interface Polymorphism: Different classes can implement the same interface or inherit from the same abstract class and provide different implementations for the interface methods.

Polymorphism Example

Remember the inheritance example above? In the example, both a Dog and a Cat share behavior called speak. Polymorphism says that we can override this common behavior within the subclasses:

class Animal
  def speak
    puts "Some generic sound"
  end
end

class Dog < Animal
  # We didn't override the method, so it "inherits" from the base class
end

class Cat < Animal
  # Here, we use "polymorphism" to override the base class method
  def speak
    puts "Meow!"
  end
end

How to Write OOP Code Effectively

This post has been a basic explainer of what object-oriented programming is via its principles, but knowing how to write it and model your systems effectively is an entirely different topic.

We have two tools at our disposal for writing better OOP code:

  1. DDD (Domain Driven Design)
  2. SOLID Principles

Domain-Driven Design: Building the Real World with Code

Domain-Driven Design (DDD) is a software development approach focused on modeling software closely to the underlying domain or business environment it aims to serve.

This methodology emphasizes deep understanding and collaboration between domain experts (those knowledgeable about the business or system requirements) and software developers to create highly functional and coherent software systems. Here’s how it plays a crucial role in designing better and faster object-oriented programming (OOP) systems:

  • Ubiquitous Language: Establishes a common language between developers and domain experts (business people, users, etc.) that is directly tied to the code. This shared language reduces misunderstandings and ensures the software accurately reflects business requirements. For example, if the system is a financial banking system, the developers might create an OOP class called Account which is then understood by all stakeholders as a "bank account".
  • Modularity: Through bounded contexts, DDD promotes a modular approach where different parts of the system are developed independently yet cohesively. This separation of concerns aligns with OOP principles of encapsulation and modularity, allowing teams to work more efficiently and with fewer dependencies.
  • Rich Domain Model: Encourages a rich domain model approach where the domain logic is encapsulated in the domain entities and value objects, which fits naturally with OOP. These objects not only hold data but also embody behaviors, enhancing the software's ability to manage complex behaviors and rules.
  • Scalability and Maintainability: The clear modular boundaries and well-defined interfaces encouraged by DDD result in a system that's easier to scale and maintain. Changes in one module are less likely to impact others, and new features can be added with minimal disruption.

SOLID Design Principles: Future-proofing your code

The SOLID principles are a set of guidelines designed to improve the maintainability, scalability, and extensibility of software development projects, particularly in object-oriented programming (OOP). These principles help developers reduce dependencies, decouple components, and manage complexity. Here's a brief overview of each principle and how they contribute to better OOP code:

Single Responsibility Principle (SRP)

  • Definition: A class should have only one reason to change, meaning it should have only one job or responsibility.
  • Benefit: This principle reduces the complexity of each class, makes the system easier to understand and maintain, and decreases the impact of changes.

Open/Closed Principle (OCP)

  • Definition: Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
  • Benefit: This allows existing code to support new functionality without being altered, which can prevent changes from introducing new bugs.

Liskov Substitution Principle (LSP)

  • Definition: Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
  • Benefit: This ensures that a subclass can stand in for a superclass without errors, leading to more robust code.

Interface Segregation Principle (ISP)

  • Definition: Clients should not be forced to depend upon interfaces that they do not use. This principle encourages creating smaller, more specific interfaces rather than large, general-purpose ones.
  • Benefit: It reduces the side effects of changes and enhances class customizability and reusability by segregating dependencies on used methods.

Dependency Inversion Principle (DIP)

  • Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces), not on concretions (e.g., classes).
  • Benefit: This promotes the decoupling of components, leading to more modular and maintainable code.

Impact on OOP Code

Implementing the SOLID principles helps developers write code that is:

  • More adaptable to change, as modifications to the software have minimal impact on existing code.
  • Easier to scale, as the system grows and new features are added.
  • Simpler to understand, as each component has a clear and focused role.
  • Less prone to bugs, as changes in some parts of the system do not affect others.