What is Object-Oriented Programming?
In procedural programming you write functions that operate on data passed to them. In OOP you bundle the data and the functions that operate on it into a single unit called an object. Objects are created from a blueprint called a class.
Think of it this way:
- A class is like an architectural blueprint for a house.
- An object (instance) is an actual house built from that blueprint.
- You can build many houses from one blueprint, each with different colours, sizes, and occupants.
Encapsulation
Bundle data and methods together. Hide internal details; expose a clean interface.
Reusability
Define a class once; create as many objects as you need. Subclasses inherit and extend behavior.
Maintainability
Fix a bug in a class and every object of that class benefits. Changes are localized.
Modelling Reality
Code mirrors real-world entities: BankAccount, Car, Student β making intent clear.
Defining a Class
Use the class keyword followed by the class name (by convention in PascalCase) and a colon. The class body is indented.
# Minimal class
class Dog:
pass # 'pass' is a placeholder for an empty body
# Creating an object (instance) of the class
my_dog = Dog()
print(type(my_dog)) #
print(my_dog) # <__main__.Dog object at 0x...>
The __init__ Method (Constructor)
__init__ is a special magic method (also called a dunder method β short for "double underscore"). It is automatically called whenever a new object is created from the class. Use it to set up the initial state of each object.
class Dog:
def __init__(self, name, breed, age):
# These are INSTANCE ATTRIBUTES
self.name = name
self.breed = breed
self.age = age
# Create instances β __init__ is called automatically
dog1 = Dog("Rex", "Labrador", 3)
dog2 = Dog("Bella", "Poodle", 5)
# Access attributes using dot notation
print(dog1.name) # Rex
print(dog2.breed) # Poodle
print(dog1.age) # 3
# Each object has its OWN copy of instance attributes
print(dog1.name == dog2.name) # False - different objects
self is a reference to the current object (the instance being created or used). When you call dog1.bark(), Python automatically passes dog1 as the first argument β that's self. The name "self" is a convention, not a keyword, but you should always use it.
Instance Methods
Methods are functions defined inside a class. The first parameter is always self, giving them access to the object's attributes.
class Dog:
def __init__(self, name, breed, age):
self.name = name
self.breed = breed
self.age = age
def bark(self):
return f"{self.name} says: Woof!"
def describe(self):
return f"{self.name} is a {self.age}-year-old {self.breed}."
def have_birthday(self):
self.age += 1
print(f"Happy Birthday {self.name}! Now {self.age} years old.")
# Create objects and call methods
rex = Dog("Rex", "Labrador", 3)
print(rex.bark())
print(rex.describe())
rex.have_birthday()
print(rex.describe()) # age is now 4
Instance Attributes vs Class Attributes
Instance attributes are unique to each object (set via self in __init__). Class attributes are shared among all instances β they're defined directly inside the class body, outside any method.
class Dog:
# CLASS attribute β shared by ALL instances
species = "Canis lupus familiaris"
total_dogs = 0 # count all dogs ever created
def __init__(self, name, breed):
# INSTANCE attributes β unique to each object
self.name = name
self.breed = breed
Dog.total_dogs += 1 # update class attribute
def info(self):
return f"{self.name} ({self.breed}) | Species: {self.species}"
d1 = Dog("Rex", "Labrador")
d2 = Dog("Bella", "Poodle")
d3 = Dog("Max", "Beagle")
print(d1.info())
print(d2.info())
# Access class attribute via class or any instance
print(f"Species: {Dog.species}")
print(f"Total dogs created: {Dog.total_dogs}") # 3
# Instance attribute shadows class attribute if set on the instance
d1.species = "Modified" # creates a local instance attribute
print(d1.species) # Modified (local copy)
print(d2.species) # Canis lupus familiaris (class attribute unchanged)
__str__ and __repr__ Magic Methods
__str__ controls what print(obj) displays β a human-friendly string. __repr__ controls the developer representation shown in the REPL and debugging. If only __repr__ is defined, print() falls back to it.
class Book:
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self.pages = pages
def __str__(self):
"""Human-friendly string: used by print()"""
return f'"{self.title}" by {self.author} ({self.year})'
def __repr__(self):
"""Developer/debug representation"""
return (f"Book(title={self.title!r}, author={self.author!r}, "
f"year={self.year}, pages={self.pages})")
book = Book("Clean Code", "Robert Martin", 2008, 431)
print(book) # uses __str__
print(repr(book)) # uses __repr__
# In a list, __repr__ is used:
library = [book, Book("The Pragmatic Programmer", "Hunt & Thomas", 1999, 352)]
print(library)
Real-World Example: BankAccount
class BankAccount:
"""A simple bank account with deposit and withdrawal support."""
bank_name = "PyBank" # class attribute
def __init__(self, owner, account_number, initial_balance=0):
self.owner = owner
self.account_number = account_number
self._balance = initial_balance # _ = convention for "private"
self._transactions = []
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive.")
self._balance += amount
self._transactions.append(("deposit", amount))
print(f" Deposited ${amount:.2f}. New balance: ${self._balance:.2f}")
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive.")
if amount > self._balance:
print(f" Insufficient funds. Balance: ${self._balance:.2f}")
return False
self._balance -= amount
self._transactions.append(("withdrawal", amount))
print(f" Withdrew ${amount:.2f}. New balance: ${self._balance:.2f}")
return True
def get_balance(self):
return self._balance
def statement(self):
print(f"\n {self.bank_name} Statement β {self.owner} ({self.account_number})")
print(f" {'='*45}")
for t_type, amount in self._transactions:
symbol = "+" if t_type == "deposit" else "-"
print(f" {symbol} ${amount:.2f} ({t_type})")
print(f" {'='*45}")
print(f" Current Balance: ${self._balance:.2f}\n")
def __str__(self):
return f"BankAccount({self.owner}, ${self._balance:.2f})"
# Use the class
acc = BankAccount("Alice", "ACC-1001", initial_balance=1000)
acc.deposit(500)
acc.withdraw(200)
acc.withdraw(2000) # should fail
acc.deposit(100)
acc.statement()
Real-World Example: Car
class Car:
"""Represents a car with fuel management."""
def __init__(self, make, model, year, fuel_capacity=60):
self.make = make
self.model = model
self.year = year
self.fuel_capacity = fuel_capacity # litres
self._fuel_level = 0
self._odometer = 0
def fuel_up(self, litres=None):
"""Fill to specified amount, or top up completely."""
if litres is None:
litres = self.fuel_capacity - self._fuel_level
added = min(litres, self.fuel_capacity - self._fuel_level)
self._fuel_level += added
print(f" Added {added:.1f}L. Fuel: {self._fuel_level:.1f}/{self.fuel_capacity}L")
def drive(self, km, fuel_per_100km=8):
"""Drive a number of km, consuming fuel."""
fuel_needed = (km / 100) * fuel_per_100km
if fuel_needed > self._fuel_level:
possible_km = (self._fuel_level / fuel_per_100km) * 100
print(f" Not enough fuel! Can drive ~{possible_km:.0f} km.")
return
self._fuel_level -= fuel_needed
self._odometer += km
print(f" Drove {km}km. Odometer: {self._odometer}km. Fuel left: {self._fuel_level:.1f}L")
def __str__(self):
return f"{self.year} {self.make} {self.model}"
def __repr__(self):
return f"Car('{self.make}', '{self.model}', {self.year})"
my_car = Car("Toyota", "Camry", 2022)
print(my_car)
my_car.fuel_up(50)
my_car.drive(200)
my_car.drive(400) # not enough fuel
Real-World Example: Student
class Student:
"""Represents a university student with grade tracking."""
def __init__(self, name, student_id, major):
self.name = name
self.student_id = student_id
self.major = major
self._grades = {} # {subject: grade}
def add_grade(self, subject, grade):
if not (0 <= grade <= 100):
raise ValueError("Grade must be between 0 and 100.")
self._grades[subject] = grade
print(f" Added {subject}: {grade}")
def gpa(self):
if not self._grades:
return 0.0
return sum(self._grades.values()) / len(self._grades)
def letter_grade(self, score):
if score >= 90: return "A"
if score >= 80: return "B"
if score >= 70: return "C"
if score >= 60: return "D"
return "F"
def transcript(self):
print(f"\n Transcript β {self.name} ({self.student_id})")
print(f" Major: {self.major}")
print(f" {'-'*35}")
for subject, grade in self._grades.items():
print(f" {subject:<20}: {grade:>3} ({self.letter_grade(grade)})")
print(f" {'-'*35}")
print(f" GPA: {self.gpa():.1f} ({self.letter_grade(self.gpa())})\n")
def __str__(self):
return f"Student({self.name}, {self.student_id})"
alice = Student("Alice Nguyen", "S20230042", "Computer Science")
alice.add_grade("Python Programming", 95)
alice.add_grade("Data Structures", 88)
alice.add_grade("Mathematics", 92)
alice.add_grade("English", 79)
alice.transcript()
Attributes prefixed with a single underscore (_balance, _grades) signal "internal/private β don't access directly from outside." Python doesn't enforce this, but it's a widely respected convention. Double underscore (__attr) triggers name mangling and provides slightly stronger protection.
ποΈ Practical Exercise
- Create a
Rectangleclass withwidthandheightattributes. Add methodsarea(),perimeter(), andis_square(). Implement__str__to return something like"Rectangle(4 x 6)". - Create a
Counterclass with a class attributeinstances_created = 0that tracks how many Counter objects have ever been instantiated. Each Counter has an instance attributecount = 0, and methodsincrement(),decrement(),reset(), andvalue(). - Build a
Temperatureclass that stores temperature in Celsius. Add methodsto_fahrenheit(),to_kelvin(), and a__str__that shows the value in all three units.
π₯ Challenge
Design a Library Management System using two classes: Book (title, author, isbn, copies_available) and Library (name, collection of books). The Library class should have methods: add_book(book), checkout(isbn) (reduce copies by 1; raise error if unavailable), return_book(isbn) (increase copies by 1), search(query) (return books where title or author contains the query), and catalog() (print all books with availability). Demonstrate checking out and returning books, and searching the catalog.
Interview Questions on Python Classes & OOP
- What is the difference between a class and an object (instance)?
- What is
__init__and when is it called? - What is
self? Why must it be the first parameter of instance methods? - What is the difference between instance attributes and class attributes?
- What are
__str__and__repr__? How do they differ? - What do the four pillars of OOP stand for: Encapsulation, Abstraction, Inheritance, Polymorphism?
- What does the single underscore prefix (
_attr) signal? What about double underscore (__attr)? - How does Python's class-based OOP differ from classical OOP languages like Java?
π Summary
- A class is a blueprint; an object (instance) is a concrete entity built from that blueprint.
- Define classes with
class ClassName:using PascalCase naming. __init__(self, ...)is the constructor β it initialises instance attributes when an object is created.selfrefers to the current object; it must be the first parameter of every instance method.- Instance attributes (
self.x) are unique to each object; class attributes are shared by all instances. - Methods are functions defined inside a class that operate on
self. __str__controlsprint(obj)output (human-readable);__repr__provides a developer/debug representation.- OOP enables encapsulation, reusability, and modelling of real-world entities β making large programs easier to manage.
Related Topics
Frequently Asked Questions
Yes. If you don't define __init__, Python uses the default inherited from object (every class implicitly inherits from object). The resulting instances have no instance attributes until you set them manually with obj.attr = value. In practice, almost all real classes define __init__ to set up a predictable starting state.
A function is defined with def at the module level. A method is a function defined inside a class. The key difference is that an instance method automatically receives the object (self) as its first argument. Technically in Python, methods are just functions that belong to a class and are called via an object.
Python raises a TypeError when you call the method. For example, if you define def bark(): inside a class and call dog.bark(), Python passes the dog instance as the first positional argument, but the function has no parameter to receive it β hence the error: "bark() takes 0 positional arguments but 1 was given."
Instance methods receive self (the object). Class methods (decorated with @classmethod) receive cls (the class itself) β useful for alternative constructors. Static methods (decorated with @staticmethod) receive neither self nor cls β they're utility functions logically grouped inside the class but not needing access to the class or instance.
If you name an attribute with two leading underscores (e.g., self.__secret), Python renames it internally to _ClassName__secret. This makes it harder (but not impossible) to accidentally access or override from outside or from subclasses. It's a stronger hint than a single underscore that the attribute is truly internal. Access it externally with obj._ClassName__secret if you must.