Intermediate
18 min read
#Python#OOP#Classes#Inheritance#Polymorphism#Encapsulation#Dunder Methods#Design Patterns

Python OOP (Object-Oriented Programming) — The Complete Notebook

Master Object-Oriented Programming in Python: class & instance lifecycle (__new__ vs __init__), 4 pillars, MRO & super(), dunder methods, __slots__, descriptors, dataclasses, Protocols, and SOLID design patterns.

Python Object-Oriented Programming (OOP)

1. Overview & Core Philosophy#

Python is a multi-paradigm language, but its object model is foundational: everything in Python is an object — including functions, modules, lists, and even primitive types like int and str.

Object-Oriented Programming (OOP) is a programming paradigm based on the concept of objects, which contain data (in the form of attributes or fields) and code (in the form of methods or procedures).

mermaid
graph TD OOP["Python OOP Core Pillars"] OOP --> Enc["1. Encapsulation<br/>(Data Hiding & Properties)"] OOP --> Abs["2. Abstraction<br/>(ABCs & Interfaces)"] OOP --> Inh["3. Inheritance<br/>(Code Reuse & Hierarchy)"] OOP --> Poly["4. Polymorphism<br/>(Duck Typing & Dynamic Dispatch)"]

In Python, functions are first-class objects (instances of function), classes are objects (instances of type), and numbers are objects (instances of int / float).


2. Classes, Objects & The Instance Lifecycle#

2.1 Class Definition & The Role of self#

A class is a blueprint for creating objects. An instance is a concrete object created from that blueprint.

self represents the specific instance of the class upon which a method is called. Python passes this instance automatically as the first argument when calling instance methods.

🐍 Python
class BankAccount: """Blueprint for a standard bank account.""" def __init__(self, account_holder: str, balance: float = 0.0) -> None: # Instance attributes (unique to each instance) self.account_holder = account_holder self.balance = balance def deposit(self, amount: float) -> float: if amount <= 0: raise ValueError("Deposit amount must be positive.") self.balance += amount return self.balance def withdraw(self, amount: float) -> float: if amount > self.balance: raise ValueError("Insufficient funds.") self.balance -= amount return self.balance # Instantiation acc1 = BankAccount("Alice", 1000.0) acc2 = BankAccount("Bob", 250.0) acc1.deposit(500.0) print(acc1.balance) # 1500.0 print(acc2.balance) # 250.0 (independent state) # Under the hood: acc1.deposit(500) is equivalent to: BankAccount.deposit(acc1, 500.0)

2.2 Object Creation: __new__ vs __init__#

Many developers assume __init__ is the constructor, but in Python:

  1. __new__(cls, *args, **kwargs) is the actual constructor / allocator (creates and returns the raw instance).
  2. __init__(self, *args, **kwargs) is the initializer (configures the freshly created instance).
🐍 Python
class ImmutableCoordinate: """Overriding __new__ to customize instance creation before initialization.""" def __new__(cls, x: float, y: float): print(f"1. Allocating memory for {cls.__name__} instance") instance = super().__new__(cls) return instance def __init__(self, x: float, y: float) -> None: print("2. Initializing instance state") self.x = x self.y = y point = ImmutableCoordinate(10.5, 20.0) # Output: # 1. Allocating memory for ImmutableCoordinate instance # 2. Initializing instance state

Use __new__ when:

  • Subclassing immutable types like int, str, or tuple.
  • Implementing Creational patterns like Singletons or Object Pooling.
  • Metaprogramming and custom class factories.

2.3 Class Attributes vs Instance Attributes#

  • Instance attributes: Owned by a specific instance, stored in the instance's __dict__.
  • Class attributes: Owned by the class itself, shared across all instances of that class.
🐍 Python
class ServerNode: # Class attribute (shared by all nodes) cluster_region = "us-east-1" total_nodes = 0 def __init__(self, node_id: str, ip_address: str) -> None: # Instance attributes (isolated per node) self.node_id = node_id self.ip_address = ip_address ServerNode.total_nodes += 1 n1 = ServerNode("node-01", "10.0.0.1") n2 = ServerNode("node-02", "10.0.0.2") print(ServerNode.total_nodes) # 2 print(n1.cluster_region) # "us-east-1" print(n2.cluster_region) # "us-east-1" # CAUTION: Modifying a class attribute via an instance creates an instance shadow variable! n1.cluster_region = "eu-central-1" # Creates n1.__dict__['cluster_region'] print(n1.cluster_region) # "eu-central-1" (instance attribute) print(n2.cluster_region) # "us-east-1" (class attribute unaffected) print(ServerNode.cluster_region) # "us-east-1"

The Mutable Class Attribute Trap: Never assign mutable objects (like lists or dictionaries) as class attributes unless you intentionally want all instances to mutate the exact same shared object!

🐍 Python
# INCORRECT: INCORRECT (Shared mutable state bug) class UserBug: roles = [] # Shared across every instance! # CORRECT: CORRECT class UserCorrect: def __init__(self): self.roles = [] # Unique to each instance

3. The Three Types of Methods: Instance, Class & Static#

Method TypeDecoratorFirst ParameterCan Access / MutatePrimary Use Case
Instance Method(None)selfInstance state (self) + Class state (self.__class__)Standard behaviors and business logic
Class Method@classmethodclsClass state (cls), cannot access selfFactory methods, alternative constructors
Static Method@staticmethod(None)Cannot access self or clsSelf-contained utilities bound to class namespace
🐍 Python
from datetime import date from typing import Self class Employee: base_salary_min = 40_000 def __init__(self, name: str, salary: float, birth_year: int) -> None: self.name = name self.salary = max(salary, Employee.base_salary_min) self.birth_year = birth_year # 1. Instance Method (Operates on self) def calculate_bonus(self, percentage: float) -> float: return self.salary * (percentage / 100) # 2. Class Method (Alternative Constructor / Factory) @classmethod def from_birth_year(cls, name: str, salary: float, birth_year: int) -> Self: """Factory method to construct an employee instance.""" return cls(name=name, salary=salary, birth_year=birth_year) @classmethod def update_minimum_salary(cls, new_min: float) -> None: cls.base_salary_min = new_min # 3. Static Method (Pure function scoped inside the class) @staticmethod def is_valid_age(birth_year: int) -> bool: current_year = date.today().year age = current_year - birth_year return 18 <= age <= 70 # Usage: emp = Employee.from_birth_year("Sarah Connor", 75_000, 1985) print(emp.calculate_bonus(10)) # 7500.0 print(Employee.is_valid_age(1995)) # True

4. Encapsulation & The @property Decorator#

Encapsulation restricts direct access to an object's internal components, preventing accidental modification and keeping interfaces clean.

4.1 Access Modifiers (Naming Conventions)#

Python does not enforce private attributes at compile-time. Instead, it uses intentional naming conventions:

  • Public (var): Accessible everywhere.
  • Protected (_var): Convention indicating internal use; subclasses may access, but external code should not.
  • Private & Name Mangling (__var): Python renames __var to _ClassName__var to prevent accidental name collisions in subclasses.
🐍 Python
class SecureVault: def __init__(self, owner: str, passcode: str) -> None: self.owner = owner # Public self._security_level = 3 # Protected (developer agreement) self.__passcode = passcode # Private (name mangled) def verify_code(self, code: str) -> bool: return self.__passcode == code vault = SecureVault("Batman", "batmobile123") print(vault.owner) # "Batman" print(vault._security_level) # 3 (accessible, but discouraged) # Trying to access __passcode directly raises AttributeError: # print(vault.__passcode) # INCORRECT: AttributeError: 'SecureVault' object has no attribute '__passcode' # Name mangled access (accessible if needed for debugging/serialization): print(vault._SecureVault__passcode) # "batmobile123"

4.2 Getters, Setters & Deleters with @property#

The @property decorator allows you to define methods that can be accessed like attributes while providing validation, lazy evaluation, or computed values.

🐍 Python
class TemperatureSensor: def __init__(self, celsius: float = 0.0) -> None: self._celsius = celsius # Getter @property def celsius(self) -> float: """The temperature in Celsius.""" return self._celsius # Setter with strict data validation @celsius.setter def celsius(self, value: float) -> None: if value < -273.15: raise ValueError("Temperature below absolute zero (-273.15°C) is impossible!") self._celsius = float(value) # Read-only computed property (Fahrenheit) @property def fahrenheit(self) -> float: return (self._celsius * 9 / 5) + 32 # Deleter @celsius.deleter def celsius(self) -> None: print("Resetting sensor reading to 0.0°C") self._celsius = 0.0 sensor = TemperatureSensor(25.0) print(sensor.celsius) # 25.0 print(sensor.fahrenheit) # 77.0 sensor.celsius = 100.0 # Uses setter print(sensor.fahrenheit) # 212.0 try: sensor.celsius = -300 # Raises ValueError except ValueError as e: print(f"Caught error: {e}")

5. Inheritance, Polymorphism & super()#

5.1 Single Inheritance & super()#

Inheritance allows a child class to inherit attributes and methods from a parent class, promoting code reuse.

🐍 Python
class PaymentProcessor: def __init__(self, currency: str = "USD") -> None: self.currency = currency def process_payment(self, amount: float) -> str: raise NotImplementedError("Subclasses must implement process_payment") def refund(self, transaction_id: str) -> str: return f"Refunding transaction {transaction_id} in {self.currency}" class StripeProcessor(PaymentProcessor): def __init__(self, api_key: str, currency: str = "USD") -> None: # Call parent's __init__ using super() super().__init__(currency=currency) self.api_key = api_key def process_payment(self, amount: float) -> str: return f"Charged {amount} {self.currency} via Stripe (API Key: {self.api_key[:4]}***)" class PayPalProcessor(PaymentProcessor): def __init__(self, client_id: str, client_secret: str, currency: str = "USD") -> None: super().__init__(currency=currency) self.client_id = client_id self.client_secret = client_secret def process_payment(self, amount: float) -> str: return f"Charged {amount} {self.currency} via PayPal account {self.client_id}"

5.2 Polymorphism & Duck Typing#

Polymorphism means "many forms". In Python, polymorphism is driven by Duck Typing:

"If it walks like a duck and quacks like a duck, it's a duck."

You do not need an explicit shared base class if the objects conform to the required interface:

🐍 Python
def checkout(processor: PaymentProcessor, amount: float) -> None: # Any object with a .process_payment() method will work seamlessly! result = processor.process_payment(amount) print(f"[SUCCESS] {result}") stripe = StripeProcessor(api_key="sk_live_9482938492") paypal = PayPalProcessor(client_id="paypal_merchant_1", client_secret="secret_xyz") checkout(stripe, 99.99) checkout(paypal, 49.50)

5.3 Multiple Inheritance & Method Resolution Order (MRO)#

Python supports multiple inheritance. The lookup sequence for methods is governed by the C3 Linearization Algorithm (accessible via Class.__mro__ or Class.mro()).

🐍 Python
class LoggerMixin: def log(self, message: str) -> None: print(f"[LOG {self.__class__.__name__}]: {message}") class JSONSerializableMixin: def to_json(self) -> str: import json return json.dumps(self.__dict__) class DatabaseRecord(LoggerMixin, JSONSerializableMixin): def __init__(self, table: str, record_id: int) -> None: self.table = table self.record_id = record_id self.log(f"Initialized record {record_id} in {table}") record = DatabaseRecord("users", 101) print(record.to_json()) print(DatabaseRecord.mro()) # [<class '__main__.DatabaseRecord'>, <class '__main__.LoggerMixin'>, <class '__main__.JSONSerializableMixin'>, <class 'object'>]

The Diamond Problem Resolved with super()

When multiple parent classes inherit from the same grandparent, super() guarantees each class is initialized exactly once in cooperative multiple inheritance:

mermaid
graph TD A["Base Class A"] A --> B["Class B (super)"] A --> C["Class C (super)"] B --> D["Class D"] C --> D
🐍 Python
class A: def action(self): print("A action") class B(A): def action(self): print("B start") super().action() print("B end") class C(A): def action(self): print("C start") super().action() print("C end") class D(B, C): def action(self): print("D start") super().action() print("D end") d = D() d.action() # Output follows MRO: D -> B -> C -> A

6. Abstraction: Abstract Base Classes (ABCs) & Protocols#

6.1 Abstract Base Classes (abc.ABC)#

ABCs enforce that derived subclasses must implement specific abstract methods before they can be instantiated.

🐍 Python
from abc import ABC, abstractmethod class AsyncDatabaseDriver(ABC): """Abstract interface for database connection drivers.""" @abstractmethod async def connect(self, dsn: str) -> None: """Establish database connection.""" pass @abstractmethod async def execute(self, query: str, *params) -> list[dict]: """Execute query and return records.""" pass @property @abstractmethod def is_connected(self) -> bool: """Return connection health status.""" pass class PostgresDriver(AsyncDatabaseDriver): def __init__(self) -> None: self._connected = False async def connect(self, dsn: str) -> None: print(f"Connecting to PostgreSQL at {dsn}") self._connected = True async def execute(self, query: str, *params) -> list[dict]: return [{"id": 1, "query": query}] @property def is_connected(self) -> bool: return self._connected # Attempting to instantiate an incomplete subclass raises TypeError: # class IncompleteDriver(AsyncDatabaseDriver): pass # drv = IncompleteDriver() # INCORRECT: TypeError: Can't instantiate abstract class IncompleteDriver with abstract methods

6.2 Structural Subtyping with typing.Protocol (Static Duck Typing)#

Unlike ABCs which require explicit inheritance (class Sub(ParentABC)), Protocol enables compile-time type-checked duck typing.

🐍 Python
from typing import Protocol, runtime_checkable @runtime_checkable class SupportsRender(Protocol): def render(self) -> str: ... class HTMLCard: def __init__(self, title: str): self.title = title def render(self) -> str: # No inheritance needed! return f"<div class='card'>{self.title}</div>" card = HTMLCard("Dashboard") print(isinstance(card, SupportsRender)) # True (runtime verified!)

7. Magic (Dunder) Methods Reference#

Dunder (Double Underscore) methods allow your custom objects to integrate natively with Python syntax operators and built-in functions.

7.1 String Representation: __repr__ vs __str__#

  • __repr__: Unambiguous developer representation (ideally valid Python code to recreate the object). Called by repr(), interactive consoles, and debugger.
  • __str__: Human-readable representation for end users. Called by str() and print().
🐍 Python
class Vector2D: def __init__(self, x: float, y: float) -> None: self.x = x self.y = y def __repr__(self) -> str: return f"Vector2D(x={self.x!r}, y={self.y!r})" def __str__(self) -> str: return f"({self.x}, {self.y})" v = Vector2D(3.5, 7.2) print(str(v)) # "(3.5, 7.2)" print(repr(v)) # "Vector2D(x=3.5, y=7.2)"

7.2 Equality, Hashing & Comparisons#

To use objects in set or as dict keys, they must implement __eq__ and __hash__.

🐍 Python
from functools import total_ordering @total_ordering # Automatically fills in __le__, __gt__, __ge__ from __eq__ and __lt__ class Task: def __init__(self, title: str, priority: int) -> None: self.title = title self.priority = priority def __eq__(self, other: object) -> bool: if not isinstance(other, Task): return NotImplemented return (self.title, self.priority) == (other.title, other.priority) def __lt__(self, other: object) -> bool: if not isinstance(other, Task): return NotImplemented return self.priority < other.priority def __hash__(self) -> int: return hash((self.title, self.priority)) t1 = Task("Deploy v2", 1) t2 = Task("Deploy v2", 1) t3 = Task("Refactor auth", 2) print(t1 == t2) # True print(t1 < t3) # True print(len({t1, t2})) # 1 (Set deduplication works via hash + eq)

7.3 Operator Overloading#

🐍 Python
class Money: def __init__(self, amount: float, currency: str = "USD") -> None: self.amount = round(amount, 2) self.currency = currency def __add__(self, other: "Money") -> "Money": if not isinstance(other, Money) or self.currency != other.currency: raise TypeError("Cannot add money of different currencies or non-Money types.") return Money(self.amount + other.amount, self.currency) def __sub__(self, other: "Money") -> "Money": if not isinstance(other, Money) or self.currency != other.currency: raise TypeError("Mismatch in currency subtraction.") return Money(self.amount - other.amount, self.currency) def __mul__(self, factor: float) -> "Money": return Money(self.amount * factor, self.currency) def __repr__(self) -> str: return f"{self.currency} {self.amount:.2f}" m1 = Money(150.50) m2 = Money(49.50) print(m1 + m2) # USD 200.00 print(m1 * 2) # USD 301.00

7.4 Container / Collection Emulation#

Implement __len__, __getitem__, __setitem__, and __contains__ to create custom sequence or mapping collections:

🐍 Python
class CustomDataSet: def __init__(self, data: list) -> None: self._data = list(data) def __len__(self) -> int: return len(self._data) def __getitem__(self, index: int | slice): return self._data[index] def __setitem__(self, index: int, value) -> None: self._data[index] = value def __contains__(self, item) -> bool: return item in self._data def __iter__(self): return iter(self._data) ds = CustomDataSet([10, 20, 30, 40, 50]) print(len(ds)) # 5 print(ds[1:4]) # [20, 30, 40] (Slicing supported!) print(30 in ds) # True print([x * 2 for x in ds]) # [20, 40, 60, 80, 100]

7.5 Callable Instances (__call__) & Context Managers (__enter__ / __exit__)#

🐍 Python
import time # 1. Callable instance (Function object / Functor) class ExponentialBackoff: def __init__(self, base_delay: float = 1.0, factor: float = 2.0): self.base_delay = base_delay self.factor = factor self.attempts = 0 def __call__(self) -> float: delay = self.base_delay * (self.factor ** self.attempts) self.attempts += 1 return delay backoff = ExponentialBackoff() print(backoff()) # 1.0 print(backoff()) # 2.0 print(backoff()) # 4.0 # 2. Context Manager Protocol class TimerBlock: def __enter__(self): self.start = time.perf_counter() return self def __exit__(self, exc_type, exc_val, exc_tb): self.elapsed = time.perf_counter() - self.start print(f"Elapsed time: {self.elapsed * 1000:.2f} ms") return False # Do not suppress exceptions with TimerBlock(): sum(range(1_000_000))

8. Memory Optimization with __slots__#

By default, every Python instance stores its attributes in a dynamic dictionary (self.__dict__). While flexible, this adds significant memory overhead (150+ bytes per instance).

Defining __slots__ allocates a fixed array of attribute pointers, reducing memory consumption by 50% to 70% and preventing arbitrary attribute assignment.

🐍 Python
import sys class StandardCoordinate: def __init__(self, x: float, y: float, z: float): self.x = x self.y = y self.z = z class SlottedCoordinate: __slots__ = ("x", "y", "z") # No __dict__ created! def __init__(self, x: float, y: float, z: float): self.x = x self.y = y self.z = z p1 = StandardCoordinate(1.0, 2.0, 3.0) p2 = SlottedCoordinate(1.0, 2.0, 3.0) # Memory footprint comparison: print(f"Standard instance + dict size: {sys.getsizeof(p1) + sys.getsizeof(p1.__dict__)} bytes") print(f"Slotted instance size: {sys.getsizeof(p2)} bytes") # Disallows dynamic attribute attachment: # p2.label = "Origin" # INCORRECT: AttributeError: 'SlottedCoordinate' object has no attribute 'label'

When creating millions of lightweight data objects (e.g., in data processing pipelines, geometry engines, graph nodes), always consider __slots__ or @dataclass(slots=True).


9. Descriptors Protocol: The Engine Behind @property and Methods#

A descriptor is any object that implements at least one of __get__, __set__, or __delete__. Descriptors customize attribute access lookup.

🐍 Python
class PositiveNumber: """Descriptor that enforces positive numeric values.""" def __set_name__(self, owner, name): self.public_name = name self.private_name = f"_{name}" def __get__(self, instance, owner): if instance is None: return self return getattr(instance, self.private_name, 0.0) def __set__(self, instance, value): if not isinstance(value, (int, float)) or value <= 0: raise ValueError(f"'{self.public_name}' must be a positive number.") setattr(instance, self.private_name, value) class Product: # Descriptors attached at class level price = PositiveNumber() weight = PositiveNumber() def __init__(self, name: str, price: float, weight: float): self.name = name self.price = price self.weight = weight item = Product("Laptop", 1299.99, 1.8) print(item.price) # 1299.99 try: item.price = -50 # INCORRECT: ValueError: 'price' must be a positive number. except ValueError as e: print(e)

10. Modern Python: @dataclass#

Introduced in Python 3.7 (and enhanced in 3.10+), @dataclass eliminates boilerplate code for __init__, __repr__, __eq__, and comparisons.

🐍 Python
from dataclasses import dataclass, field from typing import List @dataclass(order=True, slots=True) class MLModelArtifact: # Sort order compares fields in declared sequence accuracy: float model_name: str = field(compare=False) parameters_count: int = field(compare=False) tags: List[str] = field(default_factory=list, compare=False) def __post_init__(self): """Validation or post-processing executed after generated __init__.""" if not (0.0 <= self.accuracy <= 1.0): raise ValueError("Accuracy must be between 0.0 and 1.0") m1 = MLModelArtifact(accuracy=0.945, model_name="ResNet50", parameters_count=25_000_000) m2 = MLModelArtifact(accuracy=0.982, model_name="ViT-Large", parameters_count=300_000_000) print(m1) # MLModelArtifact(accuracy=0.945, model_name='ResNet50', parameters_count=25000000, tags=[]) print(m2 > m1) # True (Compared by accuracy automatically!)

When to Use What?#

FeatureStandard class@dataclasstyping.NamedTuplePydantic BaseModel
Primary GoalCustom stateful logic & behaviorsClean data carrier with methodsImmutable tuple with named fieldsComplex serialization & API parsing
MutabilityMutableConfigurable (frozen=True)ImmutableConfigurable
OverheadStandardVery low (slots=True)Ultra low (C-tuple based)Parsing/Validation overhead
ValidationManual in __init____post_init__None at runtimeRich built-in validators

11. SOLID Principles in Python#

The SOLID principles guide clean, maintainable, and scalable object-oriented software design:

1. Single Responsibility Principle (SRP)#

A class should have one, and only one, reason to change.

🐍 Python
# INCORRECT: BAD: Class handles data management, formatting, and disk persistence class ReportBad: def generate_data(self): ... def format_html(self): ... def save_to_s3(self): ... # CORRECT: GOOD: Separated into dedicated components class ReportData: def fetch_metrics(self) -> dict: ... class ReportHTMLFormatter: def format(self, data: dict) -> str: ... class S3Uploader: def upload(self, content: str, bucket: str) -> None: ...

2. Open/Closed Principle (OCP)#

Classes should be open for extension, but closed for modification.

🐍 Python
from abc import ABC, abstractmethod class DiscountStrategy(ABC): @abstractmethod def apply_discount(self, total: float) -> float: pass class RegularDiscount(DiscountStrategy): def apply_discount(self, total: float) -> float: return total class VIPDiscount(DiscountStrategy): def apply_discount(self, total: float) -> float: return total * 0.80 # 20% discount # Adding a new discount does not modify existing checkout logic! class BlackFridayDiscount(DiscountStrategy): def apply_discount(self, total: float) -> float: return total * 0.50

3. Liskov Substitution Principle (LSP)#

Subtypes must be substitutable for their base types without breaking client code.

🐍 Python
# INCORRECT: BAD: Square breaks Rectangle's behavioral invariants class Rectangle: def set_width(self, w: float): self.w = w def set_height(self, h: float): self.h = h class SquareBad(Rectangle): def set_width(self, w: float): self.w = self.h = w # CORRECT: GOOD: Use common geometric shape abstraction class Shape(ABC): @abstractmethod def area(self) -> float: pass

4. Interface Segregation Principle (ISP)#

Clients should not be forced to depend on interfaces they do not use.

🐍 Python
# INCORRECT: BAD: Fat monolithic interface class Worker(ABC): @abstractmethod def code(self): pass @abstractmethod def test(self): pass @abstractmethod def design_ui(self): pass # CORRECT: GOOD: Focused role interfaces class Programmer(ABC): @abstractmethod def code(self): pass class Tester(ABC): @abstractmethod def test(self): pass

5. Dependency Inversion Principle (DIP)#

High-level modules should not depend on low-level modules; both should depend on abstractions.

🐍 Python
class NotificationSender(ABC): @abstractmethod def send(self, recipient: str, message: str) -> None: pass class EmailSender(NotificationSender): def send(self, recipient: str, message: str) -> None: print(f"Sending Email to {recipient}: {message}") class OrderService: # Injects abstraction rather than hardcoding concrete EmailSender def __init__(self, notifier: NotificationSender) -> None: self.notifier = notifier def complete_order(self, customer_email: str, order_id: str) -> None: # Business logic... self.notifier.send(customer_email, f"Order #{order_id} confirmed!")

12. Classic OOP Design Patterns in Python#

12.1 Singleton Pattern (Thread-Safe Metaclass)#

Ensures a class has only one instance and provides a global point of access.

🐍 Python
import threading class SingletonMeta(type): """Thread-safe Singleton implementation via metaclass.""" _instances = {} _lock: threading.Lock = threading.Lock() def __call__(cls, *args, **kwargs): with cls._lock: if cls not in cls._instances: instance = super().__call__(*args, **kwargs) cls._instances[cls] = instance return cls._instances[cls] class ApplicationConfig(metaclass=SingletonMeta): def __init__(self) -> None: self.database_url = "postgresql://localhost:5432/production" self.debug_mode = False c1 = ApplicationConfig() c2 = ApplicationConfig() print(c1 is c2) # True (Exact same object in memory)

12.2 Factory Pattern#

Provides an interface for creating objects in a superclass while allowing subclasses to alter the type of objects that will be created.

🐍 Python
class StorageService(ABC): @abstractmethod def save(self, filename: str, data: bytes) -> str: pass class S3Storage(StorageService): def save(self, filename: str, data: bytes) -> str: return f"s3://my-bucket/{filename}" class LocalStorage(StorageService): def save(self, filename: str, data: bytes) -> str: return f"/var/data/uploads/{filename}" class StorageFactory: @staticmethod def get_storage(environment: str) -> StorageService: match environment.lower(): case "production" | "cloud": return S3Storage() case "development" | "local": return LocalStorage() case _: raise ValueError(f"Unknown storage environment: {environment}") storage = StorageFactory.get_storage("production") print(storage.save("avatar.png", b"...")) # "s3://my-bucket/avatar.png"

13. Summary & Quick Reference Cheat Sheet#

TaskSyntax / MethodPurpose
Constructor allocationdef __new__(cls, *args)Allocates and returns new object memory
Instance initializationdef __init__(self, *args)Sets initial attribute state
Alternative constructor@classmethod def factory(cls)Returns a new instance from alternative arguments
Namespaced utility@staticmethod def helper()Independent helper function tied to class
Encapsulated property@property / @x.setterComputed/validated attribute access
Developer printoutdef __repr__(self)Unambiguous debug string (repr(obj))
User displaydef __str__(self)Friendly formatted string (str(obj))
Callable instancedef __call__(self, *args)Allows calling instance like a function (obj())
Context manager__enter__ and __exit__Manages resources with with blocks
Memory optimization__slots__ = ("a", "b")Prevents __dict__ overhead
Explicit contractsfrom abc import ABC, abstractmethodEnforces subclass implementation
Structural typingfrom typing import ProtocolStatic Duck Typing for interface checking
Knowledge Checkpoint

Object-Oriented Programming (OOP) Checkpoint

Q1.In Python's object creation lifecycle, what is the key difference between `__new__` and `__init__`?
A`__new__` is the constructor/allocator that creates and returns the raw object instance, whereas `__init__` is the initializer that configures instance state.
B`__init__` allocates the memory, whereas `__new__` is only called during garbage collection.
C`__new__` is called only for immutable types like tuples; mutable classes only use `__init__`.
DThere is no difference; they are aliases for each other.
Q2.What is the primary architectural and performance benefit of defining `__slots__ = ('x', 'y')` inside a Python class?
AIt forces attributes to be strictly type-checked at runtime by CPython.
BIt prevents the creation of a per-instance `__dict__`, reducing memory footprint by up to 60% and speeding up attribute access.
CIt makes the class thread-safe automatically.
DIt automatically serializes class instances to JSON.
Q3.Which algorithm does Python use to determine the Method Resolution Order (MRO) in multiple inheritance hierarchies?
ADepth-First Search (DFS) with loop detection
BC3 Linearization Algorithm
CBreadth-First Search (BFS) priority queue
DDijkstra's shortest path
Q4.What protocol methods make a class a Python Descriptor?
A`__iter__` and `__next__`
B`__enter__` and `__exit__`
C`__get__`, `__set__`, and/or `__delete__`
D`__call__` and `__hash__`
Track Your Learning

Finished studying this notebook?

Mark this guide as completed to update your course progress roadmap.