Object Oriented Programming (OOP)
What is Object-Oriented Programming?
So far in this course, we’ve been writing code as a sequence of instructions — define a variable, call a function, loop over a list. This style works great for small tasks, but as programs grow, it sometimes helps to organize code around objects: bundles of related data and behavior that model things in the world. These could be experimental participants, they could be datasets of specific types (EEG dataset, or fMRI dataset), they could be computational models (recurrent neural networks) or they could be statistical models (data + parameter estimates). It is not always better to use OOP but when it is, it’s often a lot better.
Object-Oriented Programming (OOP) lets us define our own types (called classes) that combine:
- Attributes — the data an object holds (e.g., a circle’s radius)
- Methods — the functions an object can perform (e.g., calculating area)
Think of a class as a blueprint and an object as a specific instance built from that blueprint. A class Circle describes what circles are; a particular circle with radius 5 is an instance of that class.
Our First Class: Circle
import math
class Circle:
"""A circle defined by its radius."""
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radiusLet’s unpack this piece by piece.
The __init__ Method (The Initializer)
__init__ is a special method that Python calls automatically when you create a new object. Its job is to set up the object’s initial state by assigning attributes.
def __init__(self, radius):
self.radius = radiusselfrefers to the specific object being created. Every method in a class receivesselfas its first argument — it’s how the object refers to itself.self.radius = radiusstores the value you pass in as an attribute on the object.
Creating an instance looks like a function call:
c = Circle(5)
print(c.radius) # 5Attributes
Attributes are variables that belong to an object. You access them with dot notation:
c = Circle(3)
print(c.radius) # 3
c.radius = 10 # you can change attributes too
print(c.radius) # 10Methods
Methods are functions defined inside a class. They always take self as the first parameter, which gives them access to the object’s attributes.
c = Circle(5)
print(c.area()) # 78.539...
print(c.perimeter()) # 31.415...Notice that when you call a method, you don’t pass self — Python handles that for you.
Built-in Special Methods
Python has a set of “magic” or “dunder” (double-underscore) methods that let your objects work with built-in Python features like print(), ==, <, and more.
__str__ — Human-Readable Display
__str__ controls what print() shows:
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
def __str__(self):
return f"Circle(radius={self.radius})"c = Circle(5)
print(c) # Circle(radius=5)Without __str__, printing an object gives you something unhelpful like <__main__.Circle object at 0x7f3b...>.
__repr__ — Developer-Friendly Display
__repr__ is meant to give an unambiguous representation, often one you could paste back into Python to recreate the object. It’s what you see when you type a variable name in the interactive console:
def __repr__(self):
return f"Circle({self.radius})"c = Circle(5)
c # In a notebook or REPL, this shows: Circle(5)
print(c) # This still uses __str__: Circle(radius=5)Rule of thumb:
__str__is for users,__repr__is for developers. If you only define one, define__repr__— Python will fall back to it when__str__is missing.
Comparison Operators
What if we want to compare two circles? We can define what <, >, and == mean for our class. A natural choice is to compare by area.
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
def __str__(self):
return f"Circle(radius={self.radius})"
def __repr__(self):
return f"Circle({self.radius})"
def __eq__(self, other):
return self.area() == other.area()
def __lt__(self, other):
return self.area() < other.area()
def __gt__(self, other):
return self.area() > other.area()small = Circle(2)
big = Circle(7)
print(small == big) # False
print(small < big) # True
print(small > big) # FalseA bonus: once you define __lt__, you can use sorted() on a list of circles!
circles = [Circle(5), Circle(1), Circle(3)]
sorted_circles = sorted(circles)
print(sorted_circles) # [Circle(1), Circle(3), Circle(5)]Inheritance: Building on Existing Classes
Inheritance lets you create a new class based on an existing one. The new class (the child or subclass) inherits all the attributes and methods of the original (the parent or superclass), and can add or change behavior.
Let’s create a base class Shape and build specific shapes from it.
class Shape:
"""Base class for all shapes."""
def __init__(self, name):
self.name = name
def area(self):
raise NotImplementedError("Subclasses must implement area()")
def perimeter(self):
raise NotImplementedError("Subclasses must implement perimeter()")
def __str__(self):
return f"{self.name} with area {self.area():.2f}"
def __repr__(self):
return f"{self.name}()"
def __eq__(self, other):
return self.area() == other.area()
def __lt__(self, other):
return self.area() < other.area()
def __gt__(self, other):
return self.area() > other.area()A few things to notice:
Shapeis not meant to be used directly — it’s a template. Callingarea()on a plainShaperaises an error on purpose, reminding us that each specific shape must provide its own version.- The comparison methods and
__str__are defined here once and inherited by all subclasses, so we don’t have to rewrite them.
Defining Subclasses
A subclass is defined by putting the parent class name in parentheses:
class Circle(Shape):
def __init__(self, name, radius):
super().__init__(name) # call the parent's __init__
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
def __repr__(self):
return f"Circle('{self.name}', radius={self.radius})"
class Square(Shape):
def __init__(self, name, side):
super().__init__(name)
self.side = side
def area(self):
return self.side ** 2
def perimeter(self):
return 4 * self.side
def __repr__(self):
return f"Square('{self.name}', side={self.side})"
class Rectangle(Shape):
def __init__(self, name, width, height):
super().__init__(name)
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
def __repr__(self):
return f"Rectangle('{self.name}', width={self.width}, height={self.height})"super() — Calling the Parent
super().__init__(name) calls the Shape.__init__ method, which sets self.name. This way we reuse the parent’s setup logic instead of duplicating it. Now each shape can have a descriptive name: Circle("pizza", 10.5) or Square("window", 3).
Overriding Methods
When a subclass defines a method that already exists in the parent, the subclass version overrides it. Each shape above overrides area(), perimeter(), and __repr__() to provide its own behavior, while __str__, __eq__, __lt__, and __gt__ are inherited as-is from Shape.
Polymorphism: Same Interface, Different Behavior
Polymorphism means “many forms.” Because every shape implements area() and perimeter(), we can write code that works with any shape without knowing which specific type it is:
shapes = [Circle("pizza", 5), Square("window", 4), Rectangle("desk", 3, 6)]
for shape in shapes:
print(f"{shape.name}: area = {shape.area():.2f}, perimeter = {shape.perimeter():.2f}")Output:
pizza: area = 78.54, perimeter = 31.42
window: area = 16.00, perimeter = 16.00
desk: area = 18.00, perimeter = 18.00
The loop doesn’t care whether it’s dealing with a circle, square, or rectangle. It just calls .area() and .perimeter(), and each object responds with its own version. That’s polymorphism.
We can also compare shapes of completely different types, because comparison is based on area:
c = Circle("frisbee", 3) # area ≈ 28.27
s = Square("coaster", 6) # area = 36.00
r = Rectangle("postcard", 5, 4) # area = 20.00
print(c > r) # True — circle has more area than rectangle
print(s > c) # True — square has more area than circle
# Sort a mixed list of shapes by area
all_shapes = [s, c, r]
print(sorted(all_shapes))
# [Rectangle('postcard', width=5, height=4), Circle('frisbee', radius=3), Square('coaster', side=6)]Putting It All Together
Here’s the complete code in one block for reference:
import math
class Shape:
"""Base class for all shapes."""
def __init__(self, name):
self.name = name
def area(self):
raise NotImplementedError("Subclasses must implement area()")
def perimeter(self):
raise NotImplementedError("Subclasses must implement perimeter()")
def __str__(self):
return f"{self.name} with area {self.area():.2f}"
def __repr__(self):
return f"{self.name}()"
def __eq__(self, other):
return self.area() == other.area()
def __lt__(self, other):
return self.area() < other.area()
def __gt__(self, other):
return self.area() > other.area()
class Circle(Shape):
def __init__(self, name, radius):
super().__init__(name)
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
def __repr__(self):
return f"Circle('{self.name}', radius={self.radius})"
class Square(Shape):
def __init__(self, name, side):
super().__init__(name)
self.side = side
def area(self):
return self.side ** 2
def perimeter(self):
return 4 * self.side
def __repr__(self):
return f"Square('{self.name}', side={self.side})"
class Rectangle(Shape):
def __init__(self, name, width, height):
super().__init__(name)
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
def __repr__(self):
return f"Rectangle('{self.name}', width={self.width}, height={self.height})"
# --- Demo ---
shapes = [Circle("pizza", 5), Square("window", 4), Rectangle("desk", 3, 6)]
for shape in shapes:
print(shape)
print()
print("Sorted by area:")
for shape in sorted(shapes):
print(f"{repr(shape):>36s} -> area = {shape.area():.2f}")Output:
pizza with area 78.54
window with area 16.00
desk with area 18.00
Sorted by area:
Square('window', side=4) -> area = 16.00
Rectangle('desk', width=3, height=6) -> area = 18.00
Circle('pizza', radius=5) -> area = 78.54
Quick Reference
| Concept | What It Means | Where We Saw It |
|---|---|---|
| Class | A blueprint for creating objects | class Shape: |
| Instance | A specific object created from a class | c = Circle("pizza", 5) |
| Attribute | Data stored on an object | self.radius |
| Method | A function that belongs to an object | def area(self): |
__init__ |
Sets up a new object’s initial state | def __init__(self, name, radius): |
__str__ |
Controls what print() displays |
"pizza with area 78.54" |
__repr__ |
Developer-friendly representation | "Circle('pizza', radius=5)" |
__eq__, __lt__, __gt__ |
Define ==, <, > for your objects |
Comparing shapes by area |
| Inheritance | A child class reuses a parent’s code | class Circle(Shape): |
super() |
Calls a method from the parent class | super().__init__(name) |
| Overriding | A child replaces a parent’s method | Each shape defines its own area() |
| Polymorphism | Same method name, different behavior per type | Looping over mixed shapes |
Resources on OOP
- CS50P (Harvard online course)
- Classes and Objects — the Basics
- Classes and Objects — Digging a little deeper