Skip to content

Object-Oriented Programming

Object-Oriented Programming (OOP) is a way to write code by grouping related data and actions together inside something called an “object”. Instead of writing lots of separate functions and variables floating around, you create small models that represent real things - like a bank account, a cup of tea, or a user. This makes your code cleaner, easier to understand, and easier to grow over time, especially in bigger projects like apps and libraries.

ConceptWhat it means
ClassA blueprint or template to create objects
ObjectA real thing created from a class
AttributeA piece of data stored inside an object or class
MethodA function that lives inside a class
EncapsulationKeeping data and actions together in one place
InheritanceOne class borrowing from another class
PolymorphismDifferent classes using the same method name but doing different things
AbstractionHiding the complex stuff and showing only what matters

Each of these ideas is about modeling “things” instead of writing scattered, unorganized code.

A class is like a template or a cookie cutter. An object is the actual cookie made from that cutter. When you create an object, Python gives it its own space in memory to store its own data, separate from other objects.

class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
def summary(self):
return f"{self.owner}: {self.balance}"
account = BankAccount("Asha", 1000)
account.deposit(250)
print(account.summary())

Explanation:

  • BankAccount is the blueprint.
  • account is a real object with its own data.
  • Methods operate on that object’s data.
graph TD Class["Class: BankAccount"] --> Object["Object: account"] Object --> A1["owner = 'Asha'"] Object --> A2["balance = 1250"] Object --> M["Methods: deposit(), summary()"]

A namespace is just a list that maps names to values - like a dictionary. In OOP, there are two main namespaces you care about: one for the object (instance) and one for the class. When you look up a name, Python checks them in a specific order.

class Tea:
origin = "India"
masala = Tea()
masala.origin = "Nepal"

Explanation:

  • Tea.origin -> lives in the class namespace
  • masala.origin -> lives in the object’s own namespace (after you set it)
  • The object’s value wins over the class value

Lookup Order:

graph TD Object["Object namespace"] --> Class["Class namespace"] --> Builtin["Built-in namespace"]

Python first checks the object, then the class, then Python’s built-in stuff.

Attribute shadowing is when an object creates its own variable with the same name as a class variable. The object’s version covers up the class version - but the class version is still there, untouched.

class Tea:
temperature = "hot"
cup = Tea()
print(cup.temperature) # hot
cup.temperature = "warm"
print(cup.temperature) # warm
print(Tea.temperature) # hot
del cup.temperature
print(cup.temperature) # hot again

Explanation:

  • The object’s attribute takes priority when you look it up
  • If you delete the object’s attribute, Python falls back to the class attribute

self is a way for each object to refer to itself. It lets each object store its own separate data. Without self, all objects of the same class would share the same variables, which would cause a lot of bugs.

class Cup:
def __init__(self, size):
self.size = size
def describe(self):
return f"A {self.size}ml cup"

Explanation:

  • self.size belongs to that specific object, not all cups
  • When you call cup.describe(), Python automatically passes the object in as self

__init__ is a special method that runs automatically when you create a new object. It sets up the object’s starting state - kind of like filling out a form when you sign up for something.

class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance

Explanation:

  • It gives the object its starting values
  • Every object begins with a known, defined state
  • You don’t have to use it, but most classes do

Inheritance lets one class (the child) copy and extend another class (the parent). This way you don’t have to rewrite the same code twice.

class Account:
def describe(self):
return "Base account"
class SavingsAccount(Account):
def withdraw(self, amount):
return f"Withdrawing {amount}"

Explanation:

  • SavingsAccount automatically gets the describe() method from Account
  • You can add new features without rewriting the old ones
graph TD Account["Account"] --> Savings["SavingsAccount"] Savings --> Uses["Reuses describe()"]

Composition means building a class by putting other objects inside it, instead of inheriting from them. It’s like building a car by plugging in an engine, rather than making a “CarEngine” that inherits from both.

class StatementPrinter:
def print_summary(self, account):
return account.describe()
class Bank:
def __init__(self):
self.printer = StatementPrinter()

Explanation:

  • Bank uses StatementPrinter as a tool
  • This is more flexible than inheritance
  • You can easily swap one component for another

When a child class has its own __init__, it needs to call the parent’s __init__ too, or the parent’s setup gets skipped. You do that with super().

class BaseAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
class SuperAccount(BaseAccount):
def __init__(self, owner, balance):
super().__init__(owner, balance)

Explanation:

  • super() calls the parent class’s method
  • This is important when you have a chain of classes inheriting from each other
  • It avoids repeating the same setup code

MRO is the order Python follows when looking for a method, especially when a class inherits from multiple parents. Python figures this out automatically using a set of rules called C3 linearization.

class A: pass
class B(A): pass
class C(A): pass
class D(C, B): pass
print(D.__mro__)

Output:

D -> C -> B -> A -> object
graph TD A["A"] --> B["B"] A["A"] --> C["C"] B["B"] --> D["D"] C["C"] --> D["D"]

Explanation:

  • Python uses C3 linearization to figure this out
  • This makes sure there’s always one clear, predictable order to search in

A static method is just a regular function that you put inside a class because it logically belongs there. It doesn’t need access to the object or the class.

class TextTools:
@staticmethod
def clean(text):
return [item.strip() for item in text.split(",")]

Explanation:

  • No self or cls needed
  • Used for helper or utility functions that are related to the class

A class method gets the class itself passed in (as cls), instead of the object. This is useful when you want to create objects in different ways.

class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
@classmethod
def from_string(cls, text):
owner, balance = text.split("-")
return cls(owner, int(balance))

Explanation:

  • Used to give users an alternative way to create objects
  • Very useful when building libraries

Why class methods are important in libraries

Section titled “Why class methods are important in libraries”

When building libraries:

  • You don’t want users to always call __init__ manually
  • You provide clean, easy ways to create objects

Example:

user = User.from_json(data)
user = User.from_db(row)

This improves:

  • readability
  • flexibility
  • maintainability

Python doesn’t block you from accessing any variable, but it uses naming rules to signal what should stay private.

  • _name -> meant for internal use only (soft warning)
  • __name -> Python changes the name internally to make it harder to access by accident (called name mangling)

Properties let you control how someone reads or writes a value.

class Account:
def __init__(self, name):
self._name = name
self._balance = 0
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, amount):
if amount >= 0:
self._balance = amount

Explanation:

  • You access balance like a normal variable, but behind the scenes, logic runs
  • This lets you add checks like “balance can’t be negative”

Overriding is when a child class replaces a method it got from the parent with its own version.

class A:
def message(self):
return "A message"
class B(A):
def message(self):
return "B message"

Explanation:

  • Both classes have a method called message
  • But each one does something different

Python doesn’t support true overloading (having multiple functions with the same name but different inputs). Instead, you handle it with default values.

def add(a, b=0, c=0):
return a + b + c

Explanation:

  • One function handles multiple cases depending on how many arguments you pass
from warnings import deprecated
class API:
@deprecated("Use new_function() instead")
def old_method(self):
# this warning is optional, but helps users know about deprecation and encourages them to switch to the new method
warnings.warn(
"old_method is deprecated, use new_method instead",
DeprecationWarning,
stacklevel=2
)
def new_method(self):
return "new"

Explanation:

  • Shows a warning when someone uses the old method, without breaking their code
  • Very common in frameworks and libraries when updating to new versions
  • Gives users time to switch over before you remove the old method

Best practice:

  • Keep the old method around for a while
  • Add a warning message
  • Remove it later once everyone has moved on

__del__ is a special method that Python calls just before an object gets deleted from memory. Think of it as a goodbye function.

class Test:
def __del__(self):
print("Object destroyed")

Explanation:

  • You can’t rely on this for important cleanup because you don’t control exactly when Python deletes objects
  • Python’s garbage collector decides the timing, which can be unpredictable
graph TD Class --> Object Object --> Attributes Object --> Methods Methods --> Behavior Attributes --> State Inheritance --> Reuse Composition --> Flexibility MRO --> LookupOrder