Python Classes and Object-Oriented Basics
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
Reach for a class when data and the operations on that data belong together. Reach for something simpler when they do not — a module of functions is often enough.
Environment
Examples are verified on Python 3.13 with no third-party dependencies.
Defining a class
class User:
def __init__(self, name: str, age: int = 0) -> None:
self.name = name # instance attribute
self.age = age
def greet(self) -> str:
return f"Hi, I'm {self.name}"
u = User("Ada", 36)
print(u.greet()) # Hi, I'm Ada
Details worth knowing:
__init__initializes an already-created object; the object itself comes from__new__.selfis the first parameter of instance methods. The name is a convention, not syntax.u.greet()isUser.greet(u)in disguise; the bound method suppliesself.
Instance attributes versus class attributes
class Counter:
total = 0 # class attribute: shared
def __init__(self) -> None:
self.count = 0 # instance attribute: per object
Counter.total += 1
Class attributes suit constants and cross-instance counters. Never put a mutable object there as a default — see the pitfalls below.
Three kinds of methods
class User:
role = "member" # class attribute
def __init__(self, name: str) -> None:
self.name = name
def greet(self) -> str: # instance method
return f"I'm {self.name}"
@classmethod
def admin(cls, name: str) -> "User": # class method
return cls(name)
@staticmethod
def is_valid_name(name: str) -> bool: # static method
return len(name.strip()) > 0
- Instance methods operate on one object’s state.
@classmethodreceives the class, not the instance. Use it for alternative constructors;clsbinds to the subclass when inherited.@staticmethodis a plain function namespaced under the class. Use it when the logic belongs there conceptually but needs no state.
@property
class User:
def __init__(self, first: str, last: str) -> None:
self.first = first
self.last = last
@property
def full_name(self) -> str:
return f"{self.first} {self.last}"
@full_name.setter
def full_name(self, value: str) -> None:
self.first, self.last = value.split(" ", 1)
u = User("Ada", "Lovelace")
print(u.full_name) # Ada Lovelace
u.full_name = "Grace Hopper"
The point is future-proofing: you can start with a plain attribute and later add validation or computation without changing call sites.
Properties are not free; if the getter does I/O or heavy computation, callers will assume it is cheap.
Inheritance
class Animal:
def __init__(self, name: str) -> None:
self.name = name
def speak(self) -> str:
raise NotImplementedError
class Dog(Animal):
def __init__(self, name: str, breed: str) -> None:
super().__init__(name) # initialize the parent part
self.breed = breed
def speak(self) -> str:
return "woof"
def fetch(self) -> str:
return f"{self.name} fetches the ball"
super() follows the MRO (method resolution order) rather than hard-coding the parent class, which matters with multiple inheritance:
print(Dog.__mro__)
Prefer composition when the relationship is “has a” rather than “is a”:
class Order:
def __init__(self, user: User, items: list[str]) -> None:
self.user = user # holds a User instead of being one
self.items = items
Deep hierarchies are hard to change; a shallow tree plus composition stays flexible.
Dunder methods worth implementing
class Point:
def __init__(self, x: float, y: float) -> None:
self.x, self.y = x, y
def __repr__(self) -> str: # for developers, in logs and debuggers
return f"Point({self.x}, {self.y})"
def __str__(self) -> str: # for users, print() and f-strings
return f"({self.x}, {self.y})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self) -> int: # defining __eq__ removes the default hash
return hash((self.x, self.y))
Others that show up in real code:
| Method | Enables |
|---|---|
__len__ |
len(obj) |
__iter__ |
for x in obj |
__getitem__ |
obj[key] |
__enter__ / __exit__ |
with statements |
__lt__ |
sorting and comparisons |
Write __repr__ first; without it, every log line says <__main__.Point object at 0x...>.
__slots__ removes the per-instance __dict__, saving memory and turning typo’d attribute assignments into errors:
class Point:
__slots__ = ("x", "y")
Common mistakes
1. Mutable class attributes
class Bag:
items = [] # shared by every instance
b1, b2 = Bag(), Bag()
b1.items.append("x")
print(b2.items) # ['x']
Create mutable state in __init__.
2. Forgetting self
def greet(): inside a class raises TypeError: greet() takes 0 positional arguments but 1 was given on call.
3. __eq__ without a type check
Accessing other.x blindly raises AttributeError when comparing to a different class. Return NotImplemented for unknown types and let Python try the reflected operation.
4. __eq__ without __hash__
The class becomes unhashable, so it cannot go into a set or serve as a dict key. Define __hash__ from the same fields used in __eq__.
5. Overusing inheritance
Inheriting to reuse one method makes the hierarchy brittle. Module-level functions and composition are usually cheaper.
6. Doing work in __init__ that can fail
Constructors that open sockets or read files are hard to test. Pass in the dependency, or use a classmethod factory that returns a ready object.
7. Relying on __del__ for cleanup
Its timing is unspecified. Use context managers.
Summary
- A class binds state and behavior;
__init__initializes,selfis the instance. @propertykeeps the public API stable;@classmethodbuilds alternative constructors.- Use
super()and the MRO; prefer composition over deep hierarchies. - Implement
__repr__; define__eq__and__hash__as a pair.
Next: dataclasses and type hints, which remove most of this boilerplate.