My Notes

Python OOP - The Complete Breakdown

Updated 6/29/202615 min readDownload PDFEdit

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.

MethodWhen it's calledExample
__init__Object creationStudent("Jordan", 22)
__str__print(obj)print(s)
__repr__repr(obj), debuggingrepr(s)
__len__len(obj)len(classroom)
__eq__obj1 == obj2s1 == s2
__lt__obj1 < obj2s1 < s2
__add__obj1 + obj2v1 + v2
__contains__item in obj"Jordan" in classroom
__getitem__obj[key]classroom[0]
__iter__for x in objfor s in classroom
__enter__ / __exit__with obj as xcontext 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

SituationUse
Group related data + functionsclass
Set up initial data__init__
Access the object's own dataself.attribute
Share data across all instancesclass attribute
Protect data from direct access_protected / __private
Validate before setting a value@property + @setter
Reuse code from another classinheritance + super()
Replace a parent methodmethod override
Same method, different behaviourpolymorphism
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.