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.balance6 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
Pathobject 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
Pathobject 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
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:
Identity and Individuality: State allows multiple instances of the same class to exist independently. Each
Pathobject can represent a different file path because each maintains its own state.Persistence: Objects can maintain information between method calls. When you create a
Pathobject, 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 aPathobject 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
Pathclass 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:
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_balanceThe __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
BankAccountobject withBankAccount() - Takes parameters that allow us to configure the new object
- Has a parameter
initial_balancewith a default value of 0, making it optional - Initializes the object’s state by setting the
balanceattribute
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:
selfrefers 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 selfallows 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:
- When you write
acc1.deposit(10), Python looks up thedepositmethod in theBankAccountclass - Python then automatically passes
acc1as the first argument to the method - Your
10becomes the second argument, corresponding to theamountparameter
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.balanceEach method:
- Takes
selfas 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:
- Creating multiple instances of the
BankAccountclass - 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):
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 = radiusThe 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**2Method 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
Shapeclass defines a basicget_areamethod that returns 0 - The
Circleclass overrides this method with its own implementation that calculates a circle’s area using π × r² - The
Rectangleclass 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_messagemethod in theCircleclass - Not finding it there, Python looks in the parent class (
Shape) and finds it - The
print_messagemethod callsself.get_area() - Even though this call is made from code in the
Shapeclass,selfrefers to theCircleobject - Python looks for
get_areain theCircleclass first and finds the overridden version - The
Circleversion ofget_areais 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 directoryOne 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
Basic Bank Account Inheritance: Create a
SavingsAccountclass that inherits from theBankAccountclass we defined earlier. Add aninterest_rateattribute and anadd_interestmethod 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
Documentwith attributes fortitleandauthor, and a methodget_summary()that returns “No summary available”. Then create at least two subclasses (e.g.,EmailandReport) that override theget_summary()method with appropriate implementations. ForEmail, include attributes likesubjectandrecipient, and forReport, include attributes liketopicandpage_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
Triangleclass 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
NegativeValueErrorthat inherits fromValueError. Then modify theBankAccountclass to raise this exception when someone tries to deposit or withdraw a negative amount. Write a program that demonstrates catching this custom exception.