Classes & Objects
Object-oriented Python: classes, instances, methods, dunder methods, inheritance, encapsulation.
What is object-oriented programming?
Object-oriented programming (OOP) is a way of organizing code around "objects" — bundles of data (attributes) and the operations that act on that data (methods). Instead of having free-floating functions that pass data around, you group related state and behavior together into a single thing.
A class is the blueprint. An instance (or object) is what you get when you actually build one from the blueprint. If `Dog` is a class, then `my_pet = Dog("Rex")` is an instance.
Why use classes?
- Encapsulation — keep state and the code that manipulates it in one place.
- Abstraction — expose a clean interface, hide messy internals.
- Reuse — define behavior once in a base class, share it with many subclasses.
- Modeling — when your problem naturally has "things" (users, orders, sensors), classes are the cleanest way to represent them.
Defining a class
class Point:
"""A 2D point in the plane."""
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
def distance(self, other):
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
p = Point(0, 0)
q = Point(3, 4)
print(p, q)
print(p.distance(q)) # 5.0Let's break that down. `class Point:` declares the class. The first method, `__init__`, is the constructor — it runs every time you create a new instance. The first parameter, conventionally called `self`, is the instance being built; you attach data to it with `self.x = x`. Other methods take `self` as their first argument too — that's how they get access to the instance they're called on.
Dunder (double-underscore) methods
Methods with names like `__init__` and `__repr__` are called dunder methods ("double underscore") or magic methods. They customize how Python's built-in operations work on your object. `__repr__` controls how your object shows up when printed. There are dozens — `__add__` for `+`, `__len__` for `len()`, `__eq__` for `==`, `__iter__` for iteration, and so on.
class Money:
def __init__(self, amount, currency="USD"):
self.amount = amount
self.currency = currency
def __repr__(self):
return f"{self.amount} {self.currency}"
def __add__(self, other):
if self.currency != other.currency:
raise ValueError("currency mismatch")
return Money(self.amount + other.amount, self.currency)
rent = Money(1200) + Money(50)
print(rent) # 1250 USDInheritance
Inheritance lets a class take on the attributes and methods of another class, then add or override behavior. The original class is the parent (or base, or superclass). The new class is the child (or subclass).
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return "Woof"
class Cat(Animal):
def speak(self):
return "Meow"
for pet in [Dog("Rex"), Cat("Mia")]:
print(pet.name, "says", pet.speak())`Dog(Animal)` means "Dog inherits from Animal". Dog automatically gets `__init__` from Animal, but overrides `speak`. This is also our first taste of polymorphism — the same line `pet.speak()` does different things depending on the actual class of the object.
super() — calling the parent
class Vehicle:
def __init__(self, wheels):
self.wheels = wheels
class Car(Vehicle):
def __init__(self, brand):
super().__init__(wheels=4)
self.brand = brand
c = Car("Toyota")
print(c.brand, c.wheels)Encapsulation: public, _protected, __private
Python doesn't enforce visibility, but there's a convention: a leading underscore (`_name`) means "this is internal, don't touch". Two leading underscores (`__name`) trigger name-mangling, making the attribute hard to access from outside (it gets renamed to `_ClassName__name`). Most of the time, just one underscore is enough.