Skip to content

Python Advanced Programming

This article covers the core features of Python’s object-oriented programming (OOP): encapsulation, inheritance, and polymorphism, along with advanced modern Python topics such as type annotations and abstract classes.

Encapsulation

Encapsulation binds data and the methods that operate on it together, and uses access control to restrict direct external access.

Private Attributes (Double-Underscore Mangling)

class BankAccount:
    def __init__(self, owner: str, balance: float):
        self.owner = owner        # public attribute
        self.__balance = balance  # private attribute (mangled to _BankAccount__balance)

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self.__balance += amount

    def get_balance(self) -> float:
        return self.__balance

acc = BankAccount("Alice", 1000.0)
acc.deposit(500)
print(acc.get_balance())       # 1500.0
# print(acc.__balance)         # AttributeError (not directly accessible externally)
print(acc._BankAccount__balance)  # 1500.0 (accessible if you know the mangled name, but not recommended)

The @property Decorator

@property disguises a method as an attribute access, while allowing you to add getter / setter / deleter logic:

class Circle:
    def __init__(self, radius: float):
        self.__radius = radius

    @property
    def radius(self) -> float:
        return self.__radius

    @radius.setter
    def radius(self, value: float) -> None:
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self.__radius = value

    @property
    def area(self) -> float:
        import math
        return math.pi * self.__radius ** 2

c = Circle(5)
print(c.radius)    # 5
print(c.area)      # 78.539...
c.radius = 10      # triggers the setter
c.radius = -1      # ValueError

Inheritance

Inheritance lets a subclass reuse the attributes and methods of its parent class, and extend or override parent behavior.

Single Inheritance

class Animal:
    def __init__(self, name: str):
        self.name = name

    def speak(self) -> str:
        raise NotImplementedError("Subclasses must implement speak")

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(name={self.name!r})"

class Dog(Animal):
    def speak(self) -> str:
        return f"{self.name} says: Woof!"

class Cat(Animal):
    def speak(self) -> str:
        return f"{self.name} says: Meow!"

dog = Dog("Rex")
print(dog.speak())   # Rex says: Woof!
print(dog)           # Dog(name='Rex')

Calling the Parent with super()

class Vehicle:
    def __init__(self, brand: str, speed: int):
        self.brand = brand
        self.speed = speed

    def info(self) -> str:
        return f"{self.brand}, top speed {self.speed} km/h"

class ElectricCar(Vehicle):
    def __init__(self, brand: str, speed: int, battery: int):
        super().__init__(brand, speed)   # call the parent __init__
        self.battery = battery           # new attribute

    def info(self) -> str:
        base = super().info()
        return f"{base}, battery capacity {self.battery} kWh"

tesla = ElectricCar("Tesla", 250, 100)
print(tesla.info())
# Tesla, top speed 250 km/h, battery capacity 100 kWh

Multiple Inheritance and MRO

Python uses the C3 linearization algorithm to compute the Method Resolution Order (MRO). You can inspect it with ClassName.mro():

class A:
    def method(self): return "A"

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

class C(A):
    def method(self): return "C"

class D(B, C):
    pass

print(D.mro())   # [D, B, C, A, object]
print(D().method())  # "B"

The Mixin Pattern

When multiple inheritance is needed, it is recommended to use Mixin classes to mix in behavior rather than mixing in “is-a” relationships:

class JSONMixin:
    def to_json(self) -> str:
        import json
        return json.dumps(self.__dict__)

class LogMixin:
    def log(self, message: str) -> None:
        print(f"[{self.__class__.__name__}] {message}")

class User(JSONMixin, LogMixin):
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

u = User("Alice", 25)
print(u.to_json())   # {"name": "Alice", "age": 25}
u.log("User created")

Polymorphism

Polymorphism means different types of objects can use the same interface (method name) without caring about the concrete type:

animals: list[Animal] = [Dog("Rex"), Cat("Luna"), Dog("Max")]

for animal in animals:
    print(animal.speak())  # each object calls its own speak implementation

Duck Typing

Python’s polymorphism does not require inheritance — as long as an object implements the required method, it can be used:

class Duck:
    def quack(self): print("Quack!")

class Person:
    def quack(self): print("I'm quacking like a duck!")

def make_it_quack(obj) -> None:
    obj.quack()   # no type check — just needs a quack method

make_it_quack(Duck())    # Quack!
make_it_quack(Person())  # I'm quacking like a duck!

Abstract Classes (ABC)

Use the abc module to enforce that subclasses implement specific interfaces:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        """Calculate the area"""

    @abstractmethod
    def perimeter(self) -> float:
        """Calculate the perimeter"""

    def describe(self) -> str:
        return f"Area: {self.area():.2f}, Perimeter: {self.perimeter():.2f}"

class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

rect = Rectangle(4, 6)
print(rect.describe())   # Area: 24.00, Perimeter: 20.00
# Shape()  # TypeError: Can't instantiate abstract class

Class Methods and Static Methods

class DateParser:
    fmt = "%Y-%m-%d"

    def __init__(self, year: int, month: int, day: int):
        self.year = year
        self.month = month
        self.day = day

    @classmethod
    def from_string(cls, date_str: str) -> "DateParser":
        """Factory method: create an instance from a string"""
        from datetime import datetime
        d = datetime.strptime(date_str, cls.fmt)
        return cls(d.year, d.month, d.day)

    @staticmethod
    def is_valid_date(date_str: str) -> bool:
        """Utility method: does not depend on the class or instance"""
        try:
            from datetime import datetime
            datetime.strptime(date_str, "%Y-%m-%d")
            return True
        except ValueError:
            return False

d = DateParser.from_string("2024-06-01")
print(d.year, d.month, d.day)          # 2024 6 1
print(DateParser.is_valid_date("2024-13-01"))  # False

Reflection

Reflection allows you to dynamically read and modify an object’s attributes and methods using strings. It is commonly used in plugin systems or dynamic routing:

class Config:
    debug = False
    port = 8080
    host = "localhost"

cfg = Config()

# check whether an attribute exists
print(hasattr(cfg, "port"))           # True

# get an attribute value (getattr supports a default)
print(getattr(cfg, "port"))           # 8080
print(getattr(cfg, "timeout", 30))    # 30 (returned when the attribute does not exist)

# set an attribute
setattr(cfg, "debug", True)
print(cfg.debug)                      # True

# delete an attribute
setattr(cfg, "temp", "value")
delattr(cfg, "temp")

# dynamically call a method
class Router:
    def get(self): return "GET handler"
    def post(self): return "POST handler"

router = Router()
method = "get"
if hasattr(router, method):
    handler = getattr(router, method)
    print(handler())   # GET handler

Metaclasses

A metaclass is a class that creates other classes. type is the default metaclass for all classes:

# dynamically create a class with type
Dog = type("Dog", (object,), {
    "sound": "Woof",
    "speak": lambda self: f"{self.sound}!"
})

d = Dog()
print(d.speak())   # Woof!

# custom metaclass (uncommon; understanding it is enough)
class SingletonMeta(type):
    _instances: dict = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class Database(metaclass=SingletonMeta):
    def __init__(self, url: str):
        self.url = url

db1 = Database("postgres://localhost/app")
db2 = Database("mysql://localhost/app")
print(db1 is db2)   # True (singleton — db2 is ignored)

Type Annotations (Python 3.5+)

Modern Python recommends adding type annotations to function parameters and return values. Combined with tools like mypy or pyright, this enables static type checking:

from typing import Optional, Union, Callable
from collections.abc import Sequence

# basic annotations
def greet(name: str, times: int = 1) -> str:
    return (f"Hello, {name}! " * times).strip()

# optional parameter (Python 3.10+ can use str | None)
def find_user(user_id: int) -> Optional[str]:
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)

# Python 3.10+ Union shorthand
def process(value: int | str | None) -> str:
    return str(value) if value is not None else "empty"

# generics (Python 3.9+ supports built-in types)
def flatten(matrix: list[list[int]]) -> list[int]:
    return [x for row in matrix for x in row]

# Protocol (structural subtyping — formal expression of duck typing)
from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...

def render(obj: Drawable) -> None:
    obj.draw()

Python 3.12 new syntax: you can use the type keyword to define type aliases and the [T] syntax to define generic classes — much more concise than TypeVar:

type Vector = list[float]

class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, item: T) -> None:
        self._items.append(item)
    def pop(self) -> T:
        return self._items.pop()
Last updated on