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.
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.
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 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 -= amountIn 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 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 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 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.
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.
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.
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.
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.
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.
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.
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.
Learn OOP by building small programs rather than memorising definitions alone. Try the following progression:
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.
The four commonly taught pillars are encapsulation, inheritance, polymorphism, and abstraction. Classes and objects are foundational concepts used to implement object-oriented designs.
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.
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.
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.
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.
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.