Desire In TECH

OOP Fundamentals: The Four Principles of Object-Oriented Programming

Written by Desire E | Aug 11, 2026, 6:33:36 PM

Object-oriented programming (OOP) is a way to organise code around objects that combine data with the operations that work on it. This guide explains classes, objects, the four commonly taught OOP principles, and how to apply them without making code unnecessarily complicated.

Table of Contents

  1. 1. What Is Object-Oriented Programming?
  2. 2. Classes and Objects
  3. 3. Encapsulation
  4. 4. Inheritance
  5. 5. Polymorphism
  6. 6. Abstraction
  7. 7. A Practical OOP Example
  8. 8. Object Relationships and Composition
  9. 9. SOLID and Other Good Design Habits
  10. 10. Advantages and Limitations of OOP
  11. 11. When Should You Use OOP?
  12. 12. Common Mistakes to Avoid
  13. 13. A Beginner's Practice Roadmap
  14. 14. Frequently Asked Questions
  15. 15. Further Reading

What Is Object-Oriented Programming?

Object-oriented programming is a programming paradigm in which software is modelled as interacting objects. An object holds state—data describing what it is or what it knows—and exposes behaviour through methods. For example, a shopping application might represent a product with a name and price, and provide methods for calculating a discounted price or checking whether it is in stock.

OOP is supported by languages such as Java, C#, C++, Python, Ruby, and PHP, although each language implements the ideas differently. Some languages encourage class-based OOP, while JavaScript uses prototypes and can support object-oriented patterns without requiring traditional classes. OOP is one approach among several: procedural programming organises logic around procedures, while functional programming emphasises functions and data transformations. Real-world projects often combine these styles.

The main goal of OOP is not to make every piece of code a class. It is to create clear boundaries around data and behaviour so that a program can be understood, tested, extended, and changed with less risk.

Classes and Objects

A class is a definition or blueprint for a kind of object. It describes the data an instance may hold and the methods it can use. An object is a particular instance created from that class. If a class is a recipe, an object is one dish made from it: many objects can follow the same definition while holding different values.

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def bark(self):
        return f"{self.name} says woof!"

pet = Dog("Milo", 3)
print(pet.bark())

In this Python example, Dog is the class. name and age are instance attributes, bark() is a method, and pet refers to one object. A second object can have a different name and age while using the same method definition. Python's __init__ method initialises a new instance; self refers to the current instance.

Classes may also define class attributes or class methods that relate to the class as a whole. Use instance attributes for data that differs from object to object, and shared class-level data only when sharing is genuinely intended. Be careful with mutable class attributes: a shared list, for example, can accidentally be shared by every instance.

Encapsulation

Encapsulation groups related state and behaviour together and controls how an object's internal state can be accessed or changed. Instead of allowing any part of a program to change data in arbitrary ways, an object exposes a small, understandable interface that can validate operations and protect its rules.

Consider a bank account. The balance should not be changed by adding or subtracting numbers from anywhere in the application. A deposit method can reject invalid amounts, while a withdrawal method can check that the requested amount is positive and that the account has enough available funds.

class BankAccount:
    def __init__(self, opening_balance=0):
        if opening_balance < 0:
            raise ValueError("Opening balance cannot be negative")
        self._balance = opening_balance

    @property
    def balance(self):
        return self._balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self._balance += amount

    def withdraw(self, amount):
        if amount <= 0 or amount > self._balance:
            raise ValueError("Invalid withdrawal")
        self._balance -= amount

In Python, a leading underscore signals that an attribute is intended for internal use; it does not provide strict privacy. Other languages offer access modifiers such as private and protected. The broader principle is to make valid operations easy and invalid states harder to create. Encapsulation also lets implementation details change while callers continue using the same public interface.

Inheritance

Inheritance allows a class to derive behaviour and data from another class. The existing class is often called the base or parent class, and the derived class the child or subclass. Inheritance can be useful when the child is genuinely a specialised form of the parent and can honour the parent class's behavioural promises.

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "Some sound"

class Cat(Animal):
    def speak(self):
        return "Meow"

class Cow(Animal):
    def speak(self):
        return "Moo"

Both Cat and Cow inherit the shared name setup from Animal and provide their own implementation of speak(). This avoids duplicating the same setup in every subclass. Inheritance should model a meaningful “is-a” relationship; a Car is a kind of Vehicle, but an Engine is not a kind of car.

Deep inheritance trees can become difficult to follow. A change in a base class may affect many descendants, and subclasses can become tightly coupled to implementation details. Prefer a small hierarchy, and consider composition when one object simply needs to use another object's capabilities.

Polymorphism

Polymorphism means that code can work with different object types through a common interface while each type provides its own behaviour. The caller asks an object to perform an operation without needing to know every detail of how that object implements it.

animals = [Cat("Luna"), Cow("Daisy"), Animal("Unknown")]

for animal in animals:
    print(animal.name, animal.speak())

The loop calls speak() for each object. Each object's implementation determines the result, so the loop does not need a separate conditional for cats, cows, and other animals. This makes it easier to add a new type without rewriting every caller, provided it follows the expected interface.

Languages implement polymorphism in different ways, including method overriding, interfaces, abstract base classes, generics, and—in dynamically typed languages—duck typing. Duck typing focuses on whether an object supports the required operations rather than its declared class. Whatever mechanism is used, clear contracts and predictable behaviour matter more than the label.

Abstraction

Abstraction exposes the essential operations of a component while hiding details that callers do not need. A driver can use a car's steering wheel without knowing every internal step in the steering mechanism. In software, an application can call a payment interface without knowing the network protocol or provider-specific code used behind it.

An abstract class or interface can define what operations a family of implementations must support. For example, a notification service might provide a send(message, recipient) operation, while email, SMS, and push-notification implementations handle the details differently. In Python, an informal interface can be enough; other languages provide explicit interface or abstract-class constructs.

Good abstraction reduces the amount of detail a developer must keep in mind at once. Poor abstraction does the opposite: it introduces layers, names, and indirection that make a simple task harder. The right abstraction reflects a stable concept in the problem and earns its complexity by simplifying the code that uses it.

A Practical OOP Example

Imagine an online store that needs to calculate prices for different products. Start with a shared product interface, then represent different pricing rules in separate classes. The example below is deliberately small; a real system would also validate currency, quantities, taxes, discounts, and rounding rules.

class Product:
    def __init__(self, name, price):
        if price < 0:
            raise ValueError("Price cannot be negative")
        self.name = name
        self.price = price

    def final_price(self):
        return self.price

class SaleProduct(Product):
    def __init__(self, name, price, discount_rate):
        super().__init__(name, price)
        if not 0 <= discount_rate <= 1:
            raise ValueError("Discount must be between 0 and 1")
        self.discount_rate = discount_rate

    def final_price(self):
        return self.price * (1 - self.discount_rate)

items = [Product("Notebook", 5), SaleProduct("Bag", 40, 0.25)]
for item in items:
    print(item.name, item.final_price())

This example brings several ideas together. Classes define products, instances hold each product's values, validation protects the data, inheritance reuses initialisation, and polymorphism lets the same loop call final_price() on different product types. The base method provides default behaviour, while the sale product overrides it.

The example also raises a design question: should sale pricing be represented by inheritance at all? If products can have many independent features—sale price, membership discount, regional tax, and coupons—composition or a separate pricing strategy may be clearer. OOP is about making good boundaries, not forcing every new rule into another subclass.

Object Relationships and Composition

OOP systems often model relationships between objects. Association means two objects know about or collaborate with each other. Aggregation describes a whole-part relationship in which parts can exist independently. Composition is a stronger ownership relationship in which a whole controls the lifecycle or use of its parts, depending on the language and design.

For example, a team may refer to employees who also exist independently of that team. A house may contain rooms that are modelled as parts of the house. These labels are design concepts rather than universal runtime features, so use them to communicate intent instead of getting stuck on terminology.

Composition is frequently a flexible alternative to inheritance. A report generator can contain a formatter object; swapping the formatter changes output without creating a large family of report-generator subclasses. This is the idea behind “favour composition over inheritance”: share capabilities by delegating work to collaborating objects when a strict subtype relationship is not required.

SOLID and Other Good Design Habits

The four pillars describe core OOP concepts, but they do not automatically produce maintainable software. SOLID is a widely used set of object-oriented design principles that helps developers reason about responsibilities and change. Treat the principles as guidance, not rigid rules to apply without context.

  • Single Responsibility Principle: give a class one coherent reason to change. A class that validates orders, sends emails, generates reports, and writes database records may have too many responsibilities.
  • Open/Closed Principle: design stable parts of a system so new behaviour can often be added without repeatedly modifying proven code.
  • Liskov Substitution Principle: a subtype should honour the behavioural expectations of its base type. It should not unexpectedly weaken guarantees or introduce surprising failures.
  • Interface Segregation Principle: prefer small, focused interfaces over forcing callers to depend on methods they do not use.
  • Dependency Inversion Principle: high-level business logic should depend on abstractions rather than being tightly bound to low-level implementation details.

Other useful habits include keeping methods focused, choosing descriptive names, avoiding duplicated business rules, limiting hidden side effects, and writing tests around public behaviour. Do not create an interface for every class automatically; introduce one when it provides a useful boundary, alternative implementation, or test seam.

Advantages and Limitations of OOP

Benefits

  • Organisation: related data and behaviour can live behind a coherent interface.
  • Reusability: shared implementations and composition can reduce duplicated logic.
  • Maintainability: encapsulated internals can change without requiring every caller to change.
  • Extensibility: polymorphism and well-defined contracts can make it easier to add implementations.
  • Testability: components with clear responsibilities and dependencies can be tested in isolation.

Trade-offs

  • Extra structure: classes and interfaces can be excessive for a short script or simple data transformation.
  • Hidden complexity: inheritance and implicit state can make behaviour hard to trace.
  • Tight coupling: subclasses may depend on fragile assumptions in base classes.
  • Over-abstraction: too many layers can slow development and make code harder for beginners to navigate.
  • Paradigm mismatch: some data-heavy or pipeline-oriented tasks may be simpler with functions and immutable data.

The best design is the simplest one that meets the current requirements and can evolve at a reasonable cost. OOP can help, but the quality of the design depends on the boundaries, contracts, and decisions made by the developer.

When Should You Use OOP?

OOP is often useful when a system has entities with identity, state, behaviour, and rules that need to remain consistent. Examples include orders and payments in commerce, users and permissions in business software, game characters and inventory, or devices and drivers in an application. It is also useful when several implementations should fulfil a common contract.

Procedural or functional approaches may be simpler for scripts, data processing, small utilities, and transformations that do not need long-lived objects. A project can combine styles: use objects to represent domain concepts and manage dependencies, and use small pure functions for calculations and transformations. Choose the approach that makes the important logic easiest to read and verify.

Common Mistakes to Avoid

  • Making every noun a class even when it has no meaningful state, behaviour, or identity.
  • Using inheritance only to reuse a few lines of code when composition would be clearer.
  • Making all attributes publicly mutable and relying on every caller to preserve business rules.
  • Creating classes with too many unrelated responsibilities.
  • Building elaborate abstractions before there is a real need for multiple implementations.
  • Confusing encapsulation with secrecy; the aim is controlled access and stable behaviour.
  • Testing only individual methods while ignoring interactions, invalid inputs, and object invariants.

When a design becomes difficult to explain, step back and identify what each object is responsible for, which rules it protects, and what other code needs to know about it. A small refactor toward clearer responsibilities is usually more valuable than introducing another design pattern simply because it is popular.

A Beginner's Practice Roadmap

Learn OOP by building small programs rather than memorising definitions alone. Try the following progression:

  1. Create a class with two attributes and one method, then instantiate several objects with different values.
  2. Add validation so the object cannot be created or changed into an invalid state.
  3. Create a base class and two subclasses that implement a shared method differently.
  4. Write a function that accepts objects through a common interface instead of checking each concrete type.
  5. Replace an inheritance relationship with composition and compare which design is easier to understand.
  6. Write unit tests for normal behaviour, invalid inputs, and important edge cases.
  7. Refactor a small project so each class has one clear responsibility, then explain why each class exists.

Use a language you already know, and consult its official documentation for syntax and conventions. Python is approachable for short examples; Java and C# make class, interface, and access-modifier concepts explicit. After learning the basics, practise reading other people's code and explaining object responsibilities in plain language.

Frequently Asked Questions

What are the four pillars of OOP?

The four commonly taught pillars are encapsulation, inheritance, polymorphism, and abstraction. Classes and objects are foundational concepts used to implement object-oriented designs.

What is the difference between a class and an object?

A class defines a type's structure and behaviour. An object is a specific instance of that type, with its own identity and instance data.

Is OOP better than procedural programming?

Neither is universally better. OOP can organise stateful domains and interchangeable implementations, while procedural or functional styles can be simpler for straightforward sequences and data transformations. Many systems benefit from a mix.

Can Python be used for OOP?

Yes. Python supports classes, inheritance, method overriding, properties, and other object-oriented features. It also supports procedural and functional styles, so developers can choose the most suitable approach for each task.

What is the difference between abstraction and encapsulation?

Abstraction focuses on exposing the essential operations while hiding unnecessary complexity. Encapsulation groups data and behaviour together and controls how internal state is accessed or modified. They work together but solve different design problems.

Do I need to use inheritance to practise OOP?

No. You can write object-oriented code using objects, encapsulation, and composition without creating an inheritance hierarchy. Use inheritance when a genuine subtype relationship makes the design clearer.

Further Reading