Python: Zero to Hero
Home/Object-Oriented Programming
Share

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

  • _name is private by convention. __name triggers name mangling — use sparingly.
  • @property exposes a method as an attribute. Add .setter and .deleter for 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_ordering fills in the rest from two methods.
  • __call__ makes objects callable like functions.
  • __enter__ / __exit__ make objects work with with — context managers.
  • @classmethod — receives cls, used for alternative constructors.
  • @staticmethod — no self or cls, utility functions in the class namespace.
  • @dataclass generates __init__, __repr__, __eq__ automatically. frozen=True makes objects immutable and hashable. order=True generates 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.

© 2026 Abhilash Sahoo. Python: Zero to Hero.