7. OOP Introduction
When I first began programming in Python, I was doing messy code (most beginner data scientist are famous for their messy code). Then, I learned functional programming; the Python main point of using functions is to avoid repetition as mention in PEP 8 performance tips (DRY) which is: don’t repeat yourself. While the true idea behind function programming is to use pure functions like map
, filter
, or reduce
to transform your data without side effects—this means that the function only effects its input not other parts of the code.
However, when I start using PyTorch for building custom neural networks, I found that combining FP techniques with object-oriented programming (OOP) often yields cleaner code. Python is a multi-paradigm language, and mixing styles is common practice. In fact, for a lot of problems, you will often solve some parts of the problem in a functional way and other parts with objects (Nelson, 2024). So, Keeping both tools in my toolbox made my code cleaner, more flexible, and readable.
From a historical perspective, OOP itself was born out of the need to model complex systems in code. In the 1960s, languages like Simula (1961 - 1967) introduced essential OOP ideas such as classes, inheritance, and dynamic binding. These allowed programmers to group data and behaviour into “objects” instead of only writing procedural steps. Over decades, many programming languages uses OOP as their main style like Java and C++. Today, many Python tools, library, even Python itself embraces OOP, providing classes, inheritance, and polymorphism similar to legendary programming languages.
Defining classes
A class itself defines an object, and you can think of it as a template for building various objects. An individual object is an instance of that class, and each object is an individual “thing” (Nelson, 2024).
class Car:
"""A simple class representing a car."""
# attributes = adjectives or properties
def __init__(self, color, speed):
self.color = color
self.speed = speed
# methods = actions
def start_engine(self):
pass
def stop_engine(self):
pass
When I write class Car:
, Python bundles data and functions together into one unit. As the official docs explain, “Classes provide a means of bundling data and functionality together”. In the code snippet above, Car
is a new type, and each instance will have a color
and speed
attribute (set by __init__
).
Naming convention
According to PEP 8, class names should use the PascalCase
convention. That means I write class Car:
or class BankAccount
, not class car
or class bank_account
. By contrast, methods and functions should be snake_case
. As we said in lecture 1, following these naming rules helps coders (like you and me) read the code more easily.
The initialisation constructor
Every class can define a special method named __init__()
, which Python calls automatically when creating a new instance. This method, as the name suggested, initialises the object’s initial state. In my Car
example, __init__
takes color and speed as parameters and assigns them to the instance. So, consider __init__
as your chance to set up any attributes the object needs.
class Book:
def __init__(self, title, author, year, pages = None):
"""
Initialises the Book object with its basic properties.
Args:
title (str): the name of the book.
author (str): the name of the book's author.
year (int): the year the book published.
pages (int, optional): the number of pages in the book.
"""
self.title = title
self.author = author
self.year = year
self.pages = pages
# Create a new instance of Book
book = Book(
title="The Spider",
author="Mustafa Mahmoud",
year=1995
pages=50
)
print(book.title, book.year)
When I write book = Book(...)
, Python creates a new Book
object and automatically calls __init__
, setting all the properties that we have (title, author, year, pages). The self
parameter inside __init__
refers to the instance being created (more on self
below). After construction, every Book
object has its own title, author, year, pages attributes attached.
You are building a system to catalogue your book collection. Your task is to define a Book
class that can hold the title, author, and publication year of a book.
- Create a class named
Book
. Remember thePascalCase
convention for class names. - Define the constructor
__init__
method. This method should accepttitle
,author
, andyear
as arguments. - Inside
__init__
, assign these arguments to instance attributes. It’s conventional to use the same names, e.g.,self.title = title
. - After defining the class, create at least two different
Book
objects representing your favourite books. - Print out the
title
andauthor
of each book object you created to make sure the attributes were set correctly.
hint
The __init__
method is the first place you’ll use the self
keyword. It refers to the specific instance of the class being created. Every time you create a new book, self
points to that specific book.
solution
# 1. Define the Class with PascalCase naming
class Book:
# 2. Write the constructor
def __init__(self, title, author, year):
"""Initialises a new Book object."""
# 3. Assign attributes to the instance (self)
print(f"Creating a book: {title}...")
self.title = title
self.author = author
self.year = year
# 4. Instantiate two objects from our Book class
book1 = Book("Dune", "Frank Herbert", 1965)
book2 = Book("The Pragmatic Programmer", "Andrew Hunt & David Thomas", 1999)
# 5. Verify the attributes of each instance
print("\n--- My Bookshelf ---")
print(f"Book 1 Title: {book1.title}, Author: {book1.author}")
print(f"Book 2 Title: {book2.title}, Author: {book2.author}")
Building a simple array class
To tie everything together, I often build toy examples. For instance, I might write a simple class that mimics a tiny subset of a NumPy-like array. As you know every class has two main components: attributes (adjectives/properties) and methods (actions). So for OurArray
we will use two attributes; data
to store inputs as a list and shape to return the size of the array. Also, two methods; mean and sum. But first list see how these attributes and methods looks like in the real NumPy array.
import numpy as np
data = [1, 2, 3, 4]
# Creating an array instance
numpy_array = np.array(data)
# Attributes
print(
f"Attributes",
f"Data: {data}",
f"Shape: {numpy_array.shape}",
sep="\n"
)
# Methods
np_mean = numpy_array.mean()
np_sum = numpy_array.sum()
print(
f"\nMethods",
f"The mean is: {np_mean}",
f"The sum is: {np_sum}",
sep="\n"
)
Attributes
Data: [1, 2, 3, 4]
Shape: (4,)
Methods
The mean is: 2.5
The sum is: 10
Since we have seen how the attributes and methods we chose work in the NumPy world, let’s try to build something like them within our world under a class named OurArray
.
class OurArray:
"""
A simple array class with methods for sum and mean.
"""
def __init__(self, data):
"""Initialises the array with a list of numbers."""
self.data = data
self.shape = len(self.data)
def mean(self):
"""Calculates the mean (average) of all elements in the array."""
return sum(self.data) / len(self.data)
def sum(self):
"""Calculates the sum of all elements in the array."""
total = 0
for item in self.data:
total += item
return total
# --- Example Usage ---
# Create an instance of OurArray
our_list_array = OurArray([1, 2, 3, 4])
# Attributes
print(
f"Attributes",
f"Data: {our_list_array.data}",
f"Shape: {our_list_array.shape}",
sep="\n"
)
# Methods
our_mean = our_list_array.mean()
our_sum = our_list_array.sum()
print(
f"\nMethods",
f"The mean is: {our_mean}",
f"The sum is: {our_sum}",
sep="\n"
)
Attributes
Data: [1, 2, 3, 4]
Shape: 4
Methods
The mean is: 2.5
The sum is: 10
Despite creating a successful array, there are still a lot of things needed like error and exception handling. But, I will leave that to you to play with.
SimpleStack
ProjectTo build a custom SimpleStack
class that encapsulates a list, implements the LIFO (Last-In, First-Out) principle, and integrates with Python’s built-in functions via dunder methods
Note:
Dunder methods, also known as magic methods or special methods, are a core feature of Python’s object model. The term “dunder” is an abbreviation for “double underscore”, referring to the characteristic naming convention where these methods are enclosed by double underscores (e.g., __init__
, __str__
, __add__
)
Part A: The initialisation
- Define a class called
SimpleStack
. Its__init__
method should create a single “internal” instance attribute,items
, and initialise it as empty list.
Part B: Methods
Implement the essential methods that define a stack’s behaviour.
2. push(self, item)
that adds an item
to the top of the stack.
3. pop(self)
that removes and returns the item from the top of the stack. If the stack is empty, this should raise an IndexError
(which list.pop()
does automatically).
4. peek(self)
that returns the top item without removing it.
5. is_empty(self)
that returns True
if the stack has no items, False
otherwise.
Part c: Dunder methods
Make your class feel more like a native Python object by implementing these dunder methods:
6. __len__(self)
should return the number of items currently in the stack.
7. __str__(self)
should return a user-friendly string representation, e.g., SimpleStack([item1, item2, 'top'])
.
Part D: Last touches
Write a script after your class definition to test its LIFO functionality
8. Create an instance of SimpleStack
.
9. Push the strings ‘A’, ‘B’, and ‘C’ onto the stack.
10. Print the stack to see its contents.
11. Print the length of the stack.
12. Use .peek()
to see the top item (‘C’).
13. Use .pop()
to remove the top item and print the removed item.
14. Print the stack again to show that ‘C’ is gone.
hint
A Python list’s .append()
method is perfect for a push
operation, and its .pop()
method (with no arguments) already implements the LIFO behaviour you need for your pop
method.
solution
class SimpleStack:
"""
A simple stack implementation that follows the LIFO principle.
"""
# Part A: The Skeleton
def __init__(self):
"""Initialises the SimpleStack with an empty list for storage."""
self._items = []
# Part B: Core Stack Behaviour
def push(self, item):
"""Adds an item to the top of the stack."""
self._items.append(item)
def pop(self):
"""Removes and returns the top item from the stack."""
if self.is_empty():
raise IndexError("pop from an empty stack")
return self._items.pop()
def peek(self):
"""Returns the top item without removing it."""
if self.is_empty():
return None
return self._items[-1]
def is_empty(self):
"""Returns True if the stack is empty, False otherwise."""
return len(self._items) == 0
# Part C: Python Integration
def __len__(self):
"""Allows the len() function to work on this object."""
return len(self._items)
def __str__(self):
"""Provides a user-friendly string representation of the stack."""
return f"SimpleStack({self._items})"
# Part D: Putting It All Together
print("--- Testing SimpleStack ---")
# 1. Create an instance
s = SimpleStack()
print(f"Is stack empty? {s.is_empty()}")
# 2. Push items
s.push('A')
s.push('B')
s.push('C')
# 3. Print the stack
print(f"Stack after pushes: {s}")
# 4. Print the length
print(f"Length of stack: {len(s)}")
# 5. Peek at the top item
top_item = s.peek()
print(f"Peeking at top item: {top_item}")
# 6. Pop the top item
popped_item = s.pop()
print(f"Popped item: {popped_item}")
# 7. Print the stack again
print(f"Stack after pop: {s}")
print(f"Length after pop: {len(s)}")
Final note on OOP
From my small experience, OOP is valuable because it promotes modular, reusable, and maintainable code. OOP almost checks all performance books, it is optimised for speed, memory, and most importantly scalability. Whenever you need to extend the functionality of your program, I can often add a new class or method without rewriting everything.
References
[1] Guido van Rossum, Barry Warsaw, & Alyssa Coghlan. (2001). PEP 8 - Style Guide for Python Code. https://peps.python.org/pep-0008/
[2] Catherine Nelson. (2024). Software Engineering for Data Scientists. O’Reilly Media, Inc. https://www.oreilly.com/library/view/software-engineering-for/9781098136192/