Table of contents
What is OOP and Why Use It
Object-Oriented Programming (OOP) is a way of organising code around objects — bundles that combine data (attributes) and behaviour (methods) into a single unit. Instead of writing a flat sequence of instructions that manipulates separate variables, you model the real world or your problem domain as a collection of interacting objects.
Procedural approach — data and functions live separately:
name = "Alice"
balance = 1000.0
def deposit(balance, amount):
return balance + amount
def withdraw(balance, amount):
if amount > balance:
raise ValueError("Insufficient funds")
return balance - amount
balance = deposit(balance, 500)
print(balance) # 1500.0
OOP approach — data and behaviour belong together:
class BankAccount:
def __init__(self, owner: str, balance: float = 0.0):
self.owner = owner
self.balance = balance
def deposit(self, amount: float) -> None:
self.balance += amount
def withdraw(self, amount: float) -> None:
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
account = BankAccount("Alice", 1000.0)
account.deposit(500)
print(account.balance) # 1500.0
The OOP version is easier to reason about, easier to extend, and naturally groups all account-related logic in one place. As a system grows, this separation pays off considerably.
When OOP shines:
- Modelling real-world entities (users, products, orders)
- Building systems with many interacting parts
- Code that needs to scale and be maintained over time
When procedural code is perfectly fine:
- Short scripts and data pipelines
- Pure transformations with no shared state
- Functional-style data processing with
map,filter,reduce
Concepts
Class
A class is a blueprint. It defines what data an object will hold and what it can do, but it doesn't create anything on its own — it's a template waiting to be used. Think of it like a cookie cutter: the cutter is the class, the cookies are objects.
Object
An object (also called an instance) is a concrete thing created from a class. Each object has its own copy of the data defined by the class, but shares the behaviour (methods) defined on it.
Encapsulation
Encapsulation means bundling data and the code that works with it inside a single unit, and controlling what the outside world can see. It's the "need-to-know" principle: external code interacts through a defined interface, not by poking at internals directly.
Inheritance
Inheritance lets one class build on top of another. The child class gets everything
the parent has, and can add or override behaviour. It expresses an "is-a" relationship:
a SavingsAccount is a BankAccount.
Polymorphism
Polymorphism means "many forms". Different classes can implement the same method name,
and the right implementation is chosen at runtime based on the actual object type.
Code that calls account.calculate_interest() doesn't need to know which specific
account type it's dealing with — it just calls the method.
Abstraction
Abstraction hides complexity behind a simple interface. You interact with a Payment
object through process() without knowing whether it routes to Stripe, PayPal, or a bank —
the details are hidden. It's about exposing what something does, not how.
Classes and Objects
Defining a Class
A class is defined with the class keyword. By convention, class names use PascalCase.
class Product:
pass # empty class — valid Python
To create an object from it:
p = Product()
print(type(p)) # <class '__main__.Product'>
Each call to Product() creates a new, independent object.
The __init__ Method
__init__ is the initialiser — Python calls it automatically right after creating a
new object. Use it to set up the object's initial state.
class Product:
def __init__(self, name: str, price: float, stock: int = 0):
self.name = name
self.price = price
self.stock = stock
def __str__(self) -> str:
return f"{self.name} (${self.price:.2f}) — {self.stock} in stock"
laptop = Product("Laptop Pro", 1299.99, stock=15)
headphones = Product("Wireless Headphones", 79.99)
print(laptop) # Laptop Pro ($1299.99) — 15 in stock
print(headphones) # Wireless Headphones ($79.99) — 0 in stock
self is a reference to the object being created. It's always the first parameter of any
instance method — Python passes it automatically when you call the method on an object.
Instance vs Class Variables
Instance variables belong to each individual object. Changing one object's instance variable doesn't affect others.
Class variables are shared across all instances of that class. They live on the class itself, not on any particular object.
class Product:
currency = "USD" # class variable — shared by all instances
_count = 0 # class variable — tracks total products created
def __init__(self, name: str, price: float):
self.name = name # instance variable — unique per object
self.price = price # instance variable — unique per object
Product._count += 1
@classmethod
def total_products(cls) -> int:
return cls._count
p1 = Product("Laptop", 999.0)
p2 = Product("Mouse", 29.0)
print(Product.currency) # USD
print(Product.total_products()) # 2
print(p1.name) # Laptop
print(p2.name) # Mouse
A subtle trap: if you assign a class variable through an instance (p1.currency = "EUR"),
Python creates a new instance variable that shadows the class variable for that object only —
the class variable itself stays unchanged.
The Four Pillars of OOP
Encapsulation
Python doesn't enforce access control with keywords like private or protected — instead
it uses naming conventions:
name— public: accessible anywhere_name— protected by convention: "please don't touch this from outside, but I won't stop you"__name— name-mangled: Python renames it to_ClassName__name, making accidental access harder
class BankAccount:
def __init__(self, owner: str, balance: float = 0.0):
self.owner = owner # public
self._balance = balance # protected — internal use encouraged
@property
def balance(self) -> float:
return self._balance
@balance.setter
def balance(self, value: float) -> None:
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self._balance += amount
def withdraw(self, amount: float) -> None:
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
account = BankAccount("Alice", 500.0)
account.deposit(200)
print(account.balance) # 700.0
account.balance = 1000 # goes through the setter
print(account.balance) # 1000.0
# account.balance = -50 # raises ValueError
The @property decorator turns a method into an attribute-style getter. Pair it with a
@<name>.setter to add validation without changing the calling code — outside code still
writes account.balance = value instead of account.set_balance(value).
Inheritance
Inheritance lets a class reuse and specialise the behaviour of another.
Single inheritance:
class Account:
def __init__(self, owner: str, balance: float = 0.0):
self.owner = owner
self._balance = balance
@property
def balance(self) -> float:
return self._balance
def deposit(self, amount: float) -> None:
self._balance += amount
def __str__(self) -> str:
return f"{self.owner}: ${self._balance:.2f}"
class SavingsAccount(Account):
def __init__(self, owner: str, balance: float = 0.0, interest_rate: float = 0.03):
super().__init__(owner, balance) # call parent __init__
self.interest_rate = interest_rate
def apply_interest(self) -> None:
self._balance += self._balance * self.interest_rate
def __str__(self) -> str:
base = super().__str__()
return f"{base} (savings, {self.interest_rate:.0%} interest)"
savings = SavingsAccount("Bob", 2000.0, interest_rate=0.05)
savings.apply_interest()
print(savings) # Bob: $2100.00 (savings, 5% interest)
super() gives you access to the parent class. Always call super().__init__() in a child
class unless you intentionally want to skip the parent's setup.
Multiple inheritance — Python allows a class to inherit from more than one parent:
class Timestamped:
from datetime import datetime
def __init__(self):
self.created_at = self.datetime.now()
class Auditable:
def audit_log(self) -> str:
return f"Audited at {getattr(self, 'created_at', 'unknown')}"
class AuditedAccount(Account, Timestamped, Auditable):
def __init__(self, owner: str, balance: float = 0.0):
Account.__init__(self, owner, balance)
Timestamped.__init__(self)
Use multiple inheritance carefully — see the MRO section for how Python resolves method lookup order when the same method name appears in multiple parents.
Polymorphism
Method overriding lets a child class replace a parent's implementation:
class Account:
def calculate_fee(self) -> float:
return 0.0
class PremiumAccount(Account):
def calculate_fee(self) -> float:
return 5.0 # flat monthly fee
class PayAsYouGoAccount(Account):
def __init__(self, transactions: int):
self.transactions = transactions
def calculate_fee(self) -> float:
return self.transactions * 0.25 # 25 cents per transaction
accounts = [
Account(),
PremiumAccount(),
PayAsYouGoAccount(transactions=12),
]
for acc in accounts:
print(f"{type(acc).__name__}: ${acc.calculate_fee():.2f}")
# Account: $0.00
# PremiumAccount: $5.00
# PayAsYouGoAccount: $3.00
The loop doesn't care which type each element is — it just calls calculate_fee() and
gets the right result.
Duck typing — Python doesn't require a shared base class for polymorphism. Any object that has the right method can participate:
class CsvExporter:
def export(self, data: list) -> str:
return ",".join(str(x) for x in data)
class JsonExporter:
def export(self, data: list) -> str:
import json
return json.dumps(data)
def run_export(exporter, data: list) -> None:
print(exporter.export(data))
run_export(CsvExporter(), [1, 2, 3]) # 1,2,3
run_export(JsonExporter(), [1, 2, 3]) # [1, 2, 3]
Neither exporter inherits from anything — they just both have an export method. That's
enough for Python to treat them the same way.
Abstraction
Abstract classes define a contract: "any class that inherits from me must implement these
methods." Python provides this through the abc module.
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
@abstractmethod
def charge(self, amount: float) -> bool:
"""Attempt to charge the given amount. Return True on success."""
...
@abstractmethod
def refund(self, amount: float) -> bool:
"""Issue a refund. Return True on success."""
...
def process_order(self, amount: float) -> str:
if self.charge(amount):
return f"Order processed: ${amount:.2f} charged"
return "Payment failed"
class StripeProcessor(PaymentProcessor):
def charge(self, amount: float) -> bool:
print(f"[Stripe] Charging ${amount:.2f}")
return True # simulated success
def refund(self, amount: float) -> bool:
print(f"[Stripe] Refunding ${amount:.2f}")
return True
class PayPalProcessor(PaymentProcessor):
def charge(self, amount: float) -> bool:
print(f"[PayPal] Charging ${amount:.2f}")
return True
def refund(self, amount: float) -> bool:
print(f"[PayPal] Refunding ${amount:.2f}")
return True
# PaymentProcessor() ← raises TypeError: can't instantiate abstract class
stripe = StripeProcessor()
print(stripe.process_order(49.99))
# [Stripe] Charging $49.99
# Order processed: $49.99 charged
You cannot instantiate PaymentProcessor directly — Python raises a TypeError. This
forces every subclass to implement the contract before it can be used.
Special (Dunder) Methods
Dunder methods (short for double-underscore) let your objects integrate with Python's built-in syntax and functions. Implementing them is what makes your class feel like a natural part of the language.
Common Dunder Methods
| Method | Triggered by | Typical use |
|---|---|---|
|
|
|
Human-readable string |
|
|
|
Debug-friendly string |
|
|
|
Return a length |
|
|
|
Equality comparison |
|
|
|
Less-than comparison |
|
|
|
Addition / merging |
|
|
|
Membership test |
|
|
|
Subscript access |
|
|
|
Make object iterable |
|
|
|
Context manager |
class Inventory:
def __init__(self, name: str):
self.name = name
self._items: list[str] = []
def add(self, item: str) -> None:
self._items.append(item)
def __str__(self) -> str:
return f"Inventory '{self.name}': {self._items}"
def __repr__(self) -> str:
return f"Inventory(name={self.name!r}, items={self._items!r})"
def __len__(self) -> int:
return len(self._items)
def __contains__(self, item: str) -> bool:
return item in self._items
def __getitem__(self, index: int) -> str:
return self._items[index]
def __iter__(self):
return iter(self._items)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Inventory):
return NotImplemented
return self._items == other._items
inv = Inventory("Warehouse A")
inv.add("Laptop")
inv.add("Monitor")
inv.add("Keyboard")
print(str(inv)) # Inventory 'Warehouse A': ['Laptop', 'Monitor', 'Keyboard']
print(repr(inv)) # Inventory(name='Warehouse A', items=['Laptop', 'Monitor', 'Keyboard'])
print(len(inv)) # 3
print("Laptop" in inv) # True
print(inv[0]) # Laptop
for item in inv:
print(item) # Laptop / Monitor / Keyboard
__repr__ should ideally return a string from which the object could be reconstructed.
__str__ is for end-user display and can be more readable. If only one is defined,
__str__ falls back to __repr__.
Operator Overloading
Dunder methods let you define what operators like +, *, <, and == do for your objects.
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class Cart:
items: list[str] = field(default_factory=list)
def add(self, item: str) -> None:
self.items.append(item)
def __add__(self, other: Cart) -> Cart:
"""Merge two carts into a new one."""
return Cart(items=self.items + other.items)
def __len__(self) -> int:
return len(self.items)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Cart):
return NotImplemented
return sorted(self.items) == sorted(other.items)
def __lt__(self, other: Cart) -> bool:
return len(self) < len(other)
cart1 = Cart()
cart1.add("Laptop")
cart1.add("Mouse")
cart2 = Cart()
cart2.add("Keyboard")
merged = cart1 + cart2
print(merged.items) # ['Laptop', 'Mouse', 'Keyboard']
print(len(merged)) # 3
print(cart1 < merged) # True
Return NotImplemented (not raise NotImplementedError) when the other operand's type
is incompatible — this tells Python to try the reflected operation on the other object.
Advanced OOP
Class Methods and Static Methods
Python gives you three kinds of methods on a class:
Instance methods — the default. Receive self and can read/write instance state.
Class methods — decorated with @classmethod. Receive cls (the class itself) instead
of an instance. Commonly used as alternative constructors.
Static methods — decorated with @staticmethod. Receive neither self nor cls.
They're plain functions that logically belong to the class but don't need access to any
class or instance state.
from datetime import date
class Employee:
_headcount: int = 0
def __init__(self, name: str, hire_date: date, salary: float):
self.name = name
self.hire_date = hire_date
self.salary = salary
Employee._headcount += 1
# --- alternative constructors (class methods) ---
@classmethod
def from_string(cls, employee_str: str) -> "Employee":
"""Create an Employee from a 'name,yyyy-mm-dd,salary' string."""
name, raw_date, raw_salary = employee_str.split(",")
return cls(name.strip(), date.fromisoformat(raw_date.strip()), float(raw_salary.strip()))
@classmethod
def headcount(cls) -> int:
return cls._headcount
# --- utility (static method) ---
@staticmethod
def is_valid_salary(salary: float) -> bool:
return salary > 0
def years_employed(self) -> int:
return (date.today() - self.hire_date).days // 365
def __str__(self) -> str:
return f"{self.name} (hired {self.hire_date}, ${self.salary:,.2f}/yr)"
e1 = Employee("Alice", date(2020, 6, 1), 85_000)
e2 = Employee.from_string("Bob, 2022-03-15, 72000")
print(e1) # Alice (hired 2020-06-01, $85,000.00/yr)
print(e2) # Bob (hired 2022-03-15, $72,000.00/yr)
print(Employee.headcount()) # 2
print(Employee.is_valid_salary(-500)) # False
@classmethod is the right choice whenever you need factory methods or want to operate on
shared class state. @staticmethod is right when the logic is purely self-contained and
just belongs conceptually to the class.
Dataclasses
When a class is mainly a container for data, writing __init__, __repr__, and __eq__
by hand is repetitive. The @dataclass decorator generates them automatically.
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass
class OrderItem:
product_name: str
quantity: int
unit_price: float
@property
def subtotal(self) -> float:
return self.quantity * self.unit_price
@dataclass
class Order:
customer: str
items: list[OrderItem] = field(default_factory=list)
_next_id: ClassVar[int] = 1
def __post_init__(self):
self.order_id = Order._next_id
Order._next_id += 1
def add_item(self, item: OrderItem) -> None:
self.items.append(item)
@property
def total(self) -> float:
return sum(i.subtotal for i in self.items)
def __str__(self) -> str:
lines = [f"Order #{self.order_id} for {self.customer}"]
for item in self.items:
lines.append(f" {item.product_name} x{item.quantity} = ${item.subtotal:.2f}")
lines.append(f" Total: ${self.total:.2f}")
return "\n".join(lines)
order = Order("Alice")
order.add_item(OrderItem("Laptop", 1, 999.99))
order.add_item(OrderItem("Mouse", 2, 24.99))
print(order)
# Order #1 for Alice
# Laptop x1 = $999.99
# Mouse x2 = $49.98
# Total: $1049.97
Key points:
field(default_factory=list)avoids the mutable-default-argument trap.__post_init__is called after the generated__init__— useful for derived fields.ClassVarmarks class-level variables so the dataclass machinery ignores them.- Add
frozen=Trueto make instances immutable (and hashable).
Method Resolution Order
When a class inherits from multiple parents that share a method name, Python needs a deterministic rule for which one to use. It follows the C3 linearisation algorithm, which produces the MRO — an ordered list of classes to search.
class A:
def greet(self) -> str:
return "Hello from A"
class B(A):
def greet(self) -> str:
return "Hello from B"
class C(A):
def greet(self) -> str:
return "Hello from C"
class D(B, C):
pass
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
d = D()
print(d.greet()) # Hello from B
Python walks the MRO left to right and uses the first class that has the method. Here:
D → B → C → A → object.
This is also why calling super() in a cooperative multiple-inheritance chain works
correctly — each class calls super(), and Python follows the MRO to chain them together:
class Loggable:
def setup(self) -> None:
print("Loggable.setup")
class Cacheable:
def setup(self) -> None:
print("Cacheable.setup")
super().setup()
class Service(Cacheable, Loggable):
def setup(self) -> None:
print("Service.setup")
super().setup()
svc = Service()
svc.setup()
# Service.setup
# Cacheable.setup
# Loggable.setup
The MRO for Service is Service → Cacheable → Loggable → object, so each super().setup()
call continues down that chain in order.
Design and Best Practices
SOLID Principles
| Principle | One-line rule |
|---|---|
| Single Responsibility | A class should have one reason to change |
| Open/Closed | Open for extension, closed for modification |
| Liskov Substitution | Subtypes must be usable wherever the base type is expected |
| Interface Segregation | Prefer many small, focused interfaces over one large one |
| Dependency Inversion | Depend on abstractions, not concrete implementations |
A quick Python illustration of Single Responsibility:
# Bad — one class does too many things
class UserManager:
def create_user(self, name: str): ...
def send_welcome_email(self, user): ... # email concern mixed in
def save_to_database(self, user): ... # persistence mixed in
# Better — each class has one clear job
class UserRepository:
def save(self, user): ...
class EmailService:
def send_welcome(self, user): ...
class UserService:
def __init__(self, repo: UserRepository, mailer: EmailService):
self.repo = repo
self.mailer = mailer
def register(self, name: str):
user = {"name": name}
self.repo.save(user)
self.mailer.send_welcome(user)
And Open/Closed with the payment example from earlier — you add a new processor by
creating a new class, not by modifying PaymentProcessor or any existing processor.
Composition vs Inheritance
Inheritance models "is-a". Composition models "has-a". Prefer composition when:
- The relationship isn't a true "is-a" (a
Loggerisn't a type ofService) - You want to combine behaviours from multiple sources without deep hierarchies
- You need to swap implementations at runtime
# Inheritance — tight coupling, hard to swap Logger implementation
class LoggedService(Logger, Service): ...
# Composition — loose coupling, easy to swap
class Service:
def __init__(self, logger: Logger):
self.logger = logger # inject any Logger-compatible object
def run(self) -> None:
self.logger.log("Service running")
A deep inheritance hierarchy is often a sign that the design should be flattened using composition. The rule of thumb: favour composition over inheritance, and reach for inheritance only when the "is-a" relationship is clear and stable.
When Not to Use OOP
OOP is a tool, not a religion. Python supports multiple paradigms — use the one that fits.
Skip OOP when:
- You're writing a short script that runs once and is thrown away
- The logic is a pure transformation: input goes in, output comes out, no shared state
- You're working with data pipelines where
map/filter/list comprehensionexpress the logic more clearly - Adding a class would require more boilerplate than the actual logic
# A class here adds nothing
class CsvParser:
def parse(self, line: str) -> list[str]:
return line.strip().split(",")
# A plain function is cleaner
def parse_csv_line(line: str) -> list[str]:
return line.strip().split(",")
Python's standard library itself mixes styles: pathlib is deeply OOP, while itertools
is purely functional. Match the paradigm to the problem.
Real-World Example
Let's tie everything together with a small inventory management system. It demonstrates
classes, encapsulation, inheritance, polymorphism, abstract classes, dunder methods,
class methods, @dataclass, and @property — all working together.
from __future__ import annotations
import json
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import ClassVar
# ── Abstract base ────────────────────────────────────────────────
class StockItem(ABC):
"""Base contract for anything that can be stocked."""
@abstractmethod
def display_info(self) -> str: ...
@abstractmethod
def value(self) -> float: ...
# ── Concrete item types ──────────────────────────────────────────
@dataclass
class PhysicalProduct(StockItem):
name: str
sku: str
price: float
quantity: int
weight_kg: float
def display_info(self) -> str:
return (
f"[Physical] {self.name} (SKU: {self.sku}) "
f"— ${self.price:.2f} x {self.quantity} units, {self.weight_kg} kg each"
)
def value(self) -> float:
return self.price * self.quantity
@dataclass
class DigitalProduct(StockItem):
name: str
sku: str
price: float
licenses: int
def display_info(self) -> str:
return (
f"[Digital] {self.name} (SKU: {self.sku}) "
f"— ${self.price:.2f} x {self.licenses} licenses"
)
def value(self) -> float:
return self.price * self.licenses
# ── Inventory ────────────────────────────────────────────────────
class Inventory:
_instance_count: ClassVar[int] = 0
def __init__(self, warehouse_name: str):
self.warehouse_name = warehouse_name
self._items: list[StockItem] = []
Inventory._instance_count += 1
# --- class method factory ---
@classmethod
def from_json(cls, data: str) -> Inventory:
"""Create an Inventory from a JSON string (name only for brevity)."""
payload = json.loads(data)
return cls(payload["warehouse_name"])
@classmethod
def total_warehouses(cls) -> int:
return cls._instance_count
# --- static utility ---
@staticmethod
def is_low_stock(item: PhysicalProduct, threshold: int = 5) -> bool:
return isinstance(item, PhysicalProduct) and item.quantity <= threshold
# --- properties ---
@property
def total_value(self) -> float:
return sum(item.value() for item in self._items)
@property
def item_count(self) -> int:
return len(self._items)
# --- mutating methods ---
def add(self, item: StockItem) -> None:
self._items.append(item)
def remove_by_sku(self, sku: str) -> None:
before = len(self._items)
self._items = [i for i in self._items if getattr(i, "sku", None) != sku]
if len(self._items) == before:
raise KeyError(f"SKU '{sku}' not found")
# --- dunder methods ---
def __len__(self) -> int:
return self.item_count
def __contains__(self, sku: str) -> bool:
return any(getattr(i, "sku", None) == sku for i in self._items)
def __iter__(self):
return iter(self._items)
def __str__(self) -> str:
header = f"Warehouse: {self.warehouse_name} ({len(self)} items)"
lines = [header, "-" * len(header)]
for item in self:
lines.append(f" {item.display_info()}")
lines.append(f" Total stock value: ${self.total_value:,.2f}")
return "\n".join(lines)
def __repr__(self) -> str:
return f"Inventory(warehouse_name={self.warehouse_name!r}, items={self.item_count})"
# ── Demo ─────────────────────────────────────────────────────────
if __name__ == "__main__":
inv = Inventory("Central Hub")
inv.add(PhysicalProduct("Laptop Pro 15", "LP-001", 1299.99, quantity=20, weight_kg=1.8))
inv.add(PhysicalProduct("USB-C Hub", "UC-102", 39.99, quantity=4, weight_kg=0.1))
inv.add(DigitalProduct("Design Suite", "DS-500", 199.00, licenses=50))
inv.add(DigitalProduct("Code Editor Pro","CE-601", 79.00, licenses=12))
print(inv)
print()
print(f"'UC-102' in inventory: {'UC-102' in inv}")
print(f"Total warehouses: {Inventory.total_warehouses()}")
for item in inv:
if Inventory.is_low_stock(item): # type: ignore[arg-type]
print(f"⚠ Low stock: {item.name}")
inv.remove_by_sku("UC-102")
print(f"\nAfter removing UC-102 — items: {len(inv)}")
Output:
Warehouse: Central Hub (4 items)
----------------------------------
[Physical] Laptop Pro 15 (SKU: LP-001) — $1299.99 x 20 units, 1.8 kg each
[Physical] USB-C Hub (SKU: UC-102) — $39.99 x 4 units, 0.1 kg each
[Digital] Design Suite (SKU: DS-500) — $199.00 x 50 licenses
[Digital] Code Editor Pro (SKU: CE-601) — $79.00 x 12 licenses
Total stock value: $37,103.60
'UC-102' in inventory: True
Total warehouses: 1
⚠ Low stock: USB-C Hub
After removing UC-102 — items: 3
Summary
OOP in Python is built on six interconnected ideas:
- Classes and objects — the blueprint and the thing built from it.
__init__— sets up object state; instance variables belong to each object, class variables are shared.- The four pillars — encapsulation bundles state with behaviour and hides internals; inheritance reuses and specialises; polymorphism lets the same call work differently per type; abstraction enforces contracts and hides complexity.
- Dunder methods — make your objects feel native by plugging into Python's syntax and built-ins.
- Advanced tools —
@classmethodfor factory constructors,@staticmethodfor utilities,@dataclassto eliminate boilerplate, and MRO to understand multiple inheritance lookup order. - Design sense — follow SOLID where it helps, prefer composition over deep hierarchies, and know when a plain function serves you better than a class.
Mastering these ideas doesn't mean wrapping everything in classes. It means knowing when the object model genuinely organises your code better — and reaching for it with confidence when it does.
