- Define classes using the class statement and explain how classes model real-world concepts by bundling data with behaviour
- Implement __init__ to initialise objects and use self to access instance attributes and methods
- Distinguish between instance attributes and class attributes and predict which value an object will see
- Create __str__ and __repr__ methods to control how objects are displayed
- Use the @property decorator to create computed attributes with controlled access
Every program, at some level, is about data. Numbers, strings, lists, dictionaries — these are the raw materials. But real problems are rarely about isolated values. They are about things: patients with names and diagnoses, bank accounts with balances and transaction histories, sensors with readings and thresholds. When the data and the operations on it are scattered across a program in separate variables and standalone functions, keeping everything consistent becomes a full-time job. Object-oriented programming is Python's answer: bundle the data and the behaviour that operates on it into a single unit, give that unit a name, and let your code reflect the structure of the problem it solves.
Why Object-Oriented Programming
Procedural code — functions operating on plain data structures — works perfectly well for small programs. You pass a dictionary to a function, get a result back, pass it to another function. But as programs grow, cracks appear. A dictionary representing a patient has no way to enforce that it always contains a name key. A function that calculates a total has no formal connection to the data it operates on. Nothing stops you from passing the wrong dictionary to the wrong function.
Classes solve this by creating a formal relationship between data and behaviour. A class defines what attributes an object has and what methods it can perform. When you create an object from a class, you get a self-contained unit that carries its own data and knows how to operate on it. The class is the blueprint; the object is the thing built from it.
This is not just about organisation — it is about communication. When you see a Patient class with a discharge() method, you understand immediately what the code models and what it can do. That clarity compounds as programs grow larger and teams grow bigger. Object-oriented programming is not the only paradigm Python supports — you have already used functional techniques with map(), filter(), and closures — but it is the paradigm that most of Python's standard library and ecosystem is built on, and understanding it is essential for reading and writing real-world Python.
The Class Statement
You define a class with the class keyword, a name in CamelCase, and a colon:
class Dog:
pass
rex = Dog()
print(type(rex)) # Output: <class '__main__.Dog'>
Dog is a class. rex is an instance of that class — an object created from the blueprint. The pass statement is a placeholder; this class does not do anything yet. But even this empty class demonstrates the core idea: Dog() creates a new object, and type() confirms it is a Dog.
You can create as many instances as you like from one class. Each instance is an independent object with its own identity:
rex = Dog()
fido = Dog()
print(rex is fido) # Output: False
The naming convention is important: class names use CamelCase (BankAccount, HttpServer, PatientRecord) while variable and function names use snake_case. This visual distinction makes it immediately clear whether a name refers to a class or an instance.
The init Method and self
An empty class is not very useful. To give objects their initial data, you define the __init__ method — Python's initialiser. It runs automatically every time you create a new instance:
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
rex = Dog("Rex", "German Shepherd")
print(rex.name) # Output: Rex
print(rex.breed) # Output: German Shepherd
The first parameter of every method in a class is self — a reference to the instance being created or operated on. When you write self.name = name, you are attaching the value of name to this particular instance. Python passes self automatically; you never include it in the call.
The name self is a convention, not a keyword. You could call it this or s and the code would still work. But every Python programmer uses self, and breaking that convention would make your code confusing for no benefit.
Instance Attributes vs Class Attributes
Attributes set via self in __init__ belong to the instance. Each object gets its own copy. Class attributes, defined directly in the class body, are shared by all instances:
class Dog:
species = "Canis familiaris" # class attribute
def __init__(self, name):
self.name = name # instance attribute
rex = Dog("Rex")
fido = Dog("Fido")
print(rex.species) # Output: Canis familiaris
print(fido.species) # Output: Canis familiaris
print(rex.name) # Output: Rex
print(fido.name) # Output: Fido
When you access an attribute, Python looks at the instance first, then the class. If the instance does not have an attribute of that name, Python falls back to the class attribute. This means you can override a class attribute on a specific instance without affecting others — but doing so deliberately is rare and usually confusing.
Class attributes are useful for constants shared by all instances, or for counters that track how many objects have been created:
class Dog:
count = 0
def __init__(self, name):
self.name = name
Dog.count += 1
a = Dog("Rex")
b = Dog("Fido")
c = Dog("Spot")
print(Dog.count) # Output: 3
Notice that the counter is incremented via Dog.count, not self.count. Using self.count += 1 would create an instance attribute that shadows the class attribute on that particular instance — a subtle bug that catches many beginners.
Methods
A method is simply a function defined inside a class. It receives self as its first parameter, giving it access to the instance's data:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
print("Insufficient funds")
return
self.balance -= amount
def get_balance(self):
return self.balance
account = BankAccount("Alice", 100)
account.deposit(50)
account.withdraw(30)
print(account.get_balance()) # Output: 120
Methods are the behaviour half of the class. They operate on the instance's data through self, and they enforce the rules of your domain — like preventing a withdrawal that exceeds the balance. The data and the rules live together. This is the fundamental promise of object-oriented programming: you do not need to remember which functions go with which data, because the object knows.
The str and repr Methods
When you print an object, Python calls its __str__ method. When you inspect an object in the interpreter, Python calls __repr__. By default, both produce something unhelpful like <__main__.Dog object at 0x...>. You should override them:
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
def __str__(self):
return f"{self.name} the {self.breed}"
def __repr__(self):
return f"Dog(name='{self.name}', breed='{self.breed}')"
rex = Dog("Rex", "German Shepherd")
print(rex) # Output: Rex the German Shepherd
print(repr(rex)) # Output: Dog(name='Rex', breed='German Shepherd')
The convention is clear: __str__ is for humans — it should be readable and friendly. __repr__ is for developers — it should be unambiguous and, ideally, look like valid Python that could recreate the object. These two methods are called dunder methods (short for "double underscore"), and Python has dozens of them. They let your classes integrate seamlessly with Python's built-in operations — printing, comparison, arithmetic, iteration, and more.
Properties
Sometimes you want an attribute that is computed rather than stored, or you want to control what happens when an attribute is set. The @property decorator lets you define methods that look like attributes:
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)
r = Rectangle(5, 3)
print(r.area) # Output: 15
print(r.perimeter) # Output: 16
Notice that r.area is accessed without parentheses — it looks like a regular attribute, but it is computed on the fly. This is the power of properties: the caller does not need to know whether a value is stored or calculated.
You can also add a setter to control assignment:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._celsius = value
t = Temperature(25)
t.celsius = 30 # uses the setter
print(t.celsius) # Output: 30
The leading underscore in _celsius is a convention meaning "this is internal; do not access it directly." Python does not enforce this — there is no true private access in Python — but the convention is universally understood and respected.
A Worked Example
Let us put everything together with a Patient class that models a hospital patient:
class Patient:
hospital = "Auckland City Hospital" # class attribute
def __init__(self, name, age, condition):
self.name = name
self.age = age
self.condition = condition
self._notes = []
def add_note(self, note):
self._notes.append(note)
@property
def note_count(self):
return len(self._notes)
@property
def summary(self):
notes = "; ".join(self._notes) if self._notes else "No notes"
return f"{self.name} ({self.age}), {self.condition}: {notes}"
def __str__(self):
return f"{self.name}, age {self.age} — {self.condition}"
def __repr__(self):
return f"Patient(name='{self.name}', age={self.age}, condition='{self.condition}')"
p = Patient("Alice", 45, "Type 2 Diabetes")
p.add_note("HbA1c: 7.2%")
p.add_note("Commenced metformin 500mg BD")
print(p) # Output: Alice, age 45 — Type 2 Diabetes
print(p.summary) # Output: Alice (45), Type 2 Diabetes: HbA1c: 7.2%; Commenced metformin 500mg BD
print(p.note_count) # Output: 2
print(repr(p)) # Output: Patient(name='Alice', age=45, condition='Type 2 Diabetes')
This class bundles everything about a patient into one object. The data (name, age, condition, _notes) and the behaviour (add_note, summary) live together. The properties present a clean interface while hiding internal storage decisions. The dunder methods make the object behave naturally when printed or inspected.
Classes are one of those ideas that seem ceremonious when you first encounter them — all that self and __init__ just to hold some data. But the ceremony pays for itself the moment your program has more than one kind of thing, or the moment someone else needs to understand your code. A well-designed class is not just a data container; it is a statement of intent about what your program models and how it behaves.