Chapter 19: OOP in Depth — Encapsulation and Special Methods
You know how to create classes, add methods, and use inheritance. That's the foundation. This chapter goes deeper — into the features that make Python OOP genuinely powerful and elegant.
You'll learn how to protect data inside objects, how to use properties for controlled access, how to make your objects work with Python's built-in operators (+, [], len(), in, comparisons), how to make objects iterable, how to write class and static methods, and how dataclasses can save you from writing boilerplate.
By the end of this chapter, your classes will feel like native Python types — not just containers for functions.
Encapsulation — Protecting Data
Encapsulation means bundling data and the code that works on it together, and controlling access from outside.
In Python this is done by convention, not enforcement. There are two levels:
Single underscore _name — "private by convention"
A single underscore signals: "this is an implementation detail — don't use it from outside the class." Python won't stop you, but it's a clear message.
class Temperature:
def __init__(self, celsius):
self._celsius = celsius # _celsius: treat as private
temp = Temperature(25)
print(temp._celsius) # works, but you're breaking the contract
Double underscore __name — name mangling
A double underscore triggers name mangling — Python renames the attribute to _ClassName__name. This makes accidental access harder (but not impossible).
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.__balance = balance # name-mangled to _BankAccount__balance
def get_balance(self):
return self.__balance
def deposit(self, amount):
if amount > 0:
self.__balance += amount
account = BankAccount("Alice", 1000)
print(account.get_balance()) # 1000 [x]
# Direct access fails
# print(account.__balance) # AttributeError
print(account._BankAccount__balance) # 1000 — still accessible if you know the mangled name
Use __ sparingly — mainly when you're writing a library and want to prevent accidental name clashes in subclasses. For most code, _name convention is sufficient.
Properties — Controlled Attribute Access
A property lets you expose an attribute-like interface while running code behind the scenes. Callers write obj.balance (no parentheses), but you control what happens when they read, write, or delete it.
@property — A readable "attribute"
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""The radius of the circle."""
return self._radius
@property
def diameter(self):
return self._radius * 2
@property
def area(self):
import math
return math.pi * self._radius ** 2
@property
def circumference(self):
import math
return 2 * math.pi * self._radius
c = Circle(5)
print(c.radius) # 5
print(c.diameter) # 10
print(f"{c.area:.2f}") # 78.54
c.radius = 10 # AttributeError — no setter defined yet
@property.setter — Allow controlled writing
class Circle:
def __init__(self, radius):
self.radius = radius # calls the setter immediately
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError(f"Radius must be positive, got {value}.")
self._radius = value
@property
def area(self):
import math
return math.pi * self._radius ** 2
def __str__(self):
return f"Circle(radius={self._radius})"
c = Circle(5)
print(c.radius) # 5
c.radius = 10
print(c.radius) # 10
c.radius = -3 # ValueError: Radius must be positive, got -3.
The setter validates before storing. The caller just writes c.radius = 10 — clean and natural. The validation is invisible.
@property.deleter — Handle deletion
class Connection:
def __init__(self, host):
self._host = host
self._socket = None
@property
def host(self):
return self._host
@host.deleter
def host(self):
print(f"Disconnecting from {self._host}...")
self._host = None
self._socket = None
conn = Connection("example.com")
del conn.host # Disconnecting from example.com...
Computed properties
Properties are perfect for values that should be calculated rather than stored:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
@property
def perimeter(self):
return 2 * (self.width + self.height)
@property
def is_square(self):
return self.width == self.height
def __repr__(self):
return f"Rectangle({self.width}, {self.height})"
r = Rectangle(4, 6)
print(r.area) # 24
print(r.perimeter) # 20
print(r.is_square) # False
r.width = 6
print(r.is_square) # True — recalculated from current values
area, perimeter, and is_square are always fresh — they're computed from the current width and height each time. No risk of stale data.
Your turn: Build a Temperature class that stores the value in Celsius internally. Add celsius, fahrenheit, and kelvin as properties. Add setters for celsius and fahrenheit (both should update _celsius). Make it impossible to set a temperature below absolute zero (-273.15°C).
Dunder (Magic) Methods — Making Objects Feel Native
Dunder methods (double underscore on both sides — __method__) let your objects work with Python's built-in operators and functions. When Python sees a + b, it calls a.__add__(b). When it sees len(x), it calls x.__len__(). You define these methods to make your objects behave naturally.
__len__ — len() support
class Playlist:
def __init__(self, name):
self.name = name
self._songs = []
def add(self, song):
self._songs.append(song)
def __len__(self):
return len(self._songs)
def __str__(self):
return f"Playlist({self.name!r}, {len(self)} songs)"
p = Playlist("Favourites")
p.add("Bohemian Rhapsody")
p.add("Hotel California")
p.add("Stairway to Heaven")
print(len(p)) # 3
print(p) # Playlist('Favourites', 3 songs)
__getitem__ and __setitem__ — Index access []
class Playlist:
def __init__(self, name):
self.name = name
self._songs = []
def add(self, song):
self._songs.append(song)
def __len__(self):
return len(self._songs)
def __getitem__(self, index):
return self._songs[index]
def __setitem__(self, index, value):
self._songs[index] = value
def __delitem__(self, index):
del self._songs[index]
p = Playlist("Favourites")
p.add("Song A")
p.add("Song B")
p.add("Song C")
print(p[0]) # Song A
print(p[-1]) # Song C
print(p[1:3]) # ['Song B', 'Song C'] — slicing works too!
p[0] = "Song D"
print(p[0]) # Song D
del p[1]
print(len(p)) # 2
Once you define __getitem__, Python also gives you slicing, reverse iteration, and for item in obj automatically.
__contains__ — in operator
def __contains__(self, song):
return song in self._songs
p = Playlist("Favourites")
p.add("Bohemian Rhapsody")
print("Bohemian Rhapsody" in p) # True
print("Thriller" in p) # False
__iter__ and __next__ — Making objects iterable
Define __iter__ to make your object work in for loops:
class Playlist:
def __init__(self, name):
self.name = name
self._songs = []
def add(self, song):
self._songs.append(song)
def __iter__(self):
return iter(self._songs) # delegate to the list's iterator
def __len__(self):
return len(self._songs)
p = Playlist("Favourites")
p.add("Song A")
p.add("Song B")
p.add("Song C")
for song in p:
print(song)
# Also works with list comprehensions, zip, enumerate, etc.
titles = [song.upper() for song in p]
print(titles) # ['SONG A', 'SONG B', 'SONG C']
For a custom iterator that tracks state, implement both __iter__ (returns self) and __next__ (returns the next value, raises StopIteration when done):
class Countdown:
def __init__(self, start):
self.start = start
self.current = start
def __iter__(self):
self.current = self.start # reset on each iteration
return self
def __next__(self):
if self.current < 0:
raise StopIteration
value = self.current
self.current -= 1
return value
for n in Countdown(5):
print(n, end=" ") # 5 4 3 2 1 0
Arithmetic operators
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar):
return self.__mul__(scalar) # handles scalar * vector
def __neg__(self):
return Vector(-self.x, -self.y)
def __abs__(self):
return (self.x**2 + self.y**2) ** 0.5
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(2, 3)
v2 = Vector(1, 4)
print(v1 + v2) # Vector(3, 7)
print(v1 - v2) # Vector(1, -1)
print(v1 * 3) # Vector(6, 9)
print(3 * v1) # Vector(6, 9) — via __rmul__
print(-v1) # Vector(-2, -3)
print(abs(v1)) # 3.605551275...
print(v1 == v2) # False
Comparison operators
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
def __eq__(self, other):
return self.price == other.price
def __lt__(self, other):
return self.price < other.price
def __le__(self, other):
return self.price <= other.price
def __gt__(self, other):
return self.price > other.price
def __ge__(self, other):
return self.price >= other.price
def __repr__(self):
return f"Product({self.name!r}, ${self.price:.2f})"
apple = Product("Apple", 1.20)
banana = Product("Banana", 0.50)
cherry = Product("Cherry", 3.00)
print(apple > banana) # True
print(cherry < apple) # False
print(apple == apple) # True
products = [cherry, apple, banana]
products.sort() # sort() uses __lt__
print(products) # [Product('Banana', $0.50), Product('Apple', $1.20), Product('Cherry', $3.00)]
print(min(products)) # Product('Banana', $0.50)
print(max(products)) # Product('Cherry', $3.00)
Once you define comparison dunders, sort(), min(), max(), and sorted() all work on your objects automatically.
Tip: Instead of implementing all six comparison methods manually, use @functools.total_ordering. Define __eq__ and one of __lt__, __le__, __gt__, __ge__, and Python fills in the rest:
from functools import total_ordering
@total_ordering
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
def __eq__(self, other):
return self.price == other.price
def __lt__(self, other):
return self.price < other.price
__call__ — Making objects callable
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
double = Multiplier(2)
triple = Multiplier(3)
print(double(5)) # 10
print(triple(7)) # 21
numbers = [1, 2, 3, 4, 5]
doubled = list(map(double, numbers))
print(doubled) # [2, 4, 6, 8, 10]
double is an object, but it acts like a function. You can use it anywhere a function is expected.
__enter__ and __exit__ — Context managers
Define these to make your object work with with:
class Timer:
"""Context manager that times a block of code."""
import time as _time
def __enter__(self):
self._start = self._time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
elapsed = self._time.time() - self._start
print(f"Elapsed: {elapsed:.4f} seconds")
return False # don't suppress exceptions
with Timer():
total = sum(n**2 for n in range(1_000_000))
print(f"Sum: {total}")
Output:
Sum: 333332833333500000
Elapsed: 0.0842 seconds
The __exit__ method receives exception info if one occurred. Return True to suppress the exception, False (or None) to let it propagate.
Class Methods and Static Methods
@classmethod — Works on the class, not an instance
A class method receives the class itself as the first argument (conventionally named cls), not an instance. Use it for alternative constructors — different ways to create an object.
class Date:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
@classmethod
def from_string(cls, date_string):
"""Create a Date from a string like '2026-03-09'."""
year, month, day = map(int, date_string.split("-"))
return cls(year, month, day)
@classmethod
def today(cls):
"""Create a Date for today."""
from datetime import date
d = date.today()
return cls(d.year, d.month, d.day)
def __repr__(self):
return f"Date({self.year}, {self.month:02d}, {self.day:02d})"
def __str__(self):
return f"{self.year}-{self.month:02d}-{self.day:02d}"
d1 = Date(2026, 3, 9)
d2 = Date.from_string("2026-07-04")
d3 = Date.today()
print(d1) # 2026-03-09
print(d2) # 2026-07-04
print(d3) # today's date
from_string and today are alternative ways to create a Date. They're on the class because they don't need an existing instance — they create an instance.
@staticmethod — Belongs to the class, needs neither self nor cls
A static method is a plain function that lives in the class's namespace for organizational reasons. It receives neither self nor cls.
class MathUtils:
@staticmethod
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
@staticmethod
def clamp(value, minimum, maximum):
return max(minimum, min(maximum, value))
@staticmethod
def lerp(a, b, t):
"""Linear interpolation between a and b at position t (0.0--1.0)."""
return a + (b - a) * t
print(MathUtils.is_prime(17)) # True
print(MathUtils.clamp(150, 0, 100)) # 100
print(MathUtils.lerp(0, 100, 0.25)) # 25.0
Use @staticmethod for utility functions that are logically related to the class but don't need access to the instance or class.
Dataclasses — The Modern Shortcut
Writing __init__, __repr__, and __eq__ for every class gets repetitive. The @dataclass decorator generates them automatically from annotated class variables.
from dataclasses import dataclass, field
@dataclass
class Point:
x: float
y: float
def distance_to(self, other):
return ((self.x - other.x)**2 + (self.y - other.y)**2) ** 0.5
p1 = Point(3.0, 4.0)
p2 = Point(0.0, 0.0)
print(p1) # Point(x=3.0, y=4.0)
print(p1 == Point(3.0, 4.0)) # True — __eq__ is generated
print(p1.distance_to(p2)) # 5.0
Default values and field options
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Employee:
name: str
department: str
salary: float = 50_000.0
start_date: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d"))
skills: list = field(default_factory=list) # mutable default — must use field()
def add_skill(self, skill):
self.skills.append(skill)
def annual_bonus(self, percent=10):
return self.salary * percent / 100
emp = Employee("Alice", "Engineering", 95_000.0)
emp.add_skill("Python")
emp.add_skill("FastAPI")
print(emp)
# Employee(name='Alice', department='Engineering', salary=95000.0, start_date='2026-03-09', skills=['Python', 'FastAPI'])
print(emp.annual_bonus()) # 9500.0
Frozen dataclasses — immutable objects
from dataclasses import dataclass
@dataclass(frozen=True)
class Color:
r: int
g: int
b: int
def to_hex(self):
return f"#{self.r:02X}{self.g:02X}{self.b:02X}"
red = Color(255, 0, 0)
green = Color(0, 255, 0)
blue = Color(0, 0, 255)
print(red.to_hex()) # #FF0000
print(green.to_hex()) # #00FF00
# Frozen — can't change
red.r = 100 # FrozenInstanceError
Frozen dataclasses are immutable and hashable — they can be used as dictionary keys or in sets.
Ordering with dataclasses
@dataclass(order=True)
class Student:
gpa: float
name: str
students = [
Student(3.8, "Alice"),
Student(3.5, "Bob"),
Student(3.9, "Carlos"),
]
students.sort()
for s in students:
print(s)
# Student(gpa=3.5, name='Bob')
# Student(gpa=3.8, name='Alice')
# Student(gpa=3.9, name='Carlos')
order=True generates all comparison methods based on the fields in declaration order.
Putting It All Together: A Full Playlist Class
from dataclasses import dataclass, field
from functools import total_ordering
@dataclass(frozen=True)
@total_ordering
class Song:
title: str
artist: str
duration: float # seconds
@property
def duration_str(self):
m, s = divmod(int(self.duration), 60)
return f"{m}:{s:02d}"
def __lt__(self, other):
return self.duration < other.duration
def __str__(self):
return f"{self.title} — {self.artist} ({self.duration_str})"
class Playlist:
def __init__(self, name):
self.name = name
self._songs = []
def add(self, song):
if not isinstance(song, Song):
raise TypeError(f"Expected Song, got {type(song).__name__}.")
self._songs.append(song)
def remove(self, title):
before = len(self._songs)
self._songs = [s for s in self._songs if s.title != title]
if len(self._songs) == before:
raise ValueError(f"No song titled {title!r} found.")
@property
def total_duration(self):
return sum(s.duration for s in self._songs)
@property
def total_duration_str(self):
total = int(self.total_duration)
h, rem = divmod(total, 3600)
m, s = divmod(rem, 60)
return f"{h}h {m}m {s}s" if h else f"{m}m {s}s"
def shuffle(self):
import random
shuffled = self._songs.copy()
random.shuffle(shuffled)
return Playlist._from_songs(self.name + " (shuffled)", shuffled)
@classmethod
def _from_songs(cls, name, songs):
p = cls(name)
p._songs = songs
return p
def __len__(self):
return len(self._songs)
def __iter__(self):
return iter(self._songs)
def __getitem__(self, index):
return self._songs[index]
def __contains__(self, song):
return song in self._songs
def __str__(self):
return f"Playlist({self.name!r}, {len(self)} songs, {self.total_duration_str})"
def __repr__(self):
return f"Playlist({self.name!r})"
# Build a playlist
p = Playlist("Evening Chill")
p.add(Song("Moonlight Sonata", "Beethoven", 900.0))
p.add(Song("Clair de Lune", "Debussy", 300.0))
p.add(Song("Gymnopédie No. 1", "Satie", 195.0))
p.add(Song("The Four Seasons", "Vivaldi", 2400.0))
p.add(Song("Für Elise", "Beethoven", 175.0))
print(p)
print(f"Longest: {max(p)}")
print(f"Shortest: {min(p)}")
print(f"\nAll songs:")
for i, song in enumerate(p, 1):
print(f" {i}. {song}")
beethoven_songs = [s for s in p if s.artist == "Beethoven"]
print(f"\nBeethoven: {len(beethoven_songs)} song(s)")
moon = Song("Moonlight Sonata", "Beethoven", 900.0)
print(f"\n'{moon.title}' in playlist: {moon in p}")
Output:
Playlist('Evening Chill', 5 songs, 65m 50s)
Longest: The Four Seasons — Vivaldi (40:00)
Shortest: Für Elise — Beethoven (2:55)
All songs:
1. Moonlight Sonata — Beethoven (15:00)
2. Clair de Lune — Debussy (5:00)
3. Gymnopédie No. 1 — Satie (3:15)
4. The Four Seasons — Vivaldi (40:00)
5. Für Elise — Beethoven (2:55)
Beethoven: 2 song(s)
'Moonlight Sonata' in playlist: True
Song is a frozen dataclass with @total_ordering. Playlist implements __len__, __iter__, __getitem__, __contains__, __str__, __repr__, a classmethod, and a computed property. The result feels completely native.
Dunder Methods Quick Reference
| Dunder | Triggered by |
|---|---|
__init__ |
ClassName() |
__str__ |
str(obj), print(obj), f-strings |
__repr__ |
repr(obj), REPL display |
__len__ |
len(obj) |
__getitem__ |
obj[key] |
__setitem__ |
obj[key] = value |
__delitem__ |
del obj[key] |
__contains__ |
item in obj |
__iter__ |
for x in obj |
__next__ |
next(obj) |
__call__ |
obj(args) |
__add__ |
obj + other |
__sub__ |
obj - other |
__mul__ |
obj * other |
__rmul__ |
other * obj |
__neg__ |
-obj |
__abs__ |
abs(obj) |
__eq__ |
obj == other |
__lt__ |
obj < other |
__le__ |
obj <= other |
__gt__ |
obj > other |
__ge__ |
obj >= other |
__enter__ |
with obj: |
__exit__ |
end of with block |
__bool__ |
bool(obj), if obj: |
__hash__ |
hash(obj), dict key, set member |
What You Learned in This Chapter
_nameis private by convention.__nametriggers name mangling — use sparingly.@propertyexposes a method as an attribute. Add.setterand.deleterfor full control.- Computed properties are always fresh — ideal for derived values.
- Dunder methods make objects feel native:
__len__,__getitem__,__iter__,__contains__. - Arithmetic dunders:
__add__,__sub__,__mul__,__rmul__,__neg__,__abs__. - Comparison dunders:
__eq__,__lt__, etc.@total_orderingfills in the rest from two methods. __call__makes objects callable like functions.__enter__/__exit__make objects work withwith— context managers.@classmethod— receivescls, used for alternative constructors.@staticmethod— noselforcls, utility functions in the class namespace.@dataclassgenerates__init__,__repr__,__eq__automatically.frozen=Truemakes objects immutable and hashable.order=Truegenerates comparison methods.
What's Next?
Part 3's final chapter is Chapter 20 — an intermediate OOP project that puts everything together: a mini library system with multiple classes, inheritance, encapsulation, properties, and file persistence. It's the capstone for everything in Chapters 17, 18, and 19.
Your turn: Build a Matrix class that wraps a 2D list of numbers. Implement: __init__(rows, cols, fill=0), __getitem__ and __setitem__ using (row, col) tuple keys, __add__ for matrix addition, __mul__ for scalar multiplication, __str__ for a formatted grid display, __len__ returning the number of rows, and a transpose() method. Test it by creating two 3x3 matrices, adding them, multiplying by a scalar, and printing the transpose.