Object-Oriented Programming in Python: Classes, Objects and Inheritance
What is object-oriented programming in Python?
Object-oriented programming (OOP) in Python is a way of organising code around objects that bundle data (attributes) with the functions that act on it (methods). You define a blueprint with the class keyword and create as many independent instances from it as you need. Inheritance, properties and special dunder methods let your objects share behaviour and work naturally with built-in Python features.
Classes, instances, attributes and methods
- Class: a blueprint that defines what data an object holds and what it can do.
- Instance (object): one concrete thing built from a class, with its own data.
- Attribute: a value stored on an object or class, read with dot notation such as
book.title. - Method: a function defined inside a class. Its first parameter,
self, is the instance it was called on.
OOP builds on the variables and functions covered in Python basics for beginners. If you have used classes in PHP, the ideas are the same, though Python passes self explicitly instead of using $this.
How __init__, self and class attributes work
When you call a class like a function, Python creates a new object and runs __init__ on it. Attributes assigned to self belong to that one instance. Attributes assigned in the class body are class attributes, shared by every instance.
class Book:
loan_days = 14 # class attribute, shared
def __init__(self, title, author):
self.title = title # instance attributes
self.author = author
self.on_loan = False
def lend(self):
if self.on_loan:
raise ValueError(f"{self.title} is already on loan")
self.on_loan = True
b1 = Book("Godan", "Premchand")
b2 = Book("Malgudi Days", "R. K. Narayan")
b1.lend()
print(b1.on_loan, b2.on_loan) # True False
print(Book.loan_days, b2.loan_days) # 14 14b1.lend() is shorthand for Book.lend(b1), which is why every method needs self. Keep mutable values such as lists out of class attributes unless you really want every instance to share them.
Properties, class methods and static methods
class Temperature:
def __init__(self, celsius):
self.celsius = celsius # goes through the setter below
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
@classmethod
def from_fahrenheit(cls, f):
return cls((f - 32) * 5 / 9)
@staticmethod
def is_fever(celsius):
return celsius >= 38.0
t = Temperature.from_fahrenheit(98.6)
print(round(t.celsius, 1)) # 37.0
print(Temperature.is_fever(38.5)) # True@propertymakes a method readable like an attribute and lets a setter validate new values, without changing how callers use the object.@classmethodreceives the class ascls. It is the usual way to write alternative constructors such asfrom_fahrenheit.@staticmethodreceives neither the instance nor the class. Use it for a helper that belongs with the class logically but needs no object state.
Inheritance, super(), overriding and dunder methods
A subclass inherits everything from its parent and can override methods with its own version. super() calls the parent's implementation, most often inside __init__. Dunder (double underscore) methods hook your objects into built-in behaviour.
import math
class Shape:
def __init__(self, name):
self.name = name
def area(self):
raise NotImplementedError
def __str__(self):
return f"{self.name} with area {self.area():.2f}"
class Rectangle(Shape):
def __init__(self, width, height):
super().__init__("Rectangle")
self.width = width
self.height = height
def area(self): # overrides Shape.area
return self.width * self.height
def __repr__(self):
return f"Rectangle(width={self.width}, height={self.height})"
def __eq__(self, other):
if not isinstance(other, Rectangle):
return NotImplemented
return (self.width, self.height) == (other.width, other.height)
class Circle(Shape):
def __init__(self, radius):
super().__init__("Circle")
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
for shape in [Rectangle(3, 4), Circle(1)]:
print(shape) # uses __str__
print(repr(Rectangle(3, 4))) # Rectangle(width=3, height=4)
print(Rectangle(3, 4) == Rectangle(3, 4)) # True__str__ gives a friendly description for print(); __repr__ gives an unambiguous one for developers, ideally looking like code that recreates the object. Defining __eq__ makes instances unhashable unless you also define __hash__, so they cannot be used as dict keys or set members by default.
Dataclasses, and composition versus inheritance
For classes that mainly hold data, the dataclasses module writes __init__, __repr__ and __eq__ for you from type-annotated fields.
from dataclasses import dataclass, field
@dataclass
class Member:
name: str
member_id: int
borrowed: list[str] = field(default_factory=list)
@dataclass
class Library:
members: dict[int, Member] = field(default_factory=dict)
def join(self, member):
self.members[member.member_id] = member
lib = Library()
lib.join(Member("Asha", 101))
print(lib.members[101]) # Member(name='Asha', member_id=101, borrowed=[])Library has members rather than being a kind of member. That is composition: one object holds others and delegates work to them. Use inheritance only for a genuine "is a" relationship, such as a circle being a shape; prefer composition when you just want to reuse behaviour, because deep inheritance chains are hard to change.
Designing a class step by step
- Write down the nouns in your problem; each important one is a candidate class, such as Account or Customer.
- List the data each one must hold, and decide which values belong to each instance and which are shared.
- List the actions as verbs; these become methods, such as
depositandwithdraw. - Protect rules inside the class: validate in
__init__, setters and methods so an object can never reach an invalid state. - Add
__repr__early so debugging output is readable. - Only then look for shared behaviour that justifies a parent class, and test each class on its own before combining them.
This planning step mirrors the design phase of the software development life cycle, just on a smaller scale.
Practical exercise: model a bank account
class InsufficientFunds(Exception):
pass
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = 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:
raise ValueError("Withdrawal must be positive")
if amount > self._balance:
raise InsufficientFunds(f"Balance is only {self._balance}")
self._balance -= amount
def __repr__(self):
return f"BankAccount(owner={self.owner!r}, balance={self._balance})"
acct = BankAccount("Kiran", 5000)
acct.deposit(1500)
acct.withdraw(2000)
print(acct.balance) # 4500
print(acct) # BankAccount(owner='Kiran', balance=4500)- Add a
SavingsAccountsubclass that takes an interest rate, callssuper().__init__(), and has anadd_interest()method. - Add a
Bankclass that holds accounts in a dict by account number and offerstransfer(from_no, to_no, amount). - Done looks like this: a transfer larger than the balance raises
InsufficientFundsand leaves both balances unchanged, andacct.balance = 0raisesAttributeErrorbecause the property has no setter.
For real money, store amounts as whole paise in an int or use decimal.Decimal, because floats cannot represent most decimal fractions exactly. If you would like to practise these ideas in another language, the PHP Foundation course teaches the same fundamentals in PHP.
Common mistakes
- Forgetting
selfas the first method parameter, which causes aTypeErrorabout the number of arguments. - Storing a list or dict as a class attribute and then being surprised when every instance changes together.
- Overriding
__init__in a subclass without callingsuper().__init__(), so the parent's attributes are never set. - Building deep inheritance hierarchies to share a little code when composition would be simpler.
- Writing getter and setter methods for every attribute instead of using plain attributes and adding
@propertyonly when validation is needed.
Frequently asked questions
Does Python have private attributes?
Not enforced ones. A single leading underscore, as in _balance, is a convention meaning internal use. A double underscore triggers name mangling, which makes accidental access harder but does not prevent it.
What is the difference between __str__ and __repr__?
__str__ is for end users and is used by print() and str(). __repr__ is for developers and is used in the REPL and inside containers. If only __repr__ exists, Python uses it for both.
Can a Python class inherit from more than one class?
Yes, Python supports multiple inheritance and resolves methods using the method resolution order, which you can inspect with ClassName.__mro__. Use it sparingly, typically for small mixin classes.