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
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 likename
(the filename) andsuffix
(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 likeexists()
to check if the path exists in the file system, ormkdir()
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
= Path("data/economic_indicators.csv") data_file
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
= Path("/home/user") home_dir
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:
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.Persistence: Objects can maintain information between method calls. When you create a
Path
object, it remembers its path information for later use in methods likeexists()
oris_file()
.Context for Behavior: Methods operate in the context of an object’s state. The
rename()
method of aPath
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:
= Path("report.pdf")
file1 = Path("data.csv") file2
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:
= Path("data/economic_indicators.csv")
data_file
# 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
= data_file.absolute()
abs_path 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:
And used like this:
= BankAccount(100)
acc1 10)
acc1.deposit(
= BankAccount(50)
acc2 30)
acc2.withdraw(
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 withBankAccount()
- 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
:
= BankAccount(100)
acc1 10) # Python translates this to: BankAccount.deposit(acc1, 10) acc1.deposit(
What’s happening here:
- When you write
acc1.deposit(10)
, Python looks up thedeposit
method in theBankAccount
class - Python then automatically passes
acc1
as the first argument to the method - Your
10
becomes the second argument, corresponding to theamount
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
= BankAccount(100) # Create account with 100 balance
acc1 10) # Add 10 to acc1's balance
acc1.deposit(
= BankAccount(50) # Create a different account with 50 balance
acc2 30) # Subtract 30 from acc2's balance
acc2.withdraw(
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:
- Creating multiple instances of the
BankAccount
class - Each instance maintains its own state (balance)
- Calling methods on specific instances affects only that instance’s state
- 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):
= self.get_area()
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
= Circle('Earth',3)
c 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 basicget_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:
- Python first looks for a
print_message
method in theCircle
class - Not finding it there, Python looks in the parent class (
Shape
) and finds it - The
print_message
method callsself.get_area()
- Even though this call is made from code in the
Shape
class,self
refers to theCircle
object - Python looks for
get_area
in theCircle
class first and finds the overridden version - The
Circle
version ofget_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):
= 0
total for shape in shapes:
+= shape.get_area() # Polymorphic method call
total return total
# Create a list containing different types of shapes
= [Circle("Circle1", 2), Rectangle("Rectangle1", 3, 4), Circle("Circle2", 1)]
shapes
# Calculate total area
= calculate_total_area(shapes)
total_area 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
= datetime.datetime.now() # Current date and time
today = datetime.datetime.fromisoformat('2023-04-15T14:30:00')
specific_date = datetime.datetime.fromtimestamp(1672531200) # Jan 1, 2023
timestamp_date
# Path utilities
= pathlib.Path.home() # Get user's home directory
home_dir = pathlib.Path.cwd() # Get current working directory cwd
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:
= BankAccount(100)
account 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:
= BankAccount(100)
account 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:
= BankAccount(100)
account 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
Basic Bank Account Inheritance: Create a
SavingsAccount
class that inherits from theBankAccount
class we defined earlier. Add aninterest_rate
attribute and anadd_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.Document Types Hierarchy: Create a base class called
Document
with attributes fortitle
andauthor
, and a methodget_summary()
that returns “No summary available”. Then create at least two subclasses (e.g.,Email
andReport
) that override theget_summary()
method with appropriate implementations. ForEmail
, include attributes likesubject
andrecipient
, and forReport
, include attributes liketopic
andpage_count
. Write a functionprint_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.Shape Hierarchy Extension: Extend our shape hierarchy by adding a
Triangle
class that inherits fromShape
. The constructor should take a name and three sides. Implement theget_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.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 fromValueError
. Then modify theBankAccount
class to raise this exception when someone tries to deposit or withdraw a negative amount. Write a program that demonstrates catching this custom exception.