Part 7: Intro to OOP with a personal budget planner project


I used terms like methods and classes without explaining them in previous posts. This was unavoidable because they are everywhere in Python. Today, we shed some light on these concepts.
This post will give you a working knowledge of object-oriented programming (OOP).
OOP's main idea concerns how we organize data. At the heart of OOP is the concept of a class, a blueprint for creating objects. It is like a recipe. Everything about a data type (its behavior and structure) is within its definition. When you create an instance of a class, you get an object.
A word about code and data
To oversimplify, a program has two ingredients: code and data. Data can be, for example, a database, a string stored in memory, etc. Then, there's what you do to your data; that's the code part.
my_data = []
while True:
my_data.append(input("add data: "))
print(my_data)
In the example above, the data is the list stored in my_data
.
Now, that's a clear and efficient code snippet. But what if we had several copies of my_data? What if we wanted an identical internal structure across all its instances? We're going to explore those ideas today.
As always, we'll learn by doing. Today's project is a simple personal budget planner that handles mock bank accounts and budgets.
Budgeting app architecture
Our users can create a monthly budget with expenses and incomes and sum them up at the end of the month. The program should be able to accommodate several users.
We'll link the budgets to users' accounts so that we can check the amount of available money. We'll adjust it at the end of the month.
Creating accounts
Our simple accounts will hold a balance and the names of their owners. There are two main things you can do to your account: withdraw and deposit money.
The old, non-OOP way
Here's how we can create simple accounts with the Python knowledge we've built up so far.
def make_account(owner_list, balance):
return dict(owner=owner_list, balance=balance)
account_1 = make_account(['bob'], 10000)
account_2 = make_account(['alice'], 100000)
def deposit(account, amount):
account['balance'] += amount
def can_withdraw(account, amount):
return account['balance'] >= amount
def withdraw(account, amount):
if can_withdraw(account, amount):
account['balance'] -= amount
This is fine.
However, as the program grows more complex, it will become harder and harder to read and maintain.
We want a way to define data classes with associated functions. In a way, the data owns the code.
Our first class
We'll start with a simple, bare-bones class. A class, in programming terms, is a way to define data. You define a class with the class
keyword, a bit like how you would define a function. A class can hold internal data, which we call attributes. Attributes are like variables that belong to a class.
class Account:
"""
A bank account class.
"""
balance = 10000
owners = ["bob"]
Here, class Account:
starts the account definition. Notice the uppercase first letter in Account. That's a common practice when defining classes.
balance
and owners
are the class' attributes.
bobs_account = Account()
bobs_account
is an instance of the Account
class. bobs_account
is an object. We have created it by calling the class and storing the result in a variable. Calling a class like a function creates a new object. This is instantiation.
An object is a bundle: it stores data and knows how to do things with it.
Data: like balance or owners
Behavior: depositing and withdrawing money, etc.
As you can see below, the bobs_account
object has the same attributes as its class. You access them using the dot notation.
print(bobs_account.balance)
1000
print(bobs_account.owners)
['bob']
We want to change the values of our object's attributes. We'll do that in the next section.
Our first method
Methods are akin to functions but attached to a class.
You define them inside your class, like a function. def method_name(self, args):
Don't forget the self
argument. It stands for the object itself.
You call them using the dot notation: object.method(arguments)
. We’ve seen several built-in Python methods in the previous posts (e.g., split
, lower
, upper
, etc.)
The first (buggy) version
We'll start with something easy: change an object's balance
and owners
to fixed values.
This is for demo purposes and is not very useful in itself. We'll see more exciting stuff later on.
class Account:
"""
A bank account class.
"""
balance = 10000
owners = ["bob"]
def update(self):
balance = 20000
owners = ["bob", "alice"]
Let's check if this works.
bobs_account = Account()
print("Bob's account")
print(bobs_account.balance)
print(bobs_account.owners)
print(bobs_account.spending_limit)
bobs_account.update()
print("Bob's and Alice's account")
print(bobs_account.balance)
print(bobs_account.owners)
print(bobs_account.spending_limit)
% python3 Project7.py
Bob's account
10000
['bob']
2000
Bob's and Alice's account
10000
['bob']
2000
Nothing's changed. Why?
The update
method created two variables, owners
and balance
, with the assignments. But that's not what we wanted! We wanted to reference the class attributes.
Correcting the bug
Here's what our method definition should look like.
def update(self):
self.balance = 20000
self.owners = ["bob", "alice"]
It works!
Notice we used self.balance
and self.owners
instead of balance
and owners
.
When calling the update
method on an object, self
tells Python where to look for balance
and owners
. It looks for them in the object's attributes instead of creating new variables in the method.
self
is a common source of confusion for beginners. self
is how each object keeps track of its own data. Even though we defined the structure in the class, each object gets its own balance
, owners
, etc.
It is important to understand that self is about the object, not the class template.
The __init__ method
When we create a new account, it receives some hard-coded attribute values. We want to provide those values at object creation time as we make more accounts.
Enter the __init__
method. It runs every time we create a new Account
object and populates it with values. Here's what it looks like.
class Account:
"""
A bank account class.
"""
def __init__(self, balance, owners):
self.balance = balance
self.owners = owners
def update(self, new_balance, new_owners):
self.balance = new_balance
self.owners = new_owners
Now, we create a balance and owner's list with every new object. They are not hard-coded anymore. Since they are specific to each account object, we define them as attributes of self
.
Now, we create new account objects like this:
bobs_account = Account(10000, ['bob'])
alices_account = Account(1000000, ['alice'])
Play around with the code for a while to understand how it works.
Actual Account functionality
We'll create the whole Account class now, with actual functionality. Here's how I plan to do it.
Class Architecture:
-------------------
Account
- Attributes:
* balance
* owners (list of strings)
- Methods:
* deposit(amount): new balance
* withdraw(name, amount): False if failure; new balance if success
* can_spend(amount): bool
* authorized(name): bool
* show()
When programming in an OOP style, I like to plan ahead like this. This text snippet serves as documentation in a comment at the top of my program's file.
Here's the whole Account
class.
class Account:
"""
A bank account class.
"""
def __init__(self, balance, owners):
self.balance = balance
self.owners = owners
def deposit(self, amount):
self.balance = self.balance + amount
return self.balance
def authorized(self, name):
return name in self.owners
def can_spend(self, amount):
return amount >= 0 and amount <= self.balance
def withdraw(self, name, amount):
if not self.authorized(name):
print("Not authorized")
return False
if self.can_spend(amount):
self.balance = self.balance - amount
else:
print("Cannot withdraw")
return False
return self.balance
def show(self):
print("Account")
print("- Owners:")
for owner in self.owners:
print(f" * {owner}")
print(f"- Balance: ${self.balance}")
Can you figure out what each part does?
__init__
stays the same.deposit
is straightforward. We adjust the account's balance with the amount of money to deposit.authorized
tests if a name string is part of the owners' list. This is make-believe user authentication.can_spend
checks if you can withdraw a certain amount from the account.withdraw
is a bit more complicated thandeposit
. We first need to check for the user's withdrawal rights withauthorized
. Then, we check whether we have enough money. Finally, we apply the withdrawal.show
is a simple display method for our account object.
Income and Expenses
Before we create a Budget class, let's see if we need to do some preliminary work. A budget has an owner, incomes, and expenses. We'll have a list of Income
and Expense
objects.
Incomes
An income is an amount of money with a name. For example, Bob could earn $2000 from his 'day job'. We also could specify if an income is recurring. Here's my take on it.
class Income:
def __init__(self, category, amount, monthly=False):
self.category = category
self.amount = amount
self.monthly = monthly
As you can see with monthly=False
, methods can accept optional arguments.
Expenses
Expenses can follow the same template. We'll add the possibility of optional expenses (i.e., rent is not optional, but weekly massages are).
class Expense:
def __init__(self, category, amount, monthly=False, optional=False):
self.category = category
self.amount = amount
self.monthly = monthly
self.optional = optional
Creating a base class
As you can see above, the Income
and Expense
classes are very similar. We'll refactor the code and introduce an important OOP concept: inheritance.
First, we create a new BudgetEntry
class containing all the code shared by Expense
and Income
. This is the base or parent class of Expense
and Income
.
When two classes share most of their structure, it is a sign they could inherit from a common base class.
class BudgetEntry:
"""
A base class for budget entries
- Attributes:
* category (string)
* amount (float)
* monthly (bool)
"""
def __init__(self, category, amount, monthly=False):
self.category = category
self.amount = amount
self.monthly = monthly
Now, Income
will become a very simple class.
class Income(BudgetEntry):
"""
Represents an income entry in the budget.
This class inherits from BudgetEntry as-is.
"""
That's it.
It no longer has a body because all the work happens in the parent class. We specify a parent class in parentheses: class Income(BudgetEntry):
.
The Expense
class has one more attribute than BudgetEntry
, in the form of optional
.
class Expense(BudgetEntry):
"""
Represents an expense entry in the budget.
Attributes:
* optional (bool): Indicates if the expense is optional.
"""
def __init__(self, category, amount, monthly=False, optional=False):
super().__init__(category, amount, monthly)
self.optional = optional
The __init__
method first calls the parent class' __init__
method. super()
gives access to an object's parent class. So, when we create an Expense
object, we run Expense
’s __init__
method, which runs the parent __init__
method.
Calling the __init__
method on super
is a very common idiom.
Since there was no optional
in BudgetEntry
, we completed __init__
by setting the optional
attribute.
The Budget class
Now we'll do the budget class. I will provide you with the class specifications so you can write it on your own first.
Budget
- Attributes:
* owner (single string)
* incomes (Income objects list)
* expenses (Expense objects list)
- Methods:
* add_income(income): returns the updated incomes list
* add_expense(expense): returns the updated expenses list
* remove_entry(entry): removes an entry from expenses or incomes;
no specific return value
* monthly_net(): returns incomes minus expenses
* apply_monthly_entries_to_account(account):
add or subtract the net from the account's balance.
Return True for success, False for failure.
* monthly_summary(): create a nice-ish display for the monthly budget summary.
Did you have trouble writing it? Below is my take on it. Check it out.
class Budget:
"""
Budget
"""
def __init__(self, owner, incomes, expenses):
self.owner = owner
self.incomes = incomes
self.expenses = expenses
def add_income(self, income):
self.incomes.append(income)
return self.incomes
def add_expense(self, expense):
self.expenses.append(expense)
return self.expenses
def remove_entry(self, entry):
if entry in self.incomes:
self.incomes.remove(entry)
elif entry in self.expenses:
self.expenses.remove(entry)
def monthly_net(self):
net = 0
for income in self.incomes:
net = net + income.amount
for expense in self.expenses:
net = net - expense.amount
return net
def apply_entries_to_account(self, account):
amount = self.monthly_net()
if amount < 0:
withdrawal_amount = abs(amount)
if account.can_spend(withdrawal_amount):
account.withdraw(self.owner, withdrawal_amount)
else:
print("Review your budget! Cannot withdraw so much...")
return False
else:
account.deposit(amount)
return True
def monthly_summary(self):
print("MONTHLY BUDGET")
print("- Incomes:")
for income in self.incomes:
print(f" * {income.category}: ${income.amount}")
print("- Expenses:")
for expense in self.expenses:
print(f" * {expense.category}: ${expense.amount}")
print(f"NET : ${self.monthly_net()}")
Here's a short breakdown of the Budget
class.
All attributes are in the
__init__
method. When creating a new budget, callBudget
with three arguments.new_budget = Budget("tom", income_list, expense_list)
The budget's owner is a string, so we can't share budgets between users. However, if we wanted to implement multiple budget owners, we could use a list of owner strings, like in the
Account
class.Income
objects go into a list. This is theincomes
attribute.Expenses
behave the same way.Since we store
Expense
andIncome
objects in lists, adding a new object is a simpleappend
operation.Given a
BudgetEntry
object, we remove it from either the incomes or expenses list. We use thein
keyword to look for the object in one list and then the other.The
monthly_net
is the sum of all incomes minus the sum of all expenses. We loop through both incomes and expenses list to generate the net. As you see, we access theamount
attribute ofIncome
andExpenses
with the dot notation:income.amount
andexpense.amount
.In
apply_entries_to_account
, I first stored themonthly_net
in a local variableamount
. I did this for two reasons. First, we only calculate the monthly net once. Second,amount
is shorter and easier to read thanself.monthly_net()
. Our monthly net is positive or negative, so we eitherdeposit
orwithdraw
it. Withdrawing needs more fiddling than depositing, as in real life.
The last point illustrates how objects communicate with each other. Budget
objects send messages to Account
objects through their methods. Here, this happens inside apply_entries_to_account
. Writing account.withdraw(self.owner, withdrawal_amount)
is like sending a message to account
. This message says, "Use your internal method to withdraw; here are some arguments."
This is one of the core ideas of OOP: objects talk to each other using methods. The budget didn't reach in and change the account's balance. It asked the account to do so.
Running the program
We have now written the bulk of the program, but a bunch of classes can't do much on their own. Below is some sample code I wrote to create accounts, budgets, incomes, and expenses.
Let's simulate Bob's month and see how his budget affects his account.
bobs_account = Account(10000, ['bob'])
bobs_budget = Budget('bob', [], [])
rent = Expense('rent',
1000,
monthly=True)
food = Expense('food',
980,
monthly=True)
new_computer = Expense('computer',
1299.99,
monthly=False,
optional=True)
salary = Income('salary',
4200,
monthly=True)
bday_gift = Income('bday',
100,
monthly=False)
bobs_budget.add_income(salary)
bobs_budget.add_income(bday_gift)
bobs_budget.add_expense(rent)
bobs_budget.add_expense(food)
bobs_budget.add_expense(new_computer)
bobs_budget.monthly_summary()
bobs_account.show()
bobs_budget.apply_entries_to_account(bobs_account)
bobs_account.show()
print("New month...")
bobs_budget.remove_entry(new_computer)
bobs_budget.remove_entry(bday_gift)
bobs_budget.monthly_summary()
bobs_account.show()
bobs_budget.apply_entries_to_account(bobs_account)
bobs_account.show()
Try writing your own budget for this month to get a feel of the code!
Recap
Classes and objects
A class is a blueprint for creating objects. It's like a recipe: you define what kind of data and behavior something should have. When you create an instance of a class, you get an object. That's called instantiation.
An object is a bundle. It stores data and also knows how to do things with it.
You create a class with the class
keyword.
Attributes
Attributes are variables belonging to an object.
You either
hardcode attributes' values when you define the class
define them in the
__init__
method.
For example:
class MyClass:
# hard-coded, same for all objects of this class
attribute_1 = "some value"
def __init__(self, attr):
# initialize attributes at object creation
self.attribute_2 = attr
In this example, each object keeps its own copy of self.attribute_2
.
Methods
Methods are functions that belong to a class.
You define them like regular functions, but they live inside the class. They always take self
as the first argument. That’s how Python knows which object the method is acting on.
You call them using the dot notation: object.method()
.
We’ve used this with strings in previous posts: "hello".upper()
— that’s a method call, too.
Core ideas of OOP
Encapsulation: group data and behavior together in a class.
Self: connect each method and attribute to the object it's part of.
Objects talk to each other: They use method calls to "ask" each other to do things instead of reaching into each other's internals.
Inheritance: If classes share structure/behavior, they can inherit from a common base class.
Exercises
Add a spending limit (e.g., 50% of the balance) to the Account class. Refuse to withdraw amounts that exceed this limit. Recalculate the limit after each deposit and withdrawal.
Add a transaction log to the Account class. We append a string to the log each time we deposit or withdraw some money. For example, "Withdrew $147". Create a method to print the log.
In <apply_entries_to_account>, check that the budget owner is one of the account's owners. If not, refuse the operation.
Add a method <purge_optional_expenses> to the Budget. class to remove all expenses with the optional flag.
Create a History class to track net worth over time. It stores a list of account balances. Add methods to show how the balance changed over time and the average monthly change.
Add a method to Budget that returns a report as a formatted multi-line string instead of printing it. Save this report to a file.
Change the Budget class to support multi-owner budgets, like Account does. Adapt the methods.
In the beginning, we wrote the account functionality with dictionaries and functions. Rewrite the whole program in this style, ditching object-oriented programming. Which style is more straightforward? Which one do you prefer?
Go back to one of our previous projects. Rework it to use object-oriented programming.
Congrats!
You now have a working knowledge of simple object-oriented programming in Python.
See you in the next post.
Subscribe to my newsletter
Read articles from Had Will directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
