Python Course
OOP Basics
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 that data together 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, and the special methods that make your objects feel like native Python.
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.
Why it exists: without classes, you would represent related data as loose variables or disconnected dictionaries. A class groups the data and all the operations on it into one coherent, named unit — making code easier to reason about, test, and reuse.
Real-world use: a banking application defines a BankAccount class. Every customer account is a separate instance — same structure and behaviour, independent data.
# Defining a class and creating instances
class Dog:
"""A simple class representing a dog."""
def bark(self): # a method — a function that belongs to the class
print("Woof!")
# Create two independent instances
rex = Dog()
luna = Dog()
rex.bark() # Woof!
luna.bark() # Woof!
print(type(rex)) #
print(isinstance(rex, Dog)) # True Woof!
<class '__main__.Dog'>
True
- 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 method — it refers to the instance the method is called onisinstance(obj, ClassName)checks whether an object is an instance of a class
The __init__ Method — Initialising Instances
__init__ is the initialiser — a special method Python calls automatically every time you create a new instance. It is where you set up the initial state of the object by assigning instance attributes.
Why it exists: every instance needs its own data. __init__ is the guaranteed place to set that data up — you know it always runs exactly once, right when the object is created.
# __init__ — setting up instance attributes
class Dog:
def __init__(self, name, breed, age):
self.name = name # instance attribute — unique to each object
self.breed = breed
self.age = age
def describe(self):
print(f"{self.name} is a {self.age}-year-old {self.breed}.")
def bark(self):
print(f"{self.name} says: Woof!")
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.bark() # Rex says: Woof!
# Access attributes directly
print(rex.name) # Rex
print(luna.age) # 2Luna is a 2-year-old Labrador.
Rex says: Woof!
Rex
2
__init__is not called a constructor — Python's actual constructor is__new__.__init__initialises the already-created instance.- Every attribute you want the instance to own must be assigned as
self.attribute = value - Attributes set in
__init__are available in every other method viaself - Each instance holds its own copy of the attributes — changing
rex.agenever affectsluna.age
Instance Attributes vs Class Attributes
Attributes defined inside __init__ on self belong to each instance individually. Attributes defined directly on the class body are class attributes — shared across all instances.
# Instance attributes vs class attributes
class BankAccount:
bank_name = "Dataplexa Bank" # class attribute — shared by all instances
interest_rate = 0.03 # class attribute
def __init__(self, owner, balance=0.0):
self.owner = owner # instance attribute — unique per object
self.balance = balance
def deposit(self, amount):
self.balance += amount
print(f"{self.owner} deposited ${amount:.2f}. Balance: ${self.balance:.2f}")
acc1 = BankAccount("Alice", 500.00)
acc2 = BankAccount("Bob")
acc1.deposit(200)
print(acc2.balance) # 0.0 — independent from acc1
# Class attribute accessible on both instance and class
print(acc1.bank_name) # Dataplexa Bank
print(BankAccount.bank_name) # Dataplexa Bank
print(BankAccount.interest_rate) # 0.030.0
Dataplexa Bank
Dataplexa Bank
0.03
- Class attributes are defined at the class level, outside any method
- All instances share the same class attribute — useful for constants and defaults
- If you assign to
self.bank_nameon an instance, that creates a new instance attribute that shadows the class attribute for that instance only - Use class attributes for data that is truly shared — counters, configuration, constants
Methods — Instance, Class, and Static
Python classes support three types of methods, each serving a different purpose.
- Instance methods — the most common. Take
selfas the first argument and operate on instance data. - Class methods — decorated with
@classmethod. Takecls(the class itself) as the first argument. Used as alternative constructors or to work with class-level data. - Static methods — decorated with
@staticmethod. Noselforcls. Just a regular function namespaced inside the class.
# Three types of methods
class Temperature:
unit = "Celsius" # class attribute
def __init__(self, value):
self.value = value # instance attribute
def describe(self): # instance method
print(f"{self.value}° {Temperature.unit}")
@classmethod
def set_unit(cls, new_unit): # class method
cls.unit = new_unit
@staticmethod
def is_freezing(temp_c): # static method — no self or cls
return temp_c <= 0
t = Temperature(100)
t.describe() # 100° Celsius
Temperature.set_unit("Fahrenheit")
t.describe() # 100° Fahrenheit
print(Temperature.is_freezing(-5)) # True
print(Temperature.is_freezing(20)) # False100° Fahrenheit
True
False
- Class methods are commonly used as alternative constructors — e.g.
Temperature.from_fahrenheit(212) - Static methods are utility functions that logically belong with the class but do not need access to class or instance data
- When in doubt, use an instance method — it is the most flexible
The __str__ and __repr__ Methods
By default, printing an object shows something like <__main__.Dog object at 0x...> — not very useful. Implementing __str__ and __repr__ gives your objects readable, informative string representations.
__str__— called byprint()andstr(). Should return a human-friendly description.__repr__— called in the REPL and byrepr(). Should return an unambiguous developer-facing string, ideally one that could recreate the object.
# __str__ and __repr__
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
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})" # developer-friendly
p = Product("Notebook", 4.99)
print(p) # calls __str__ → Notebook — $4.99
print(repr(p)) # calls __repr__ → Product(name='Notebook', price=4.99)
items = [Product("Pen", 1.50), Product("Desk", 89.99)]
print(items) # list uses __repr__ for each itemProduct(name='Notebook', price=4.99)
[Product(name='Pen', price=1.5), Product(name='Desk', price=89.99)]
- If only one is defined,
__repr__is used as a fallback for both - Always implement at least
__repr__— it makes debugging dramatically easier !rinside an f-string callsrepr()on the value — useful for showing strings with their quotes
Encapsulation — Public, Protected, and Private
Python uses naming conventions to signal the intended visibility of attributes and methods. There are no hard access modifiers like in Java or C++, but the conventions are respected universally.
- Public —
self.name— accessible from anywhere - Protected —
self._name— single underscore signals "internal use, handle with care" - Private —
self.__name— double underscore triggers name mangling, making it harder (but not impossible) to access from outside
# Encapsulation conventions
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # public
self._log = [] # protected — internal use
self.__balance = balance # private — name-mangled
def deposit(self, amount):
if amount > 0:
self.__balance += amount
self._log.append(f"Deposit: ${amount:.2f}")
def get_balance(self): # controlled public access
return self.__balance
acc = BankAccount("Alice", 100.00)
acc.deposit(50)
print(acc.get_balance()) # 150.0
print(acc._log) # ['Deposit: $50.00'] — accessible but discouraged
# print(acc.__balance) # AttributeError — name mangled to _BankAccount__balance
print(acc._BankAccount__balance) # 150.0 — still accessible if you know the mangled name['Deposit: $50.00']
150.0
- Single underscore
_is a convention — Python does not enforce it - Double underscore
__triggers name mangling:__balancebecomes_BankAccount__balance - The Pythonic approach is to trust the convention rather than fight against it — "we're all adults here"
Summary 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 | Function that 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 representation | def __str__(self): |
__repr__ |
Developer-facing string representation | def __repr__(self): |
Practice Questions
Practice 1. What naming convention do Python class names follow?
Practice 2. What is the name of the special method Python calls automatically when a new instance is created?
Practice 3. What is the difference between a class attribute and an instance attribute?
Practice 4. Which special method is called when you use print() on an object?
Practice 5. What prefix signals that an attribute is intended for internal use only (protected)?
Quiz
Quiz 1. What does self refer to inside a method?
Quiz 2. Which decorator is used to define a class method?
Quiz 3. What happens when you define an attribute with a double underscore prefix like self.__balance?
Quiz 4. If __str__ is not defined but __repr__ is, what does print(obj) use?
Quiz 5. What is the main advantage of using a class method as an alternative constructor?
Next up — Inheritance: extending classes to build specialised types without rewriting shared behaviour.