Part 3: Python Programming
14. Classes & Objects (OOP)
Classes & Objects (OOP)
So far we have organised code with functions. Object-oriented programming (OOP) offers another powerful way: bundle related data and the functions that act on it into a single unit called an object. A class is the blueprint; an object (or instance) is a specific thing built from that blueprint.
Defining a class
Use the keyword class and a capitalised name by convention. The special method __init__ runs automatically when you create a new object; it sets up the object's starting data. The first parameter, always called self, refers to the particular object being worked on:
The variables self.name and self.age are attributes: data belonging to each object. bark is a method: a function that belongs to the class and can use the object's attributes through self.
Many objects from one class
From a single class you can create as many independent objects as you like. Each carries its own attribute values:
A worked example: a bank account
Classes shine when data and behaviour belong together. A bank account has a balance (data) and operations like deposit and withdraw (behaviour) that must keep the balance sensible:
The account started at 100, gained 50, lost 30, and refused the oversized withdrawal, ending at 120. Notice how the withdraw method protects the data by checking before subtracting. Keeping such rules inside the class is one of OOP's main benefits.
A brief look at inheritance
Inheritance lets a new class build on an existing one, reusing its code and adding or changing behaviour. The new class lists the parent in parentheses:
Cat inherits the __init__ method from Animal, so it already knows how to store a name, but it provides its own speak method, replacing the parent's version. This ability to reuse and specialise code keeps large programs manageable.
Object-oriented programming takes practice to appreciate fully. For now, remember the core idea: a class groups related data (attributes) and actions (methods) into one tidy package, and you create as many objects from it as you need.
The four pillars of OOP
Once you can define a class and create objects, the next step is understanding why OOP is organised the way it is. Almost everything you will meet from here on is an expression of four core ideas, usually called the four pillars of object-oriented programming. You do not need to memorise the names, but recognising them helps you see the pattern behind the syntax.
| Pillar | One-line meaning | Everyday analogy |
|---|---|---|
| Encapsulation | Bundle data with the code that works on it, and control who can touch it. | A medicine capsule: the contents are sealed inside; you take the pill, you don't repackage the powder. |
| Inheritance | A new class can reuse and extend an existing one ("is-a"). | A sports car is a car: it has everything a car has, plus extras. |
| Polymorphism | The same method name behaves correctly for different types. | Pressing "play" works on a phone, a TV and a car stereo, even though each does it differently. |
| Abstraction | Expose a simple interface and hide the messy details. | Driving a car: you use the steering wheel and pedals without knowing how the engine burns fuel. |
The rest of this chapter walks through each pillar with runnable code. Keep the table in mind: every feature below is a tool for one of these four goals.
Instance attributes vs class attributes
There are two places data can live in a class, and mixing them up is one of the most common beginner bugs.
- Instance attributes are created inside
__init__usingself. Each object gets its own private copy. - Class attributes are defined directly in the class body, outside any method. There is only one copy, shared by every instance of the class.
Notice that changing Dog.species affected both existing dogs instantly, because they all read the same shared value. Instance attributes like name stay independent. Class attributes are perfect for constants and defaults that genuinely belong to the whole type.
The mutable class-attribute trap. Problems appear when a shared class attribute is something changeable, like a list. Watch what happens:
Rex somehow knows Bella's trick! Because tricks is one shared list, every dog appends to the same object. The fix is to make the list an instance attribute, created fresh for each dog inside __init__:
Rule of thumb: use class attributes for constants shared by every object; put anything each object should own separately (especially mutable things like lists and dicts) in __init__ via self.
Three kinds of methods: instance, class, and static
So far every method you wrote took self. That is just one of three method types Python offers, each with a different job.
- Instance method — takes
self; works with one specific object's data. This is the default. - Class method — marked with
@classmethod, takescls(the class itself). Great as an alternative constructor / factory. - Static method — marked with
@staticmethod, takes neitherselfnorcls. It is just a plain utility function that logically belongs inside the class.
Why use cls instead of writing Pizza(...) directly in the factory? Because cls respects inheritance: if a subclass calls margherita(), it builds an instance of the subclass, not of Pizza. The static area method does not read any object state, so it takes neither self nor cls and can be called straight off the class.
Encapsulation: controlling access
Python does not have true "private" keywords like some languages. Instead it uses naming conventions plus one mild technical trick:
name— public: part of the official interface, use freely._name— protected (single underscore): "internal, please don't touch from outside." A convention only; Python does not stop you.__name— private (double underscore): Python performs name mangling, rewriting it to_ClassName__nameso it is hard to hit by accident.
So __pin is not truly hidden; it has merely been renamed to _Account__pin. The point of the double underscore is to avoid accidental clashes and signal "really internal," not to provide security.
The clean way to guard data: @property. A property lets an attribute look like a plain variable from the outside while secretly running a getter and setter. This is how you validate data without forcing callers to use ugly get_x() / set_x() methods.
From the caller's point of view, t.celsius = 100 is just an assignment. Under the hood the setter runs, rejecting impossible values. fahrenheit has only a getter, so it is effectively read-only and always computed from the current Celsius. This is encapsulation done the Pythonic way: start with plain public attributes, and reach for @property only when you need validation or a computed value.
Inheritance in depth
Inheritance models an is-a relationship: a Dog is an Animal. The child class automatically gets the parent's attributes and methods, and can add new ones or replace (override) existing ones. Use super().__init__() to run the parent's constructor so you don't rewrite its setup.
The arrows point from the specific class up to the general one: "Dog is-a Animal." Here is a three-level hierarchy where each level overrides speak():
Look closely at describe(): it is defined only on Animal, yet it prints the right sound for each animal. That is because it calls self.speak(), and self looks up speak on the actual object's class first. The Puppy never defines its own __init__, so it transparently uses Dog's, which is why p.breed works.
Two built-in functions let you ask about relationships at runtime:
Multiple inheritance and the MRO (briefly). Python allows a class to inherit from more than one parent at once, e.g. class D(B, C). When you call a method, Python searches the parents in a fixed order called the Method Resolution Order (MRO), which you can inspect with ClassName.__mro__. It generally goes left-to-right, depth-first, with each class appearing once:
Multiple inheritance is powerful but easy to misuse; for most programs, prefer single inheritance (or composition, covered later) and keep hierarchies shallow.
Polymorphism and duck typing
Polymorphism ("many forms") means the same method name can be called on different types and each does the right thing. Because of this, you can loop over a mixed collection of objects and call one method without caring which exact class each one is:
Notice these three classes do not share a parent. Python does not check their type; it only checks that each object has a speak() method. This is called duck typing: "if it walks like a duck and quacks like a duck, treat it like a duck." What matters is the behaviour an object offers, not its label.
The Robot has nothing to do with animals, yet make_it_speak accepts it happily. Duck typing keeps Python code flexible: you program to a capability, not to a rigid type hierarchy.
Abstraction with abstract base classes
Sometimes you want to guarantee that every subclass implements a particular method, and you want to forbid creating the vague "base" type on its own. The abc module (Abstract Base Classes) provides exactly this. You inherit from ABC and mark required methods with @abstractmethod.
Shape describes what every shape must be able to do (compute an area) without saying how. Trying to create a bare Shape() raises TypeError, because it has an unimplemented abstract method. If you wrote a Triangle(Shape) and forgot to define area, Python would refuse to instantiate it too. This is abstraction enforced by the language: the base class defines the contract, concrete subclasses fulfil it.
Special (dunder) methods
Methods with double underscores on both sides (__init__, __str__, ...) are called dunder ("double underscore") or magic methods. Python calls them automatically in response to built-in operations like print(), ==, len(), + and sorting. Defining them makes your objects feel like native Python types.
By default, printing an object is unhelpful:
(The exact hexadecimal address varies every run.) Two dunders fix this. __str__ gives a friendly, human-readable form used by print() and str(); __repr__ gives an unambiguous, developer-facing form used in the interactive shell, in containers, and as a fallback when __str__ is missing.
A good habit: always define at least __repr__. If you only define one, make it __repr__, because __str__ falls back to it.
Value equality with __eq__. By default == compares identity (are these the same object in memory?). Define __eq__ to compare by value instead:
Without __eq__, the first line would print False, because the two points are distinct objects even though their values match.
Making objects sortable with __lt__. The sorted() function and < operator use __lt__ ("less than"). Define it and your objects sort automatically:
Length with __len__, and operators with __add__. __len__ lets len() work on your object; __add__ lets + combine two objects:
| Dunder method | What it enables |
|---|---|
__init__ | Set up a new object (the constructor). |
__str__ | Friendly text for print() / str(). |
__repr__ | Unambiguous text for debugging / containers. |
__eq__ | Compare two objects with == by value. |
__lt__ | Order objects with < and sorted(). |
__len__ | Make len(obj) work. |
__add__ | Define what obj1 + obj2 does. |
__getitem__ | Enable indexing, e.g. obj[0]. |
Composition vs inheritance
Inheritance models is-a ("a Dog is an Animal"). Composition models has-a ("a Car has an Engine"): instead of inheriting, one object simply holds another object as an attribute and delegates work to it.
A Car is clearly not a kind of Engine, so inheritance would be wrong here; the car simply contains one. Composition is more flexible: you could swap in an ElectricEngine, or give the car several components (wheels, radio, battery) without tangling them into one inheritance chain.
Rule of thumb: if you can say "X is a Y," consider inheritance; if you can say "X has a Y," prefer composition. When unsure, favour composition — it leads to looser coupling and fewer surprises.
Best practices & common pitfalls
- One responsibility per class. A class should model one clear thing. If it is doing networking and maths and file I/O, split it up.
- Prefer composition for "has-a". Reach for inheritance only when there is a genuine is-a relationship, and keep hierarchies shallow.
- Don't overuse inheritance. Deep chains and multiple inheritance make code hard to follow. Most designs need only one or two levels.
- Use
@propertyfor validated access. Expose plain attributes by default; switch to a property when you need to validate input or compute a value, rather than littering code withget_x()/set_x(). - Never forget
self. Every instance method's first parameter isself, and you access object data through it (self.name, not justname). - Avoid mutable defaults and mutable class attributes. Create lists/dicts inside
__init__so each object owns its own, instead of sharing one by accident. - Define
__repr__(and often__str__). Readable objects make debugging and logging far easier. - Keep methods short and named for what they do.
account.withdraw(50)reads better than one giantdo_everything()method.
Quick reference
| Feature | Syntax | Purpose |
|---|---|---|
| Define a class | class Dog: | Create a new type. |
| Constructor | def __init__(self, ...): | Set up a new object's data. |
| Current object | self | Refers to the instance inside its methods. |
| Class attribute | species = "..." (in class body) | Value shared by all instances. |
| Class method | @classmethod / def m(cls): | Factory or alternative constructor. |
| Static method | @staticmethod / def m(): | Utility that needs no object state. |
| Validated attribute | @property + @x.setter | Getter/setter that looks like an attribute. |
| Inheritance | class Dog(Animal): + super().__init__() | Reuse and extend a parent class. |
| Abstract method | @abstractmethod (class inherits ABC) | Force subclasses to implement a method. |
| Readable text | __str__ / __repr__ | Control how the object prints. |
| Value equality | __eq__ | Compare objects with ==. |