Skip to content

Object-Oriented Programming

Object-Oriented Programming (OOP) is a way to organize code by grouping data (attributes) and behavior (methods) together inside objects. Instead of writing separate functions and variables, you design small “models” of real-world or logical entities. This approach makes code easier to understand, reuse, and scale, especially in large applications like APIs, frameworks, and libraries.


ConceptMeaning
ClassBlueprint used to create objects
ObjectInstance of a class
AttributeData stored inside object/class
MethodFunction defined inside a class
EncapsulationBinding data + behavior together
InheritanceReusing another class
PolymorphismSame method name, different behavior
AbstractionHiding complexity, exposing essentials

Each of these concepts builds on the idea that we model “things” instead of writing scattered logic.


A class defines structure and behavior, while an object is a real usable instance of that class. When you create an object, Python allocates memory and stores its state separately 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 a mapping of names to values. In OOP, two main namespaces matter: instance (object) namespace and class namespace. Python always searches for attributes in a specific order.

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

Explanation:

  • Tea.origin → class namespace
  • masala.origin → instance namespace (after assignment)
  • Instance value overrides class value

Lookup Order:

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

Python first checks object → then class → then built-in.


Attribute shadowing happens when an instance variable hides a class variable with the same name. This does not delete the class variable, it just overrides it for that object.

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:

  • Instance attribute has higher priority
  • Removing it restores class attribute visibility

self represents the current object instance. It allows each object to store its own data separately. Without self, all objects would share the same variables.

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
  • self is passed automatically when calling cup.describe()

__init__ is automatically called when an object is created. It initializes the object’s state.

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

Explanation:

  • It sets initial values
  • Every object starts with defined state
  • Not required, but commonly used

Inheritance allows one class (child) to reuse another class (parent). It helps avoid code duplication.

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

Explanation:

  • SavingsAccount gets describe() automatically
  • Extends behavior without rewriting code
graph TD Account["Account"] --> Savings["SavingsAccount"] Savings --> Uses["Reuses describe()"]

Composition means building a class using other objects instead of inheriting.

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

Explanation:

  • Bank uses StatementPrinter
  • More flexible than inheritance
  • Easy to replace components

When child class needs parent initialization, use 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 parent method
  • Required in inheritance chains
  • Prevents duplication and errors

MRO defines the order Python follows to search for methods in multiple inheritance.

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
  • Ensures consistent and predictable lookup

A static method does not use instance or class data. It behaves like a normal function but is inside a class.

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

Explanation:

  • No self or cls
  • Used for utility functions

A class method receives the class (cls) instead of instance.

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 for alternative constructors
  • Helps in building libraries (important)

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 ways to create objects

Example:

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

This improves:

  • readability
  • flexibility
  • maintainability

Python does not enforce strict privacy but uses naming conventions.

  • _name → internal use
  • __name → name mangling

Properties allow controlled access.

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:

  • Access like attribute, but logic runs
  • Ensures validation

Child class replaces parent method.

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

Explanation:

  • Same method name
  • Different behavior

Python does not support true overloading, but uses defaults.

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

Explanation:

  • One function handles multiple cases

from warnings import deprecated
class API:
@deprecated("Use new_function() instead")
def old_method(self):
# this warrning 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:

  • Warns users without breaking code
  • Common in frameworks and libraries
  • Helps safe upgrades

Best practice:

  • Keep old method temporarily
  • Add warning
  • Remove later

__del__ is called when an object is about to be destroyed.

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

Explanation:

  • Not reliable for important cleanup
  • Python garbage collection timing is unpredictable

graph TD Class --> Object Object --> Attributes Object --> Methods Methods --> Behavior Attributes --> State Inheritance --> Reuse Composition --> Flexibility MRO --> LookupOrder