9.1. OOP Design & Methods#
9.1.1. Why Object-Oriented Programming?#
Procedural programs often pass the same data through many separate functions. For example, the deposit() and withdraw() functions of a banking account management program would have the same parameter (data intake) of owner_id, balance, and amount.
def deposit(owner, balance, amount):
return owner, balance + amount
def withdraw(owner, balance, amount):
if amount > balance:
raise ValueError("Insufficient funds")
return owner, balance - amount
…
You may create a file called banking.py and throw in the functions. In Python shell, you may do import banking, and then you can actually use the functions.
>>> import banking
>>> banking.deposit('Tom', 1000, 100)
('Tom', 1100)
>>>
This procedural style can work well for small scripts, but once the banking program starts to grow in the number of functions (which will eventually become non-trivial), it becomes harder to keep track of which data (the parameters) belongs together and which functions are allowed to change it.
The same problem will occur when we model our programs and systems based on real world things like library management information, student information system, or even game characters.
Object-Oriented Programming (OOP) helps by organizing related data and behavior (functions/methods) into a class, which you then use to create objects.
Key Benefits of Using OOP include:
Modularity and Organization: OOP allows you to structure your code by breaking complex systems into smaller, manageable pieces (classes). This makes the code easier to navigate and maintain as projects grow.
Code Reusability: Through inheritance, you can create a base class and reuse its logic in multiple child classes, adhering to the “Don’t Repeat Yourself” (DRY) principle.
Easier Debugging: Because data and the functions that manipulate it are bundled together in objects, it is often easier to isolate and fix errors without affecting the rest of the program.
Real-World Modeling: OOP makes it intuitive to model real-world entities like “Users,” “BankAccount” objects, or “GameCharacters” by giving them specific properties (attributes) and actions (methods).
Python is object-oriented at its core. While Python is multi-paradigm and allows for functional or procedural styles, OOP is its core foundation. In fact, everything in Python is an object, including integers and strings.
This can be evidenced by seeing that Python values are already objects because they have methods:
Value |
Type (Class) |
Example method |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
When you call [1, 2, 3].sort(), you are calling a method on a list object. In this chapter, we apply the same idea to custom classes.
Note the column header Type (Class): in Python 3, the two words mean the same thing. Every class you define is a type, and every built-in type like str or list is a class. The terms are used interchangeably throughout this chapter.
9.1.2. Classes and Instances#
A class is a blueprint that defines data and behavior. An instance is one concrete object created from that blueprint.
Think of a class as a cookie cutter and each instance as an individual cookie. The cutter defines the shape; every cookie produced from it is a separate object with its own toppings (attributes).
Term |
Meaning |
Cookie analogy |
|---|---|---|
Class |
Blueprint / template |
The cookie cutter |
Instance |
One object made from the class |
A specific cookie |
Attribute |
Data stored on the instance |
Icing color, sprinkles |
Method |
Function that belongs to the class |
|
The basic syntax for defining a class in Python looks like this:
class ClassName:
"""Optional docstring describing the class."""
def __init__(self, attribute1, attribute2): # constructor
self.attribute1 = attribute1 # store data on the instance
self.attribute2 = attribute2
def method_name(self, other_param): # instance method
# self always refers to the current instance
return self.attribute1 + other_param
class ClassName:declares a new class (use PascalCase by convention)__init__: the constructor that runs automatically when you create a new instanceself: the instance itself; Python passes it automaticallydef method_name(self, ...): any method that operates on the instance
Here is a simple Point class. As you can see, it creates 2D points and can calculate the distance between two points.
class Point:
"""Represent a 2D point with x and y coordinates."""
def __init__(self, x, y):
self.x = x
self.y = y
def distance_to(self, other):
"""Return Euclidean distance from this point to another Point."""
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
### run the code below to see the default string representation of Point objects,
### which is not very informative. Then uncomment the __str__ method to provide a
### more useful string representation.
# def __str__(self):
# return f"Point({self.x}, {self.y})"
You create instances by calling the class like a function:
p = Point(3, 4) # one point object
q = Point(0, 0) # another point object
print(p)
print(q)
print(p.distance_to(q))
<__main__.Point object at 0x10cab12b0>
<__main__.Point object at 0x1133fead0>
5.0
Even though the points come from the same class, they are independent objects.
r = Point(3, 4) # a third point object with the same coordinates as p
s = Point(3, 4) # a fourth point object with the same coordinates as p and r
print(r is s)
print(r == s)
False
False
### Exercise: Classes vs Instances
# 1. Define a class `Book` with `title` and `author` in `__init__`.
# 2. Create two instances with different titles.
# 3. Print each instance's title and author on separate lines.
# 4. Print `book1 is book2` to verify they are different objects.
### Your code starts here.
### Your code ends here.
Python Design - A. Chen
Data Workflows - B. Diaz
False
9.1.3. A First Class#
Here is a simple sample class: CalcTwo, a calculator that performs operations on two input numbers. Methods still use self because they belong to an instance. Now you have a good idea that a class can contain multiple methods.
class CalcTwo:
"""Represents a calculator for operations on two numbers."""
def __init__(self):
self.last_result = None # Keep track of the most recent calculation
def add(self, a, b):
"""Return a + b."""
self.last_result = a + b # Save result on this instance
return self.last_result
def subtract(self, a, b):
"""Return a - b."""
self.last_result = a - b
return self.last_result
def multiply(self, a, b):
"""Return a * b."""
self.last_result = a * b
return self.last_result
def divide(self, a, b):
"""Return a / b."""
if b == 0:
raise ValueError("Cannot divide by zero") # Avoid undefined operation
self.last_result = a / b
return self.last_result
def __str__(self):
return f"CalcTwo(last_result={self.last_result})"
Key parts of the code above:
Part |
What it does |
|---|---|
|
Declares the class |
|
Constructor method; runs automatically when you call |
|
Controls what |
|
The instance being created or used; Python passes it automatically |
|
Stores the most recent result on this instance |
|
Returns the sum of two numbers |
|
Returns the difference of two numbers |
|
Returns the product of two numbers |
|
Returns the quotient of two numbers |
__init__(): The constructor, called automatically when a newCalcTwois created.__str__(): Returns a readable string representation of the calculator state.Arithmetic methods: each method takes two numbers and returns a computed result.
last_result: lets each instance remember its own most recent calculation.
Now create two instances of CalcTwo: c1 and c2. After instance creation, we can:
access data in the object (e.g.,
c1.last_result)make method calls with two numbers (e.g.,
c1.add(5, 2),c1.subtract(9, 4))
c1 = CalcTwo()
c2 = CalcTwo()
print(c1.add(10, 5)) # 15
print(c1.subtract(10, 3)) # 7
print(c1.multiply(4, 6)) # 24
print(c2.divide(21, 3)) # 7.0
print(c1.last_result) # 24 (independent from c2)
print(c2.last_result) # 7.0
print(c1)
15
7
24
7.0
24
7.0
CalcTwo(last_result=24)
The __init__ Method
__init__ is called a dunder method (short for “double underscore”) because its name starts and ends with two underscores. Python uses dunder methods to hook into built-in behaviors - you define them in your class, and Python calls them automatically at certain times.
__init__ is the constructor: Python calls it automatically whenever you create a new instance of a class. Its job is to initialize the object’s attributes. For example:
c = CalcTwo()
This creates a new CalcTwo object and immediately calls __init__(self), which sets self.last_result = None.
The first parameter of every method is self, which refers to the instance being created or operated on.
9.1.3.1. Custom and Built-in Types#
Let’s use two built-in tools that let you inspect objects:
type(obj)— returns the class of the objectisinstance(obj, SomeClass)— returnsTrueifobjis an instance of that class (or a subclass)
Now let’s examine the characteristics of CalcTwo and compare with Python built-in data types.
Takeaway: user-defined class instances behave like built-in objects under type() and isinstance().
c = CalcTwo()
print(type(c)) ### <class '__main__.CalcTwo'>
print(type(c) is CalcTwo) ### True
print(isinstance(c, CalcTwo)) ### True
print(isinstance(c, str)) ### False
### Custom types/classes and built-in types work the same way
print(type("hello")) ### <class 'str'>
print(type("hello") is str) ### True
print(isinstance(42, int)) ### True
print(isinstance(42, float)) ### False
<class '__main__.CalcTwo'>
True
True
False
<class 'str'>
True
True
False
9.1.3.2. Classes and Methods#
Python is an object-oriented language – that is, it provides features that support object-oriented programming, which has these defining characteristics:
Most of the computation is expressed in terms of operations on objects.
Objects often represent things in the real world, and methods often correspond to the ways things in the real world interact.
Programs include class and method definitions.
For example, consider a CalcTwo class and an operation like addition.
There is no explicit connection between a standalone function and the class unless we make one.
We can make that connection explicit by rewriting a function as a method, which is defined inside a class definition.
9.1.3.3. Defining Methods#
To start, here is a standalone function that adds two numbers and stores the result on a calculator object.
def add_two(calc, a, b):
calc.last_result = a + b
return calc.last_result
To make this behavior a method, we move the function logic inside the class.
At the same time, we change the first parameter from calc to self.
This change is not required by syntax, but it is the standard convention in Python classes.
# Method version inside the class (already defined above):
#
# class CalcTwo:
# def add(self, a, b):
# self.last_result = a + b
# return self.last_result
To call either version, we first create a CalcTwo object.
calc = CalcTwo()
Now there are two ways to call the method logic. The first (and less common) way is function-style syntax.
CalcTwo.add(calc, 9, 4)
13
In this version, CalcTwo is the class name, add is the method name, and calc is passed explicitly as the first argument.
The second (and more idiomatic) way is method syntax:
calc.add(9, 4)
13
Both calls return the same result.
The receiver object is assigned to the first parameter, so inside the method, self refers to the same object as calc.
print(calc.last_result)
13
In method syntax, calc is the object the method is invoked on (the receiver).
Regardless of syntax, behavior is the same:
CalcTwo.add(calc, 9, 4) and calc.add(9, 4) both use the same method implementation.
### Exercise: A First Class
# Define a class Rectangle with:
# 1. __init__(self, width, height) — store both as instance attributes.
# 2. __str__ — returns a string like 'Rectangle(3 x 5)'.
# 3. area(self) — returns width * height.
# Then create Rectangle(3, 5), print it, and call .area().
### Your code starts here.
### Your code ends here.
Rectangle(3 x 5)
15
9.1.4. More Methods#
In this section, we build a BankAccount class and explore the four
special methods Python programmers write most often:
Method |
Called by |
Purpose |
|---|---|---|
|
|
Initializes attributes when a new object is created |
|
|
Human-readable string |
|
|
Unambiguous, ideally reconstructable string |
|
attribute access ( |
Read-only computed attribute |
Here is the complete BankAccount class. We define it all at once and
then walk through each method in detail below.
class BankAccount:
"""A simple bank account with owner name and a dollar balance."""
def __init__(self, owner="Unknown", balance=0.0):
self.owner = owner
self._balance = balance
@property
def balance(self):
"""Current balance in dollars (read-only)."""
return self._balance
def deposit(self, amount):
"""Add amount to the balance and return self."""
self._balance += amount
return self
def withdraw(self, amount):
"""Subtract amount from the balance and return self."""
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
return self
def __str__(self):
return f"BankAccount(owner={self.owner}, balance=${self._balance:.2f})"
def __repr__(self):
return f"BankAccount('{self.owner}', {self._balance})"
acct1 = BankAccount("Ava", 945.30)
acct2 = BankAccount("Max", 500.00)
9.1.4.1. The __init__ Method#
The most special of the special methods is __init__, so-called because it
initializes the attributes of a new object. Here is the __init__ method
from our BankAccount class:
def __init__(self, owner="Unknown", balance=0.0):
self.owner = owner
self._balance = balance
When we instantiate a BankAccount object, Python invokes __init__ automatically
and passes along the arguments. So we can create an object and set its initial balance
at the same time.
account = BankAccount("Ava", 94.00)
print(account)
BankAccount(owner=Ava, balance=$94.00)
Both parameters are optional. If you call BankAccount with no arguments,
you get the default values ("Unknown" and 0.0).
account = BankAccount()
print(account)
BankAccount(owner=Unknown, balance=$0.00)
If you provide one argument, it overrides owner:
account = BankAccount("Sam")
print(account)
BankAccount(owner=Sam, balance=$0.00)
If you provide two arguments, they override owner and balance.
account = BankAccount("Sam", 45.00)
print(account)
BankAccount(owner=Sam, balance=$45.00)
When I write a new class, I almost always start by writing __init__, which
makes it easier to create objects, and __str__, which is useful for debugging.
9.1.4.2. Properties#
The @property decorator lets you define a method that behaves like an attribute.
This is useful when you want controlled read-only (or validated) access to a
value stored on the object, without changing how callers access it.
Here is the balance property from our BankAccount class:
@property
def balance(self):
"""Current balance in dollars (read-only)."""
return self._balance
The underscore in _balance signals that it is an internal attribute — callers
should use the balance property rather than accessing _balance directly.
acct = BankAccount("Mia", 150.0)
# Access like an attribute — no parentheses needed
print(acct.balance) # 150.0
# Attempting to set it raises AttributeError (read-only)
try:
acct.balance = 9999
except AttributeError as e:
print(f"Error: {e}")
# Use deposit and withdraw to change the balance
acct.deposit(50.0)
acct.withdraw(30.0)
print(acct.balance) # 170.0
150.0
Error: property 'balance' of 'BankAccount' object has no setter
170.0
9.1.4.3. The __str__ Method#
When you write a class, you can define a method named __str__ to control
how Python converts instances to a string.
For example, here is the __str__ method from our BankAccount class:
def __str__(self):
return f"BankAccount(owner={self.owner}, balance=${self._balance:.2f})"
This method returns a formatted string. You can invoke it directly:
acct2.__str__()
'BankAccount(owner=Max, balance=$500.00)'
But Python can also invoke it for you.
If you use the built-in function str to convert a BankAccount object to a string, Python uses the __str__ method in the class.
str(acct2)
'BankAccount(owner=Max, balance=$500.00)'
And it does the same if you print a BankAccount object.
print(acct2)
BankAccount(owner=Max, balance=$500.00)
Methods like __str__ are called special methods.
You can identify them because their names begin and end with two underscores.
9.1.4.4. The __repr__ Method#
__repr__ returns a string that ideally looks like a valid Python expression
that could recreate the object. It is used in the interactive interpreter and
by repr(). When __str__ is not defined, Python falls back to __repr__.
Method |
Called by |
Purpose |
|---|---|---|
|
|
Human-readable output |
|
|
Unambiguous, ideally reconstructable |
Here is the __repr__ method from our BankAccount class:
def __repr__(self):
return f"BankAccount('{self.owner}', {self._balance})"
acct = BankAccount("Ava", 945.30)
print(str(acct)) # BankAccount(owner=Ava, balance=$945.30)
print(repr(acct)) # BankAccount('Ava', 945.3)
BankAccount(owner=Ava, balance=$945.30)
BankAccount('Ava', 945.3)
### Exercise: Independent Class Practice
# Build a new class without using `BankAccount`.
# 1. Define a class `TaskList` with `owner` and `tasks` in `__init__`.
# Use an empty list for `tasks` when no list is provided.
# 2. Add `add_task(task)` to append one task to the list.
# 3. Add `task_count()` to return how many tasks are stored.
# 4. Create t1 and t2 with different owners, add tasks to each.
# 5. Print each owner with task_count(), then compare counts.
### Your code starts here.
### Your code ends here.
Lia 2
Noah 1
True
9.1.5. Summary#
In this section we covered:
Programmer-defined types: creating classes and working with object attributes
Classes and methods:
__init__,__str__,__repr__,@property
Next: The Four Pillars of OOP — applying encapsulation, polymorphism, inheritance, and abstraction with focused examples.
### Exercise: Chapter Review
# 1. Define a class `Wallet` with attributes `owner` and `cash`.
# 2. Add a method `spend(amount)` that subtracts from `cash` and
# raises `ValueError` if amount is greater than available cash.
# 3. Create `w = Wallet("Kai", 40)` and call `w.spend(15)`.
# 4. Print the remaining cash.
### Your code starts here.
### Your code ends here.
25