Python OOP — Complete Study Guide
Scope: Fundamentals → Advanced concepts → Design patterns → Best practices → SOLID →
Testing → Capstone project.
Table of contents
1. Introduction to OOP
2. Classes, objects, attributes, methods
3. The self keyword and __init__
4. Encapsulation: public, protected, private
5. Properties and descriptors
6. Class variables vs instance variables
7. Classmethods and staticmethods
8. Inheritance: single, multiple, MRO
9. Composition vs inheritance
10. Polymorphism & duck typing
11. Abstract Base Classes (ABC)
12. Magic methods / dunder methods (special methods)
13. Operator overloading
14. Context managers and with protocol
15. Decorators (function & class) and the decorator pattern
16. Metaclasses
17. dataclasses and attrs
18. __slots__ and memory optimization
19. Design patterns (creational, structural, behavioral) with examples
20. SOLID principles in Python
21. Testing and debugging OOP code
22. Concurrency considerations for OOP
23. Performance tips and common pitfalls
24. Capstone Project: Library Management System (detailed)
25. Further reading & exercises
1. Introduction to OOP
Object-oriented programming models software as a collection of interacting objects. Each object bundles
state (attributes) and behavior (methods). OOP concepts help organize complex systems, promote reuse,
and provide clearer abstractions.
Benefits:
• Encapsulation (grouping related data + behavior)
1
• Abstraction (hide complexity)
• Reuse via inheritance/composition
• Polymorphism (different objects responding to same interface)
2. Classes, objects, attributes, methods
class Dog:
"""Simple Dog class demo."""
# class attribute (shared across instances)
species = "Canis familiaris"
def __init__(self, name, age):
# instance attributes (unique to each instance)
self.name = name # dog's name
self.age = age
# dog's age
def bark(self):
# instance method — operates on 'self'
return f"{self.name} says woof!"
# Usage
fido = Dog("Fido", 3)
print(fido.bark())
# create an object (instance)
# Fido says woof!
Line-by-line explanation:
• class Dog: — defines a new class named Dog .
• species = ... — class attribute accessible as Dog.species or fido.species .
• def __init__(self, name, age): — initializer called when creating a new instance.
• self.name = name — store the argument on the instance.
• def bark(self): — an instance method; self refers to the instance.
3. The self keyword and __init__
self is the conventional name for the first parameter of instance methods. It refers to this instance.
__init__ initializes the object after it's created by __new__ .
Example with validation:
class Person:
def __init__(self, name: str, age: int):
2
# basic validation
if not isinstance(name, str):
raise TypeError("name must be a string")
if age < 0:
raise ValueError("age must be non-negative")
self.name = name
self.age = age
p = Person("Ada", 36)
4. Encapsulation: public, protected, private
Python does not have enforced access modifiers, but conventions exist:
• Public: normal names obj.x
• Protected (convention): _name — hint for "internal use"
• Private (name-mangling): __name becomes _ClassName__name
Example:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
# public
self._transactions = []
# protected-ish by convention
self.__balance = balance
# name-mangled private
def deposit(self, amount):
if amount <= 0:
raise ValueError("deposit positive amount")
self.__balance += amount
self._transactions.append(("deposit", amount))
def get_balance(self):
# public accessor for private balance
return self.__balance
Note: Name-mangling prevents accidental access but is not real security.
5. Properties and descriptors
@property makes a method act like an attribute — great for computed attributes and validation.
3
class Temperature:
def __init__(self, celsius=0):
self._celsius = celsius
@property
def celsius(self):
"""Getter for celsius."""
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
# computed property — no setter (read-only)
return self._celsius * 9/5 + 32
t = Temperature(25)
print(t.fahrenheit)
# 77.0
Descriptors: objects implementing __get__ , __set__ , __delete__ — property is a descriptor.
Short custom descriptor example:
class Typed:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__[self.name]
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"{self.name} must be {self.expected_type}")
instance.__dict__[self.name] = value
class Person:
name = Typed("name", str)
age = Typed("age", int)
4
def __init__(self, name, age):
self.name = name
self.age = age
p = Person("Ada", 36)
6. Class variables vs instance variables
• Class variable: declared in class body; shared by instances.
• Instance variable: stored on self ; unique per instance.
Pitfall example — mutable class variable:
class MyClass:
items = []
# shared across all instances — usually a bug
def add(self, x):
self.items.append(x)
a = MyClass()
b = MyClass()
a.add(1)
print(b.items)
# [1] — surprising
Use None or initialize in __init__ instead.
7. Classmethods and staticmethods
class MyCounter:
count = 0
def __init__(self):
MyCounter.count += 1
@classmethod
def get_count(cls):
# receives the class as first argument
return cls.count
@staticmethod
def is_positive(n):
5
# no implicit first argument; utility function inside class namespace
return n > 0
When to use classmethod: alternative constructors or behaviour that depends on class. Staticmethod:
namespaced utility.
8. Inheritance: single, multiple, MRO
class Animal:
def speak(self):
raise NotImplementedError
class Dog(Animal):
def speak(self):
return "woof"
class Cat(Animal):
def speak(self):
return "meow"
Multiple inheritance & MRO (method resolution order):
class A:
def ping(self):
return "A"
class B(A):
def ping(self):
return "B"
class C(A):
def ping(self):
return "C"
class D(B, C):
pass
# D().ping() will follow D -> B -> C -> A resolution order
Use D.__mro__ to inspect MRO.
6
9. Composition vs inheritance
Prefer composition when behaviour can be delegated to a contained object. Inheritance expresses an "is-a"
relationship; composition expresses "has-a".
class Engine:
def start(self):
return "engine started"
class Car:
def __init__(self, engine: Engine):
self.engine = engine # composition/dependency injection
def start(self):
return self.engine.start()
10. Polymorphism & duck typing
Polymorphism = different types responding to the same method. Pythonic style: duck typing — "if it quacks
like a duck...".
class Sparrow:
def fly(self):
return "flying"
class Airplane:
def fly(self):
return "jetting"
def let_it_fly(obj):
# works for any object that implements fly()
print(obj.fly())
let_it_fly(Sparrow())
let_it_fly(Airplane())
11. Abstract Base Classes (ABC)
Use abc module to define interfaces and ensure subclasses implement methods.
7
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Circle(Shape):
def __init__(self, r):
self.r = r
def area(self):
import math
return math.pi * self.r ** 2
Trying to instantiate Shape() will fail; subclass must implement area .
12. Magic methods / dunder methods
__repr__ , __str__ , __len__ , __iter__ , __next__ , __getitem__ , __setitem__ , etc. Let
your objects integrate with Python idioms.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x!r}, {self.y!r})"
def __str__(self):
return f"({self.x}, {self.y})"
def __len__(self):
return 2
13. Operator overloading
Implement operators using dunder methods, e.g. __add__ , __mul__ , __eq__ .
class Vector:
def __init__(self, x, y):
8
self.x = x
self.y = y
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
Return NotImplemented for unsupported operand types to allow Python to try reversed operations.
14. Context managers and with protocol
Implement a context manager using __enter__ / __exit__ or contextlib.contextmanager .
class ManagedFile:
def __init__(self, filename):
self.filename = filename
def __enter__(self):
self.file = open(self.filename, 'w')
return self.file
def __exit__(self, exc_type, exc, tb):
self.file.close()
# returning False will re-raise any exception
with ManagedFile("/tmp/test.txt") as f:
f.write("hello")
Use contextlib for simpler cases.
15. Decorators (function & class) and the decorator
pattern
Function decorator example (logging):
9
from functools import wraps
def log_calls(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"Calling {fn.__name__} with {args} {kwargs}")
result = fn(*args, **kwargs)
print(f"{fn.__name__} returned {result}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(2, 3)
Class decorator example: adding attributes or registering classes in a registry.
Desi
10