My Notes
Python OOP - The Complete Breakdown
Python OOP — The Complete Breakdown
So what even is OOP?
You've been writing procedural code — functions, variables, loops, all running top to bottom. That works. But as programs get bigger, you end up with dozens of functions all operating on the same data, passed around everywhere, getting messy fast.
Object-Oriented Programming is a different way to think about code. Instead of "here's some data, here are functions that work on it" — you package the data and the functions that belong together into one thing called an object.
A User object knows its own name, age, and email. It also knows how to introduce itself, update its password, and log in. Everything that belongs to a user lives in one place.
That's OOP. Data + behavior, bundled together.
The four pillars — OOP concepts at a glance
Before diving into code, here's the full map of OOP. Every concept below gets its own section with code after this.
1. Encapsulation
Bundling data and the functions that work on it into one object. The object controls access to its own data — you interact through methods, not by reaching in and changing things directly.
2. Abstraction
Hiding the messy details and showing only what's needed. You call user.login() without knowing or caring how the login logic works internally. The complexity is hidden behind a clean interface.
3. Inheritance
One class can inherit everything from another class and extend it. A Student is a type of Person. Instead of rewriting all the Person code, Student inherits it and adds its own stuff on top.
4. Polymorphism
Different objects responding to the same method call in different ways. A Dog and a Cat both have a .speak() method — Dog returns "Woof", Cat returns "Meow". Same method name, different behavior depending on the object.
These four are the backbone of everything in OOP. Now let's build them all from scratch.
The class — the blueprint
A class is a blueprint for creating objects. The class defines the structure. Each object you create from it is an instance of that class.
class Student:
pass
s1 = Student()
s2 = Student()
print(type(s1))
print(s1 == s2)
Output:
<class '__main__.Student'>
False
s1 and s2 are both Student objects — two separate instances made from the same blueprint. Empty for now, but they exist.
__init__ — the setup function
__init__ is a special method that runs automatically every time you create a new instance. It's where you set up the object's initial data.
class Student:
def __init__(self, name, age, course):
self.name = name
self.age = age
self.course = course
s = Student("Jordan", 22, "Python")
print(s.name)
print(s.age)
print(s.course)
Output:
Jordan
22
Python
When you write Student("Jordan", 22, "Python"), Python calls __init__ automatically and passes those three values in. You never call __init__ directly — Python does it for you.
self — the object talking about itself
self is how an object refers to its own data and methods. It's always the first parameter of every method in a class — Python passes it automatically.
class Student:
def __init__(self, name, age):
self.name = name # self.name belongs to THIS object
self.age = age # self.age belongs to THIS object
def introduce(self):
return f"Hi, I'm {self.name} and I'm {self.age}."
s1 = Student("Jordan", 22)
s2 = Student("Alice", 25)
print(s1.introduce())
print(s2.introduce())
Output:
Hi, I'm Jordan and I'm 22.
Hi, I'm Alice and I'm 25.
Same method, two different objects. When s1.introduce() runs, self IS s1. When s2.introduce() runs, self IS s2. That's all self is — a reference to whichever object is calling the method.
You never pass self manually when calling a method — s1.introduce() not s1.introduce(s1). Python handles it.
Instance attributes vs class attributes
self.name is an instance attribute — it belongs to one specific object. Every instance has its own copy.
A class attribute belongs to the class itself — shared by every instance.
class Student:
school = "Northeastern" # class attribute — shared by all
def __init__(self, name):
self.name = name # instance attribute — unique per object
s1 = Student("Jordan")
s2 = Student("Alice")
print(s1.school) # Northeastern
print(s2.school) # Northeastern
print(s1.name) # Jordan
print(s2.name) # Alice
Student.school = "MIT" # change on the class
print(s1.school) # MIT — all instances see it
print(s2.school) # MIT
Output:
Northeastern
Northeastern
Jordan
Alice
MIT
MIT
Class attributes are useful for constants or shared counters. Instance attributes are for per-object data.
Methods — functions that belong to a class
Any function defined inside a class is a method. There are three types.
Instance methods — the regular ones
Take self as the first argument. Operate on instance data.
class Student:
def __init__(self, name, score):
self.name = name
self.score = score
def grade(self):
if self.score >= 90:
return "A"
elif self.score >= 80:
return "B"
elif self.score >= 70:
return "C"
else:
return "F"
def summary(self):
return f"{self.name}: {self.score} ({self.grade()})"
s = Student("Jordan", 87)
print(s.summary())
Output:
Jordan: 87 (B)
Class methods — operate on the class itself
Decorated with @classmethod. Take cls instead of self. Useful for alternative constructors.
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
@classmethod
def from_dict(cls, data):
return cls(data["name"], data["age"])
data = {"name": "Alice", "age": 21}
s = Student.from_dict(data)
print(s.name, s.age)
Output:
Alice 21
from_dict is an alternative way to create a Student — feed it a dictionary instead of separate arguments. cls refers to the Student class itself, so cls(...) is the same as Student(...).
Static methods — utility functions
Decorated with @staticmethod. No self, no cls. Just a regular function that logically belongs inside the class.
class Student:
@staticmethod
def is_passing(score):
return score >= 70
print(Student.is_passing(85)) # True
print(Student.is_passing(55)) # False
Output:
True
False
Static methods don't need an instance. They're just helper functions grouped under the class for organisation.
Encapsulation — protecting your data
Encapsulation means controlling how data is accessed and modified. Python uses naming conventions for this.
Public — accessible everywhere (default)
self.name = "Kalyan" # public — anyone can read or write
Protected — convention says "don't touch from outside"
Single underscore prefix. Python doesn't enforce it — it's just a signal to other developers.
self._password = "hashed_value" # protected — internal use only
Private — name-mangled, harder to access from outside
Double underscore prefix. Python transforms the name to make it harder (not impossible) to access from outside the class.
self.__secret = "top secret" # private
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # public
self._balance = balance # protected
self.__pin = "1234" # private
def deposit(self, amount):
if amount > 0:
self._balance += amount
def get_balance(self):
return self._balance
def check_pin(self, pin):
return pin == self.__pin
acc = BankAccount("Kalyan", 1000)
acc.deposit(500)
print(acc.owner) # Kalyan — public, fine
print(acc.get_balance()) # 1500 — via method, fine
print(acc._balance) # 1500 — works but bad practice
print(acc.__pin) # AttributeError — name mangled
Output:
Kalyan
1500
1500
AttributeError: 'BankAccount' object has no attribute '__pin'
Properties — getters and setters the Python way
Instead of get_balance() and set_balance() methods like Java, Python uses @property.
class BankAccount:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, amount):
if amount < 0:
raise ValueError("Balance can't be negative.")
self._balance = amount
acc = BankAccount(1000)
print(acc.balance) # 1000 — calls the getter
acc.balance = 2000 # calls the setter
print(acc.balance) # 2000
acc.balance = -500 # ValueError
Output:
1000
2000
ValueError: Balance can't be negative.
@property makes a method behave like an attribute. acc.balance looks like reading a variable but actually calls the getter function. The setter validates before allowing the change.
Inheritance — reusing and extending
Inheritance lets one class take everything from another and add to it or override it. The parent class is the base. The child class extends it.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def introduce(self):
return f"Hi, I'm {self.name}, {self.age} years old."
class Student(Person): # Student inherits from Person
def __init__(self, name, age, course):
super().__init__(name, age) # call Person's __init__
self.course = course
def study(self):
return f"{self.name} is studying {self.course}."
s = Student("Jordan", 22, "Python")
print(s.introduce()) # inherited from Person
print(s.study()) # defined in Student
Output:
Hi, I'm Jordan, 22 years old.
Jordan is studying Python.
super().__init__(name, age) calls the parent class's __init__ so you don't have to repeat that code. Student gets everything Person has for free, then adds course and study() on top.
isinstance() and issubclass()
print(isinstance(s, Student)) # True
print(isinstance(s, Person)) # True — Student IS a Person
print(issubclass(Student, Person)) # True
Output:
True
True
True
A Student instance is also a Person instance — that's the whole point of inheritance.
Method overriding — replacing parent behaviour
A child class can override any method from the parent.
class Person:
def introduce(self):
return f"Hi, I'm {self.name}."
class Student(Person):
def __init__(self, name, course):
self.name = name
self.course = course
def introduce(self): # overrides Person's version
return f"Hi, I'm {self.name} and I study {self.course}."
s = Student("Jordan", "Python")
print(s.introduce())
Output:
Hi, I'm Jordan and I study Python.
Python uses the child's version of introduce() because it finds it there first before looking at the parent.
Polymorphism — same method, different behaviour
Polymorphism means many forms. Different objects respond to the same method call in their own way.
class Dog:
def speak(self):
return "Woof!"
class Cat:
def speak(self):
return "Meow!"
class Duck:
def speak(self):
return "Quack!"
animals = [Dog(), Cat(), Duck()]
for animal in animals:
print(animal.speak())
Output:
Woof!
Meow!
Quack!
Same loop, same method call, three completely different results. The loop doesn't know or care what type each object is — it just calls .speak() and each object handles it its own way.
This is duck typing — "if it has a .speak() method, I can call it." Python doesn't require formal interfaces like Java does.
Abstraction — hiding complexity
Abstraction means exposing a clean interface and hiding the messy implementation. The user of your class doesn't need to know how it works internally — just what it does.
class DatabaseConnection:
def __init__(self, host, port):
self._host = host
self._port = port
self._conn = None
self._connected = False
def connect(self):
# pretend this does complex socket setup
self._conn = f"socket://{self._host}:{self._port}"
self._connected = True
print("Connected.")
def query(self, sql):
if not self._connected:
raise RuntimeError("Not connected.")
return f"Results for: {sql}"
def disconnect(self):
self._conn = None
self._connected = False
print("Disconnected.")
db = DatabaseConnection("localhost", 5432)
db.connect()
print(db.query("SELECT * FROM users"))
db.disconnect()
Output:
Connected.
Results for: SELECT * FROM users
Disconnected.
The user calls connect(), query(), disconnect(). They have no idea what's happening internally. The complexity is hidden. That's abstraction.
__str__ and __repr__ — string representations
These are special methods that control how your object looks when printed.
class Student:
def __init__(self, name, score):
self.name = name
self.score = score
def __str__(self):
return f"Student({self.name}, score={self.score})"
def __repr__(self):
return f"Student(name={self.name!r}, score={self.score!r})"
s = Student("Jordan", 92)
print(s) # calls __str__
print(repr(s)) # calls __repr__
Output:
Student(Jordan, score=92)
Student(name='Jordan', score=92)
__str__ is for human-readable output. __repr__ is for developer/debugging output — should ideally look like valid Python code to recreate the object. Always define __str__ on any class you'll print.
Magic methods (dunder methods) — full list
Methods with double underscores on both sides are called dunder (double underscore) methods. Python calls them automatically in certain situations.
| Method | When it's called | Example |
|---|---|---|
__init__ | Object creation | Student("Jordan", 22) |
__str__ | print(obj) | print(s) |
__repr__ | repr(obj), debugging | repr(s) |
__len__ | len(obj) | len(classroom) |
__eq__ | obj1 == obj2 | s1 == s2 |
__lt__ | obj1 < obj2 | s1 < s2 |
__add__ | obj1 + obj2 | v1 + v2 |
__contains__ | item in obj | "Jordan" in classroom |
__getitem__ | obj[key] | classroom[0] |
__iter__ | for x in obj | for s in classroom |
__enter__ / __exit__ | with obj as x | context managers |
class Classroom:
def __init__(self):
self.students = []
def add(self, student):
self.students.append(student)
def __len__(self):
return len(self.students)
def __contains__(self, name):
return any(s.name == name for s in self.students)
def __str__(self):
names = [s.name for s in self.students]
return f"Classroom({', '.join(names)})"
room = Classroom()
room.add(Student("Jordan", 92))
room.add(Student("Alice", 88))
print(len(room)) # 2
print("Jordan" in room) # True
print("Bob" in room) # False
print(room)
Output:
2
True
False
Classroom(Jordan, Alice)
Multiple inheritance — inheriting from more than one class
Python lets a class inherit from multiple parents.
class Swimmer:
def swim(self):
return "Swimming."
class Runner:
def run(self):
return "Running."
class Triathlete(Swimmer, Runner):
def compete(self):
return f"{self.swim()} {self.run()}"
t = Triathlete()
print(t.compete())
print(t.swim())
print(t.run())
Output:
Swimming. Running.
Swimming.
Running.
Triathlete gets everything from both Swimmer and Runner. Use multiple inheritance carefully — it can get complicated when both parents define the same method name (Python resolves this using MRO — Method Resolution Order, left to right).
The full real project — a security tool inventory system
Everything in one place. Classes, inheritance, encapsulation, polymorphism, properties, dunder methods, all of it.
class Tool:
"""Base class for all security tools."""
tool_count = 0 # class attribute — tracks total tools created
def __init__(self, name, version, vendor):
self.name = name
self.version = version
self.vendor = vendor
self._active = False
Tool.tool_count += 1
def activate(self):
self._active = True
print(f"{self.name} activated.")
def deactivate(self):
self._active = False
print(f"{self.name} deactivated.")
@property
def active(self):
return self._active
def scan(self, target):
raise NotImplementedError("Subclasses must implement scan().")
def __str__(self):
status = "active" if self._active else "inactive"
return f"{self.name} v{self.version} by {self.vendor} [{status}]"
def __repr__(self):
return f"Tool(name={self.name!r}, version={self.version!r})"
class NetworkScanner(Tool):
"""Scans networks for open ports and services."""
def __init__(self, name, version, vendor, port_range):
super().__init__(name, version, vendor)
self.port_range = port_range
def scan(self, target):
if not self._active:
return f"Error: {self.name} is not active."
return f"[NetworkScanner] Scanning {target} on ports {self.port_range}..."
def banner_grab(self, target, port):
return f"[BannerGrab] Connecting to {target}:{port}..."
class VulnScanner(Tool):
"""Scans for known CVEs and vulnerabilities."""
def __init__(self, name, version, vendor, cve_db):
super().__init__(name, version, vendor)
self.cve_db = cve_db
self._findings = []
def scan(self, target):
if not self._active:
return f"Error: {self.name} is not active."
fake_cve = f"CVE-2024-{hash(target) % 9999:04d}"
self._findings.append((target, fake_cve))
return f"[VulnScanner] {target} — found {fake_cve}"
@property
def findings(self):
return list(self._findings) # return copy, not original
class Inventory:
"""Manages a collection of security tools."""
def __init__(self):
self._tools = {}
def add(self, tool):
self._tools[tool.name] = tool
print(f"Added: {tool}")
def remove(self, name):
tool = self._tools.pop(name, None)
if tool:
print(f"Removed: {tool.name}")
else:
print(f"Tool '{name}' not found.")
def get(self, name):
return self._tools.get(name)
def run_all(self, target):
print(f"\nRunning all active tools against {target}:")
for tool in self._tools.values():
if tool.active:
print(f" {tool.scan(target)}")
def __len__(self):
return len(self._tools)
def __contains__(self, name):
return name in self._tools
def __str__(self):
return f"Inventory({len(self)} tools)"
inv = Inventory()
nmap = NetworkScanner("nmap", "7.94", "Insecure.org", "1-1024")
nikto = VulnScanner( "nikto", "2.1", "CIRT.net", "cve-2024")
nessus = VulnScanner( "nessus", "10.6", "Tenable", "full-db")
inv.add(nmap)
inv.add(nikto)
inv.add(nessus)
nmap.activate()
nikto.activate()
print(f"\nInventory size: {len(inv)}")
print(f"nmap in inventory: {'nmap' in inv}")
inv.run_all("192.168.1.1")
print(f"\nNikto findings: {nikto.findings}")
print(f"\nTotal tools ever created: {Tool.tool_count}")
Output:
Added: nmap v7.94 by Insecure.org [inactive]
Added: nikto v2.1 by CIRT.net [inactive]
Added: nessus v10.6 by Tenable [inactive]
nmap activated.
nikto activated.
Inventory size: 3
nmap in inventory: True
Running all active tools against 192.168.1.1:
[NetworkScanner] Scanning 192.168.1.1 on ports 1-1024...
[VulnScanner] 192.168.1.1 — found CVE-2024-XXXX
Nikto findings: [('192.168.1.1', 'CVE-2024-XXXX')]
Total tools ever created: 3
Every OOP concept in this file lives in that project. The Tool base class with scan() raising NotImplementedError is abstraction — it forces subclasses to implement their own version. NetworkScanner and VulnScanner inherit from Tool and each implement scan() differently — that's polymorphism. _active and _findings with property access — encapsulation. __len__ and __contains__ on Inventory — dunder methods. Tool.tool_count — class attribute. super().__init__() — inheritance wiring. All of it, running together.
The decision table — when to use what
| Situation | Use |
|---|---|
| Group related data + functions | class |
| Set up initial data | __init__ |
| Access the object's own data | self.attribute |
| Share data across all instances | class attribute |
| Protect data from direct access | _protected / __private |
| Validate before setting a value | @property + @setter |
| Reuse code from another class | inheritance + super() |
| Replace a parent method | method override |
| Same method, different behaviour | polymorphism |
| Control how object looks when printed | __str__ / __repr__ |
Custom len(), in, + etc. | dunder methods |
| Utility function inside a class | @staticmethod |
| Alternative constructor | @classmethod |
The one-line summary
A class is a blueprint. __init__ sets it up. self is the object talking about itself. Encapsulation bundles and protects data. Inheritance reuses and extends. Polymorphism lets different objects respond to the same call differently. Abstraction hides the mess behind a clean interface. Master these four pillars and you can build anything — from a simple student record to a full security tool framework.