6  Classes

Classes are user-defined data types. Just as Python gives you built-in types like lists and dictionaries, classes let you define your own types that represent concepts specific to your program - like a BankAccount, Customer, or ChessBoard. You get to define how objects that belong to your class store the data associated with them. You also define the methods available for each object of your class.

As programs become larger, we must find ways to structure them to save ourselves from drowning in a sea of complexity. One broad principle of organization is that of modularity. We break our program into parts in a way that each part hides some amount of complexity in its interior but provides a simple and limited exterior interface through which other modules can interact with it. Think of the wiring system of your house and your washing machine. Both are complex systems in their own right. But they interface through a simple plug socket and switch system. This ensures that the complexity of each is contained within its boundaries and we can understand them one at a time.

We have already seen two major ways of implementing modularity in Python. The first are packages and modules (look at the name itself!). The other are functions. A user of a function needs to only know the input it expects and the output it produces. The process of computing the output is contained within the function and is shielded from its user.

Classes add another tool to our modularity toolbox. By collecting related function together they act somewhat like modules. But each object of a class can also store data. Thus class becomes carrier of state. Below we will develop a class to represent a bank account. Objects of this class will not only provide a coherent class of methods to operate on a bank account, they will also remember the balance in the account, so that the users of this class do not have to bother directly with the storage of this data. As programs become larger, not just the computations, but the data structures needed to support them also become complicated. Classes hide this data complexity from users, offering them a simple interface. Thus they do for data what functions do for computation.

There is more. Like many other programming languages, Python allows classes to be connected to each other in a hierarchy. Each class can have subclasses, with the intent being that subclasses provide specialized versions of the functionalities provided by its superclass. For example a machine learning library may provide a general Model class with features common to all models, and then specialized LinearRegression, DecisionTree, and NeuralNetwork subclasses that inherit those features while adding their own unique characteristics.

Programming with classes and class hierarchies is known as object-oriented programming. This chapter introduces its most important aspects as they apply to Python.

6.1 Classes in Python

In Python, everything is an object. When we use a string, list, or dictionary, we’re working with objects of those respective classes. A class defines what attributes and methods its objects will have:

  • Attributes are the data or variables that belong to an object. They represent the state or characteristics of the object. For example, a Path object has attributes like name (the filename) and suffix (the file extension).

  • Methods are functions that belong to an object. They define the behaviors or actions that the object can perform. For example, a Path object has methods like exists() to check if the path exists in the file system, or mkdir() to create a directory at that path.

Let’s examine the Path class from the pathlib module, which provides an object-oriented way to work with file system paths:

from pathlib import Path

# Creating a Path object
data_file = Path("data/economic_indicators.csv")

Here, data_file is an instance (object) of the Path class. The Path class itself is the blueprint that defines what all Path objects can do.

6.1.1 Constructors

When you create a new object, Python calls a special method called a constructor. In Python, the constructor method is named __init__. This method initializes the new object with any attributes it needs.

For the Path class, the constructor takes a string representing a file path:

# The constructor is called when we create a new Path object
home_dir = Path("/home/user")

Behind the scenes, the __init__ method sets up the internal state of the home_dir object.

6.1.1.1 The Importance of State in Classes

State is a fundamental concept in object-oriented programming. The “state” of an object refers to all the data it contains at a given point in time. This state is essential to the functioning of many classes for several reasons:

  1. Identity and Individuality: State allows multiple instances of the same class to exist independently. Each Path object can represent a different file path because each maintains its own state.

  2. Persistence: Objects can maintain information between method calls. When you create a Path object, it remembers its path information for later use in methods like exists() or is_file().

  3. Context for Behavior: Methods operate in the context of an object’s state. The rename() method of a Path object knows which file to rename because that information is part of the object’s state.

Classes encapsulate this state, meaning they bundle the data with the methods that operate on that data. This encapsulation provides several benefits:

  • Abstraction: Users of the class don’t need to know how the state is stored or managed internally. For example, users of the Path class don’t need to understand how file paths are represented internally.

  • Information Hiding: The internal state can be protected from direct manipulation, preventing invalid states. Many classes use private attributes (conventionally prefixed with _) to indicate that these attributes shouldn’t be accessed directly.

  • Interface Stability: The internal representation of state can change without affecting code that uses the class, as long as the methods (the interface) remain the same.

In essence, a well-designed class provides an abstract interface through its methods, allowing users to interact with the object’s state in a controlled, meaningful way without needing to understand the implementation details.

6.1.2 Instances vs. Classes

It’s important to distinguish between a class and its instances:

  • A class is the blueprint or template (e.g., Path)
  • An instance is a specific object created from that class (e.g., data_file)

You can create multiple instances from the same class, each with its own state:

file1 = Path("report.pdf")
file2 = Path("data.csv")

Both file1 and file2 are Path objects, but they represent different file paths.

6.1.3 Methods as Interfaces

Methods define the behaviors of objects - what they can do. They provide an interface for interacting with the object.

The Path class offers many methods for working with file paths:

data_file = Path("data/economic_indicators.csv")

# Using methods
if data_file.exists():
    print("File exists!")
    
# Check if it's a file (not a directory)
if data_file.is_file():
    print("It's a file")
    
# Get the absolute path
abs_path = data_file.absolute()
print(abs_path)

Methods often use the object’s state (attributes) to perform their operations. For example, the exists() method checks if the file path stored in the Path object exists in the file system.

6.2 Writing your own classes

Suppose we want to create a BankAccount class which will keep track of balance in a bank account and allow deposits and withdrawals to be tracked. A simple version would be defined like this:

class BankAccount:
    def __init__(self, initial_balance=0):
        self.balance = initial_balance

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        self.balance -= amount

    def check(self):
        return self.balance

And used like this:

acc1 = BankAccount(100)
acc1.deposit(10)

acc2 = BankAccount(50)
acc2.withdraw(30)

print("Money in account 1", acc1.check())
print("Money in account 2", acc2.check())
Money in account 1 110
Money in account 2 20

Let’s break down the BankAccount class definition to understand how Python classes work:

6.2.1 Class Declaration

class BankAccount:

This line declares a new class named BankAccount. The class keyword tells Python we’re defining a blueprint for creating bank account objects. The rules for class names are the same as those for variable names, though it is conventional to start class names with an upper-case letter.

6.2.2 The __init__ Method (Constructor)

def __init__(self, initial_balance=0):
    self.balance = initial_balance

The __init__ method is a special method called when a new instance of the class is created. It’s Python’s version of a constructor. This method:

  • Is automatically called when we create a new BankAccount object with BankAccount()
  • Takes parameters that allow us to configure the new object
  • Has a parameter initial_balance with a default value of 0, making it optional
  • Initializes the object’s state by setting the balance attribute

When we create accounts with acc1 = BankAccount(100) and acc2 = BankAccount(50), the __init__ method is called with the values 100 and 50 respectively, setting each account’s initial balance.

6.2.3 Understanding self

The self parameter is crucial to understanding Python classes:

  • self refers to the specific instance of the class that is being created or operated on
  • It’s always the first parameter in methods
  • Through self, methods can access and modify the object’s attributes
  • self allows each object to maintain its own separate state

When a new object is created in Python, it starts as a blank slate. Unlike some other languages, Python objects don’t have a predefined set of attributes. Instead, attributes can be added to an object at any point. Usually the __init__ method creates attributes which are essential for the operation of the object.

6.2.4 How Method Calls Work Behind the Scenes

When you call a method on an object, Python automatically passes the object itself as the first argument to the method. This is why the first parameter in instance methods is conventionally named self:

acc1 = BankAccount(100)
acc1.deposit(10)  # Python translates this to: BankAccount.deposit(acc1, 10)

What’s happening here:

  1. When you write acc1.deposit(10), Python looks up the deposit method in the BankAccount class
  2. Python then automatically passes acc1 as the first argument to the method
  3. Your 10 becomes the second argument, corresponding to the amount parameter

This automatic passing of the instance as the first argument is what allows methods to access and modify the specific object’s state. It’s why we can have multiple bank accounts, each with its own balance, and operations on one account don’t affect others.

In our example, self.balance refers to the balance attribute of the specific account object. When we have two accounts (acc1 and acc2), each has its own separate balance attribute. When acc1.deposit(10) is called, it modifies acc1’s balance, not acc2’s.

6.2.5 Methods

The BankAccount class defines three methods that operate on the account’s state:

def deposit(self, amount):
    self.balance += amount

def withdraw(self, amount):
    self.balance -= amount

def check(self):
    return self.balance

Each method:

  • Takes self as its first parameter to access the object’s state
  • Performs operations on the object’s attributes
  • May take additional parameters as needed (like amount)

The deposit method increases the account’s balance, withdraw decreases it, and check returns the current balance.

6.2.6 Using the Class

acc1 = BankAccount(100)  # Create account with 100 balance
acc1.deposit(10)         # Add 10 to acc1's balance

acc2 = BankAccount(50)   # Create a different account with 50 balance
acc2.withdraw(30)        # Subtract 30 from acc2's balance

print("Money in account 1", acc1.check())  # Display acc1's balance (110)
print("Money in account 2", acc2.check())  # Display acc2's balance (20)

This code demonstrates:

  1. Creating multiple instances of the BankAccount class
  2. Each instance maintains its own state (balance)
  3. Calling methods on specific instances affects only that instance’s state
  4. The class provides a clean interface for working with bank accounts

This example illustrates the power of object-oriented programming: we’ve created a reusable blueprint for bank accounts that encapsulates both data (the balance) and behavior (deposit, withdraw, check) in a single, coherent unit.

6.3 Inheritance

Now we come to arranging classes in hierarchies by creating classes which are specialized variants of other classes. This is called inheritance. To take a simple example, suppose we have a program for we have to keep track of multiple objects of different shapes, which each object having a name and dimensions. Suppose further that we need to print messages about the name and shape of objects.

We realize that having a name and having to print messages is a feature common to all shapes. So rather than defining each type of shape as a separate class, we will first define a common base class Shape which implements common attributes and functionality and then define subclasses like Circle and Rectangle which inherit from the shape class.

Let’s begin by defining the Shape class.

class Shape:
    def __init__(self, name):
        self.name = name

    def get_area(self):
        return 0

    def print_message(self):
        area = self.get_area()
        print(f"Shape '{self.name}' has area {area:0.2f}")

This class has a constructor which takes a name and a print_message method which prints a message about the area of the shape. The method print_message find the area of the shape by calling the get_area method of the object. We have defined get_area for the basic shape to return the value 0. But let us we see how we can override this by defining subclasses of Shape.

import math

class Circle(Shape):
    def __init__(self,name,radius):
        super().__init__(name)
        self.radius = radius

    def get_area(self):
        return math.pi*self.radius**2

class Rectangle(Shape):
    def __init__(self,name,height,width):
        super().__init__(name)
        self.h = height
        self.w = width

    def get_area(self):
        return self.h*self.w

# Examples of use
c = Circle('Earth',3)
c.print_message()
Shape 'Earth' has area 28.27

In Python, inheritance is specified by placing the parent class name in parentheses after the child class name:

class Circle(Shape):

This syntax tells Python that Circle is a subclass of Shape, meaning it inherits all attributes and methods from the Shape class. The same applies to the Rectangle class. This inheritance relationship creates an “is-a” relationship: a Circle is a Shape, and a Rectangle is a Shape.

6.3.1 Method Inheritance

When a class inherits from another class, it automatically gains access to all the methods defined in the parent class. In our example, both Circle and Rectangle inherit the print_message method from the Shape class. This is why we can call c.print_message() on a Circle object even though we didn’t define that method in the Circle class.

This inheritance mechanism promotes code reuse. We define the common behavior once in the parent class, and all subclasses automatically receive this functionality. If we later decide to modify how messages are printed, we only need to change the code in one place—the Shape class—and all subclasses will automatically use the updated behavior.

6.3.2 Constructor Inheritance and super()

When creating a subclass, we often need to initialize both the subclass’s specific attributes and the attributes defined in the parent class. This is where the super() function becomes essential:

def __init__(self, name, radius):
    super().__init__(name)
    self.radius = radius

The super() function provides a reference to the parent class. By calling super().__init__(name), we’re invoking the parent class’s constructor, which sets up the name attribute. After that, we initialize the subclass-specific attribute radius.

Without this call to the parent’s constructor, the name attribute would never be set, and methods like print_message that rely on self.name would fail. The super() function ensures proper initialization of the entire inheritance chain.

6.3.3 Method Overriding and Dynamic Dispatch

One of the most powerful aspects of inheritance is the ability to override methods from the parent class. In our example, both Circle and Rectangle override the get_area method defined in the Shape class:

# In Shape class
def get_area():
    return 0

# In Circle class
def get_area(self):
    return math.pi * self.radius**2

Method overriding occurs when a subclass redefines a method that is already defined in its parent class. This allows each subclass to implement behavior appropriate to its specific nature while maintaining a common interface.

In our example:

  • The Shape class defines a basic get_area method that returns 0
  • The Circle class overrides this method with its own implementation that calculates a circle’s area using π × r²
  • The Rectangle class also overrides this method with a formula specific to rectangles (height × width)

When we call c.print_message() on a Circle object, the following sequence occurs:

  1. Python first looks for a print_message method in the Circle class
  2. Not finding it there, Python looks in the parent class (Shape) and finds it
  3. The print_message method calls self.get_area()
  4. Even though this call is made from code in the Shape class, self refers to the Circle object
  5. Python looks for get_area in the Circle class first and finds the overridden version
  6. The Circle version of get_area is executed, calculating π × r²

This mechanism, known as dynamic dispatch or dynamic method resolution, is fundamental to object-oriented programming. The actual method that gets called depends on the runtime type of the object, not where the method is called from. This allows for polymorphic behavior—different objects responding differently to the same method call.

In our example, when we call print_message on a Circle object, it correctly calculates and displays the circle’s area. If we called it on a Rectangle object, it would calculate the rectangle’s area instead. The Shape class doesn’t need to know anything about how different shapes calculate their areas—it just knows that shapes have areas.

This powerful feature allows us to write code that works with objects at an abstract level (treating them as “shapes”) while still getting behavior specific to the concrete type of each object (circles, rectangles, etc.). It’s a cornerstone of extensible, maintainable object-oriented design.

Many libraries provide classes like our Shape class which implement some general design or algorithm. The users of the library are then expect to define subclasses of the base class provided by the library and override a few key methods to apply the general design to their specific problem.

6.3.4 Runtime Polymorphism

The fact that all Shape subclasses provide a get_area method enables runtime polymorphism. The term “polymorphism” comes from Greek words meaning “many forms,” and it refers to the ability of different objects to respond to the same method call in different ways.

For example, we can now write write code like this:

def calculate_total_area(shapes):
    total = 0
    for shape in shapes:
        total += shape.get_area()  # Polymorphic method call
    return total

# Create a list containing different types of shapes
shapes = [Circle("Circle1", 2), Rectangle("Rectangle1", 3, 4), Circle("Circle2", 1)]

# Calculate total area
total_area = calculate_total_area(shapes)
print(f"Total area: {total_area}")

In this code, the calculate_total_area function doesn’t need to know what specific types of shapes it’s dealing with. It does not even need to know about the different types of shapes that exist. It simply calls get_area() on each shape, and Python automatically calls the appropriate version of the method based on each object’s actual type. This enables a strong form of modularity. Users of shapes become insulated to a large extent from the details of how shapes are implemented.

In many other programming languages class hierarchies are the only way to get runtime polymorphism. This is not so in Python. The above-example would have worked equally well if Circle and Rectangle had not been subclasses of a common Shape class, as long as they each had a get_area method.

This is why class hierarchies are not as heavily used in Python as in other languages that support object-oriented programming.

6.4 Methods that don’t operate on objects

Not all methods in a class need to operate on specific instances. Python classes can include methods that work at the class level or that simply provide utility functions related to the class’s domain. We will not show you how to define such methods for your own classes, but you should know about them since many classes for popular libraries do provided them.

6.4.1 Examples from the Standard Library

The Python standard library contains many examples of classes with methods that don’t operate on specific objects:

import datetime
import math
import pathlib
import string

# Creating datetime objects without using constructors
today = datetime.datetime.now()  # Current date and time
specific_date = datetime.datetime.fromisoformat('2023-04-15T14:30:00')
timestamp_date = datetime.datetime.fromtimestamp(1672531200)  # Jan 1, 2023

# Path utilities
home_dir = pathlib.Path.home()  # Get user's home directory
cwd = pathlib.Path.cwd()  # Get current working directory

One of the main benefits of including these types of methods in classes is namespacing. By organizing related functions within a class, even when they don’t operate on instances, we create a logical grouping that makes code more organized and discoverable. We are using a class as a sort of module.

6.5 Making your objects printable

When you try to print an object in Python or convert it to a string, Python needs to know how to represent that object as text. By default, Python provides a generic representation that shows the object’s class and memory address, which isn’t very informative:

account = BankAccount(100)
print(account) 
<__main__.BankAccount object at 0x7f2664127ed0>

Python provides two special methods that allow you to customize how your objects are represented as strings:

6.5.1 The __str__ Method

The __str__ method defines the “informal” or user-friendly string representation of an object. This is what’s used when you:

  • Call str(object)
  • Use print(object)
  • Use f-strings like f"{object}"

Let’s add a __str__ method to our BankAccount class:

class BankAccount:
    def __init__(self, initial_balance=0):
        self.balance = initial_balance
        
    def deposit(self, amount):
        self.balance += amount
        
    def withdraw(self, amount):
        self.balance -= amount
        
    def check(self):
        return self.balance
        
    def __str__(self):
        return f"Bank account with balance: ₹{self.balance:.2f}"

Now when we print a bank account, we get a more informative message:

account = BankAccount(100)
print(account) 
Bank account with balance: ₹100.00

6.5.2 The __repr__ Method

The __repr__ method defines the “official” string representation of an object. This representation should ideally be unambiguous and, when possible, should represent valid Python code that could recreate the object. It’s used when:

  • You call repr(object)
  • The object is displayed in the interactive console
  • The object is included in a container like a list that’s being printed

Let’s add a __repr__ method to our BankAccount class:

def __repr__(self):
    return f"BankAccount(initial_balance={self.balance})"

Now when we evaluate the object in the interactive console or call repr(), we get:

account = BankAccount(100)
repr(account)  # Output: 'BankAccount(initial_balance=100)'

6.5.3 When to Use Each Method

  • Use __str__ to provide a readable, user-friendly representation of your object
  • Use __repr__ to provide a complete, unambiguous representation, ideally one that could be used to recreate the object

If you only implement one of these methods, implement __repr__. If __str__ is not defined, Python will fall back to using __repr__.

6.6 Conclusion

When object-oriented programming emerged in the 1980s and 1990s, it arrived with considerable fanfare. Software engineering conferences and textbooks promoted OOP as a revolutionary paradigm that would transform software development. There was a strong emphasis on modeling real-world relationships through elaborate class hierarchies, and many projects created complex inheritance structures with dozens of layers. Design patterns became a hot topic, and some developers seemed to measure their expertise by the complexity of their class designs.

Over time, the hype around OOP has naturally subsided, and the programming community has developed a more balanced perspective. Today, classes and objects are viewed as practical tools rather than a programming philosophy. Modern Python code often combines functional, procedural, and object-oriented approaches based on what works best for the specific problem. Classes are used when they provide clear benefits—like encapsulating related data and behavior or enabling code reuse through inheritance—but simpler approaches are preferred when appropriate. This pragmatic approach has allowed OOP to find its rightful place in the programmer’s toolbox: not as a silver bullet, but as a valuable technique that, when applied judiciously, can lead to more maintainable and extensible code.

6.7 Exercises

  1. Basic Bank Account Inheritance: Create a SavingsAccount class that inherits from the BankAccount class we defined earlier. Add an interest_rate attribute and an add_interest method that increases the balance by the interest rate. Test your class by creating a savings account, making some deposits and withdrawals, and then adding interest.

  2. Document Types Hierarchy: Create a base class called Document with attributes for title and author, and a method get_summary() that returns “No summary available”. Then create at least two subclasses (e.g., Email and Report) that override the get_summary() method with appropriate implementations. For Email, include attributes like subject and recipient, and for Report, include attributes like topic and page_count. Write a function print_document_summaries(documents) that takes a list of documents and prints the title, author, and summary of each one. Test your function with a list containing different document types.

  3. Shape Hierarchy Extension: Extend our shape hierarchy by adding a Triangle class that inherits from Shape. The constructor should take a name and three sides. Implement the get_area() method using Heron’s formula:

    s = (a + b + c) / 2
    area = sqrt(s * (s-a) * (s-b) * (s-c))

    where a, b, and c are the lengths of the sides. Test your implementation by creating a triangle and calling its print_message() method.

  4. Custom Exception Class: Read the Python documentation on exceptions (https://docs.python.org/3/tutorial/errors.html). Create a custom exception class called NegativeValueError that inherits from ValueError. Then modify the BankAccount class to raise this exception when someone tries to deposit or withdraw a negative amount. Write a program that demonstrates catching this custom exception.