
Python Course
OOP Basics in Python
Everything in Python is an object — integers, strings, lists, functions. But until now you have been using objects that Python created for you. Object-Oriented Programming (OOP) is the practice of designing your own objects: custom types that bundle data and the functions that operate on it into a single, reusable unit called a class.
OOP is not just a style preference — it is the dominant paradigm in professional Python. Web frameworks, data libraries, and virtually every large codebase you will encounter are built around classes. This lesson covers the complete foundation: classes, instances, attributes, methods, the special dunder methods, and encapsulation.
Classes and Instances
A class is a blueprint — it defines the structure and behaviour of a type of object. An instance is a specific object built from that blueprint. You can create as many independent instances from one class as you need.
class Dog:
"""A simple class representing a dog."""
species = "Canis familiaris" # class attribute — shared by all dogs
def bark(self):
print("Woof!")
# Create two independent instances
rex = Dog()
luna = Dog()
rex.bark() # Woof!
luna.bark() # Woof!
print(type(rex)) # <class '__main__.Dog'>
print(isinstance(rex, Dog)) # True
print(rex.species) # Canis familiaris — shared class attribute- Class names use PascalCase by convention —
BankAccount, notbank_account. - Calling a class like a function (
Dog()) creates a new instance. selfis the first parameter of every instance method — it refers to the specific instance the method is called on.isinstance(obj, ClassName)checks whether an object is an instance of a class (also returnsTruefor subclasses).
The __init__ Method — Initialising Instances
__init__ is the initialiser — Python calls it automatically every time you create a new instance. It is where you set up the object's initial state by assigning instance attributes.
class Dog:
species = "Canis familiaris" # class attribute
def __init__(self, name, breed, age):
self.name = name # instance attributes — unique per object
self.breed = breed
self.age = age
def describe(self):
print(f"{self.name} is a {self.age}-year-old {self.breed}.")
def birthday(self):
self.age += 1
print(f"Happy birthday {self.name}! Now {self.age}.")
rex = Dog("Rex", "German Shepherd", 4)
luna = Dog("Luna", "Labrador", 2)
rex.describe() # Rex is a 4-year-old German Shepherd.
luna.describe() # Luna is a 2-year-old Labrador.
rex.birthday() # Happy birthday Rex! Now 5.
rex.birthday() # Happy birthday Rex! Now 6.
print(luna.age) # 2 — luna.age is completely independent of rex.age- Every attribute the instance should own must be assigned as
self.attribute = valuein__init__. - Attributes set in
__init__are available in every other method viaself. - Each instance holds its own copy — changing
rex.agenever affectsluna.age.
Instance Attributes vs Class Attributes
Attributes defined on self in __init__ belong to each instance individually. Attributes defined directly in the class body are class attributes — shared across all instances.
class BankAccount:
bank_name = "Dataplexa Bank" # class attribute — shared
interest_rate = 0.03 # class attribute
_account_count = 0 # class attribute used as counter
def __init__(self, owner, balance=0.0):
self.owner = owner # instance attribute
self.balance = balance # instance attribute
BankAccount._account_count += 1
def deposit(self, amount):
if amount > 0:
self.balance += amount
print(f"{self.owner}: +${amount:.2f} → balance ${self.balance:.2f}")
@classmethod
def total_accounts(cls):
return cls._account_count
acc1 = BankAccount("Alice", 500.00)
acc2 = BankAccount("Bob")
acc1.deposit(200)
print(acc2.balance) # 0.0 — independent
print(BankAccount.bank_name) # Dataplexa Bank
print(BankAccount.total_accounts()) # 2- Class attributes are ideal for constants, shared configuration, and counters.
- If you assign to
self.bank_nameon an instance, it creates a new instance attribute that shadows the class attribute for that instance only — the class attribute is unchanged.
Three Types of Methods
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def describe(self): # instance method
print(f"{self.celsius}°C / {self.to_fahrenheit():.1f}°F")
def to_fahrenheit(self):
return self.celsius * 9/5 + 32
@classmethod
def from_fahrenheit(cls, f): # class method — alternative constructor
return cls((f - 32) * 5/9)
@classmethod
def from_kelvin(cls, k):
return cls(k - 273.15)
@staticmethod
def is_freezing(celsius): # static method — utility, no self/cls
return celsius <= 0
t1 = Temperature(100)
t1.describe() # 100°C / 212.0°F
t2 = Temperature.from_fahrenheit(32)
t2.describe() # 0.0°C / 32.0°F
t3 = Temperature.from_kelvin(373.15)
t3.describe() # 100.0°C / 212.0°F
print(Temperature.is_freezing(-5)) # True
print(Temperature.is_freezing(20)) # False- Instance methods — take
self, operate on instance data. Most common. - Class methods —
@classmethod, takecls. Used as alternative constructors or to work with class-level data. - Static methods —
@staticmethod. Noselforcls. Pure utility functions namespaced inside the class.
The __str__ and __repr__ Methods
By default, printing an object shows something like <__main__.Dog object at 0x...>. Implementing __str__ and __repr__ gives your objects informative, readable string representations.
class Product:
def __init__(self, name, price, stock=0):
self.name = name
self.price = price
self.stock = stock
def __str__(self):
return f"{self.name} — ${self.price:.2f}" # human-friendly
def __repr__(self):
return f"Product(name={self.name!r}, price={self.price}, stock={self.stock})"
def __len__(self):
return self.stock # len(product) returns stock count
def __bool__(self):
return self.stock > 0 # in stock = True
p1 = Product("Notebook", 4.99, 50)
p2 = Product("Desk", 89.99, 0)
print(p1) # Notebook — $4.99 (calls __str__)
print(repr(p1)) # Product(name='Notebook', price=4.99, stock=50)
print(len(p1)) # 50
print(bool(p1)) # True — in stock
print(bool(p2)) # False — out of stock
items = [p1, p2]
print(items) # list uses __repr__ for each item- If only
__repr__is defined, it is used as a fallback for bothprint()andrepr(). - Always implement at least
__repr__— it makes debugging dramatically easier. __len__,__bool__,__eq__,__lt__etc. are dunder methods — they let your objects work with Python's built-in operators and functions.
Dunder Methods — Making Objects Feel Native
class Vector:
"""2D vector that supports +, -, *, ==, and abs()."""
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __add__(self, other): # v1 + v2
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other): # v1 - v2
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar): # v * 3
return Vector(self.x * scalar, self.y * scalar)
def __abs__(self): # abs(v) — magnitude
return (self.x**2 + self.y**2) ** 0.5
def __eq__(self, other): # v1 == v2
return self.x == other.x and self.y == other.y
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v2 - v1) # Vector(2, 2)
print(v1 * 3) # Vector(3, 6)
print(abs(v2)) # 5.0 — magnitude of (3,4) = 5
print(v1 == Vector(1, 2)) # TrueEncapsulation — Public, Protected, Private
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # public — access freely
self._log = [] # protected — internal use, handle with care
self.__balance = balance # private — name-mangled
def deposit(self, amount):
if amount > 0:
self.__balance += amount
self._log.append(f"+${amount:.2f}")
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
self._log.append(f"-${amount:.2f}")
else:
print("Insufficient funds or invalid amount")
def get_balance(self):
return self.__balance
def statement(self):
print(f"Account: {self.owner}")
for entry in self._log:
print(f" {entry}")
print(f" Balance: ${self.__balance:.2f}")
acc = BankAccount("Alice", 200.00)
acc.deposit(100)
acc.withdraw(50)
acc.withdraw(500) # Insufficient funds
acc.statement()
# __balance is name-mangled but still reachable if you know the name
print(acc._BankAccount__balance) # 250.0- Single underscore
_name— convention for "internal use". Python does not enforce it. - Double underscore
__name— Python renames it to_ClassName__name(name mangling) making it harder to access accidentally from outside. - The Pythonic philosophy: trust naming conventions rather than enforcing hard barriers — "we're all adults here".
Quick Reference Table
| Concept | What It Is | Key Syntax |
|---|---|---|
| Class | Blueprint for creating objects | class Name: |
| Instance | A specific object built from a class | obj = ClassName() |
__init__ | Initialises instance attributes | def __init__(self, ...): |
| Instance method | Operates on instance data | def method(self): |
| Class method | Operates on the class itself | @classmethod def m(cls): |
| Static method | Utility function namespaced in class | @staticmethod def m(): |
__str__ | Human-readable string (print) | def __str__(self): |
__repr__ | Developer-facing string (repr/REPL) | def __repr__(self): |
| Public | Accessible anywhere | self.name |
| Protected | Internal use by convention | self._name |
| Private | Name-mangled, harder to access externally | self.__name |
Practice
What naming convention do Python class names follow?
What special method does Python call automatically when a new instance is created?
What is the difference between a class attribute and an instance attribute?
Which special method is called when you use print() on an object?
What prefix signals that an attribute is intended for internal use only (protected)?
Which dunder method enables the + operator between two instances?
Quick Quiz
What does self refer to inside a method?
Which decorator is used to define a class method?
What happens when you define an attribute with a double underscore prefix like self.__balance?
If __str__ is not defined but __repr__ is, what does print(obj) use?
What is the main advantage of using a class method as an alternative constructor?
To support v1 + v2 between two custom objects, which method do you implement?