Defining methods
Classes which contain only data attributes are not very different from dictionaries. Below you will find two ways to model a bank account, first with a class definition, and then using a dictionary.
# Example 1: bank account with class definition
class BankAccount:
def __init__(self, account_number: str, owner: str, balance: float, annual_interest: float):
self.account_number = account_number
self.owner = owner
self.balance = balance
self.annual_interest = annual_interest
peters_account = BankAccount("12345-678", "Peter Python", 1500.0, 0.015)
# Example 2: bank account with dictionary
peters_account = {"account_number": "12345-678", "owner": "Peter Python", "balance": 1500.0, "annual_interest": 0.0}
With a dictionary the implementation is much shorter and more straightforward. With a class, however, the structure is more "tightly bound", so that we can expect all BankAccount
objects to be structurally alike. A class is also named. The BankAccount
class is referenced when creating a new bank account, and the type of the object is BankAccount
, not dict
.
Another significant advantage of classes is that in addition to data, they can contain functionality. One of the guiding principles of object oriented programming is that an object is uswd to access both the data attached to an object and the functionality to process that data.
Methods in classes
A method is a subprogram or function that is bound to a specific class. Usually a method only affects a single object. A method is defined within the class definition, and it can access the data attributes of the class just like any other variable.
Let's continue with the BankAccount
class introduced above. Below we have a new method which adds interest to the account:
class BankAccount:
def __init__(self, account_number: str, owner: str, balance: float, annual_interest: float):
self.account_number = account_number
self.owner = owner
self.balance = balance
self.annual_interest = annual_interest
# This method adds the annual interest to the balance of the account
def add_interest(self):
self.balance += self.balance * self.annual_interest
peters_account = BankAccount("12345-678", "Peter Python", 1500.0, 0.015)
peters_account.add_interest()
print(peters_account.balance)
1522.5
The add_interest
method multiplies the balance of the account by the annual interest percentage, and then adds the result to the current balance. The method acts only on the object which it is called on.
Let's see how this works when we have created multiple instances of the class:
# The class BankAccount is defined in the previous example
peters_account = BankAccount("12345-678", "Peter Python", 1500.0, 0.015)
paulas_account = BankAccount("99999-999", "Paula Pythonen", 1500.0, 0.05)
pippas_account = BankAccount("1111-222", "Pippa Programmer", 1500.0, 0.001)
# Add interest on Peter's and Paula's accounts, but not on Pippa's
peters_account.add_interest()
paulas_account.add_interest()
# Print all account balances
print(peters_account.balance)
print(paulas_account.balance)
print(pippas_account.balance)
1522.5 1575.0 1500.0
As you can see above, the annual interest is added only to those accounts which the method is called on. As the annual interest rates are different for Peter's and Paula's accounts, the results are different for these two accounts. The balance on Pippa's account does not change, because the add_interest
method is not called on the object pippas_account
.
Encapsulation
In object oriented programming the word client comes up from time to time. This is used to refer to a section of code which creates an object and uses the service provided by its methods. When the data contained in an object is used only through the methods it provides, the internal integrity of the object is guaranteed. In practice this means that, for example, a BankAccount
class offers methods to handle the balance
attribute, so the balance is never accessed directly by the client. These methods can then verify that the balance is not allowed to go below zero, for instance.
An example of how this would work:
class BankAccount:
def __init__(self, account_number: str, owner: str, balance: float, annual_interest: float):
self.account_number = account_number
self.owner = owner
self.balance = balance
self.annual_interest = annual_interest
# This method adds the annual interest to the balance of the account
def add_interest(self):
self.balance += self.balance * self.annual_interest
# This method "withdraws" money from the account
# If the withdrawal is successful the method returns True, and False otherwise
def withdraw(self, amount: float):
if amount <= self.balance:
self.balance -= amount
return True
return False
peters_account = BankAccount("12345-678", "Peter Python", 1500.0, 0.015)
if peters_account.withdraw(1000):
print("The withdrawal was successful, the balance is now", peters_account.balance)
else:
print("The withdrawal was unsuccessful, the balance is insufficient")
# Yritetään uudestaan
if peters_account.withdraw(1000):
print("The withdrawal was successful, the balance is now", peters_account.balance)
else:
print("The withdrawal was unsuccessful, the balance is insufficient")
The withdrawal was successful, the balance is now 500.0 The withdrawal was unsuccessful, the balance is insufficient
Maintaining the internal integrity of the object and offering suitable methods to ensure this is called encapsulation. The idea is that the inner workings of the object are hidden from the client, but the object offers methods which can be used to access the data stored in the object.
Adding a method does not automatically hide the attribute. Even though the BankAccount
class definition contains the withdraw
method for withdrawing money, the client code can still access and change the balance
attribute directly:
peters_account = BankAccount("12345-678", "Peter Python", 1500.0, 0.015)
# Attempt to withdraw 2000
if peters_account.withdraw(2000):
print("The withdrawal was successful, the balance is now", peters_account.balance)
else:
print("The withdrawal was unsuccessful, the balance is insufficient")
# "Force" the withdrawal of 2000
peters_account.balance -= 2000
print("The balance is now:", peters_account.balance)
The withdrawal was unsuccessful, the balance is insufficient The balance is now: -500.0
It is possible to hide the data attributes from the client code, which can help in solving this problem. We will return to this topic in the next part.
To finish off this section, lets have a look at a class which models the personal best of a player. The class definition contains separate validator methods which ascertain that the arguments passed are valid. The methods are called already within the constructor. This ensures the object created is internally sound.
from datetime import date
class PersonalBest:
def __init__(self, player: str, day: int, month: int, year: int, points: int):
# Default values
self.player = ""
self.date_of_pb = date(1900, 1, 1)
self.points = 0
if self.name_ok(player):
self.player = player
if self.date_ok(day, month, year):
self.date_of_pb = date(year, month, day)
if self.points_ok(points):
self.points = points
# Helper methods to check the arguments are valid
def name_ok(self, name: str):
return len(name) >= 2 # Name should be at least two characters long
def date_ok(self, day, month, year):
try:
date(year, month, day)
return True
except:
# an exception is raised if the arguments are not
return False
def points_ok(self, points):
return points >= 0
if __name__ == "__main__":
result1 = PersonalBest("Peter", 1, 11, 2020, 235)
print(result1.points)
print(result1.player)
print(result1.date_of_pb)
# The date was not valid
result2 = PersonalBest("Paula", 4, 13, 2019, 4555)
print(result2.points)
print(result2.player)
print(result2.date_of_pb) # Tulostaa oletusarvon 1900-01-01
235 Peter 2020-11-01 4555 Paula 1900-01-01
In the example above also the helper methods were called via the self
parameter name when they were used in the constructor. It is possible to also include static method definitions in class definitions. These are methods which can be called without ever creating an instance of the class. We will return to this subject in the next part.
The parameter name self
is only used when referring to the features of the object as an instance of the class. These include both the data attributes and the methods attached to an object. To make the terminology more confusing, the data attributes and methods are together sometimes referred to simply as the attributes of the object, which is why in this material we have often specified data attributes when we mean the variables defined within the class. This is where the terminology of some Python programmers slightly differs from the terminology used in object oriented programming more widely, where attributes usually refers to just the data attributes of an object.
It is also possible to create local variables within method definitions without referring to self
. You should do so if there is no need to access the variables outside the method. Local variables within methods have no special keywords; they are used just like any normal variables you have come across thus far.
So, for example this would work:
class BonusCard:
def __init__(self, name: str, balance: float):
self.name = name
self.balance = balance
def add_bonus(self):
# The variable bonus below is a local variable.
# It is not a data attribute of the object.
# It can not be accessed directly through the object.
bonus = self.balance * 0.25
self.balance += bonus
def add_superbonus(self):
# The superbonus variable is also a local variable.
# Usually helper variables are local variables because
# there is no need to access them from the other
# methods in the class or directly through an object.
superbonus = self.balance * 0.5
self.balance += superbonus
def __str__(self):
return f"BonusCard(name={self.name}, balance={self.balance})"
You can check your current points from the blue blob in the bottom-right corner of the page.