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

Had WillHad Will
14 min read

< Previous Post

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.

cookie metaphor for classes and objects

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.

💡
object = data + behavior

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 reference to the current object instance. It lets each object manage its own separate attributes.

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.

💡
init is Python's special method that runs each time you create an object. It's the best place to set up your object's attributes.

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 than deposit. We first need to check for the user's withdrawal rights with authorized. 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, call Budget 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 the incomes attribute. Expenses behave the same way.

  • Since we store Expense and Income objects in lists, adding a new object is a simple append operation.

  • Given a BudgetEntry object, we remove it from either the incomes or expenses list. We use the in 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 the amount attribute of Income and Expenses with the dot notation: income.amount and expense.amount.

  • In apply_entries_to_account, I first stored the monthly_net in a local variable amount. I did this for two reasons. First, we only calculate the monthly net once. Second, amount is shorter and easier to read than self.monthly_net(). Our monthly net is positive or negative, so we either deposit or withdraw 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

  1. 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.

  2. 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.

  3. In <apply_entries_to_account>, check that the budget owner is one of the account's owners. If not, refuse the operation.

  4. Add a method <purge_optional_expenses> to the Budget. class to remove all expenses with the optional flag.

  5. 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.

  6. 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.

  7. Change the Budget class to support multi-owner budgets, like Account does. Adapt the methods.

  8. 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?

  9. 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.

< Previous Post

0
Subscribe to my newsletter

Read articles from Had Will directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Had Will
Had Will