Intro to Classes

A class is like a blueprint or template for an object. Once the class is defined you can create instances which are single objects in memory that have various data, attributes, and methods which were defined for the class.

In our first example we will create a Person class. Think of the Person class as being a template which gives structure around the details you could store or compute about a person in general. For example, any one individual person would have an age, height, birth date, phone number, email, to name a few things

Defining Classes

class Person:
    def __init__(self, first_name, last_name, height, birth_date):
        self.first_name = first_name
        self.last_name = last_name
        self.height = height
        self.birth_date = birth_date

To define a class you start with the class keyword followed by the name of the class and a colon. Within the class you can define variables and functions which are attributes of the class. The __init__ method is a special method known as the constructor. It gets executed once each time you create a new instance of a class.

The keyword self is a special keyword used to represent the instance of the class. In general, it gets passed as the first argument to all the methods defined in the class. It can be used throughout the class code to refer to any of the classes attributes. Let’s create an instance of the Person class. When using the class, the self keyword argument always gets passed implicitly and you do not need to use it explicitly.

p1 = Person(first_name='Chris', last_name='Levy', height=6, birth_date=1985)
p1
<__main__.Person at 0x106518d30>
type(p1)
__main__.Person

Now we have an instance of the Person class, p1, created in memory. We can access the attributes of the instance, p1, with the . operator.

p1.birth_date
1985
p1.first_name
'Chris'
p1.last_name
'Levy'
p1.birth_date
1985

Now let’s create a second person.

p2 = Person(first_name='Joanna', last_name='Levy', height=5.5, birth_date=1983)
p2.first_name
'Joanna'

The data for such a simple class could of been stored in a dictionary or some other Python object. However, classes provide much more flexibility. For example, we can define functions within the class. These functions of the class are called methods of the class and are available as attributes of the instances of the class. For example, suppose we wanted a function to get the age of the person. We can modify the Parent class like so:

import datetime
class Person:
    def __init__(self, first_name, last_name, height, birth_date):
        self.first_name = first_name
        self.last_name = last_name
        self.height = height
        self.birth_date = birth_date
    
    def age(self):
        todays_date = datetime.date.today()
        return todays_date.year - self.birth_date
        

Notice that the function age has the first argument self. If you need to refer to any of the instance attributes within the function then you can do so with self keyword. It is always the first argument. The keyword self is not passed as an explicit argument when you go and use the age method of the instance. The functions defined in the class are referred to as methods of the instance that is created from the class.

p1 = Person(first_name='Chris', last_name='Levy', height=6, birth_date=1985)
p1.age()
36
p2 = Person(first_name='Joanna', last_name='Levy', height=5.7, birth_date=1983)
p2.age()
38
print(f'{p1.first_name} {p1.last_name} is {p1.height} feet tall and is {p1.age()} years old.')
Chris Levy is 6 feet tall and is 36 years old.
print(f'{p2.first_name} {p2.last_name} is {p2.height} feet tall and is {p2.age()} years old.')
Joanna Levy is 5.7 feet tall and is 38 years old.

If for example we found the above print statement useful, we could just create it as a method within the class.

import datetime

class Person:
    def __init__(self, first_name, last_name, height, birth_date):
        self.first_name = first_name
        self.last_name = last_name
        self.height = height
        self.birth_date = birth_date
    
    def age(self):
        todays_date = datetime.date.today()
        return todays_date.year - self.birth_date
    
    def print_details(self):
        age = self.age()
        print(f'{self.first_name} {self.last_name} is {self.height} feet tall '
              f'and is {self.age()} years old.')
p1 = Person(first_name='Chris', last_name='Levy', height=6, birth_date=1985)
p2 = Person(first_name='Joanna', last_name='Levy', height=5.7, birth_date=1983)
p1.print_details()
p2.print_details()
Chris Levy is 6 feet tall and is 36 years old.
Joanna Levy is 5.7 feet tall and is 38 years old.

Changing Class Instance Attributes

The instances of the class you create are mutable in that you can modify basically anything about the object after it is created.

p1.first_name
p1.last_name
'Levy'

Here we modify the name, height, and the birth date of the p1 object. Then whenever self is used within the function definitions in the class it uses the updated object values for the instance.

p1.first_name = 'Isaac'
p1.last_name = 'Levy'
p1.birth_date = 2011
p1.height = 4.5
p1.age()
10
p1.print_details()
Isaac Levy is 4.5 feet tall and is 10 years old.

Bank Account Example

Let’s look at another example of creating a class. This class will be called BankAccount and will be responsible for storing and keeping up to date the details of a persons bank account. It will keep track of the amount of money in the account, a history of previous transactions, and will be able to perform new transactions such as withdrawals and deposits.

The first step is to choose the name of the class. When naming classes in Python it’s common to use the CamelCase format which just means to capitalize the first letter at the beginning of each new word. We will use the name BankAccount for our class.

The very minimum code needed to create a class is the following.

class BankAccount:
    pass
account = BankAccount()

This instance of the class, account has no data or methods associated with it because we have not defined anything within the class BankAccount.

When we create a bank account it needs to have an initial state which gets computed once the instance object of the class is created. This is what the __init__ method is for. We will pass the name of the person who will own the account as an argument to the constructor method (i.e. the __init__ method). In the constructor we will also set the balance of the account to $0 and the transaction history to an empty list. We store the balance and the transaction_history in __init__ with self. This way those values become attributes of the class and we can keep track of them.

class BankAccount:
    def __init__(self, name):
        self.name = name
        self.balance = 0
        self.transaction_history = []
account = BankAccount('Chris Levy')
print(account.name, account.balance, account.transaction_history)
Chris Levy 0 []

After someone creates an account, they probably want to be able to put some money in. These are called deposits. We will create a deposit function which takes the amount of money to deposit and updates self.balance. The function does not need to return anything because it’s simply updating the internal self.balance.

class BankAccount:
    def __init__(self, name):
        self.name = name
        self.balance = 0
        self.transaction_history = []
    
    def deposit(self, amount):
        self.balance = self.balance + amount
account = BankAccount('Chris Levy')
print(account.name, account.balance, account.transaction_history)
Chris Levy 0 []
account.deposit(100)
print(account.name, account.balance, account.transaction_history)
Chris Levy 100 []
account.deposit(30)
print(account.name, account.balance, account.transaction_history)
Chris Levy 130 []
account.deposit(22)
print(account.name, account.balance, account.transaction_history)
Chris Levy 152 []

Above are some examples of deposit transactions which update the balance associated with the instance of the class. If we create another bank account instance it will have it’s own data.

account2 = BankAccount('Isaac')
account2.deposit(10)
print(account.name, account.balance, account.transaction_history)
Chris Levy 152 []
print(account2.name, account2.balance, account2.transaction_history)
Isaac 10 []

We want to keep track of every historical transaction for both deposits and withdrawals. We need to modify the deposit method so that it adds the details of the deposit transaction to the history. We will add an optional details argument. As well, we will store each transaction as a dictionary. This way we can add more fields later if we need to. The transactions need to be appended to the list transaction_history. It also makes sense to add the date and time of the transaction so we will use the datetime library to do that.

from datetime import datetime

class BankAccount:
    def __init__(self, name):
        self.name = name
        self.balance = 0
        self.transaction_history = []
    
    def deposit(self, amount, details=None):
        self.balance = self.balance + amount
        now = datetime.now()
        transaction = {
            'amount': amount,
            'type': 'deposit',
            'details': details,
            'date': now.strftime("%Y/%m/%d/ %H:%M:%S") # format date as string
        }
        self.transaction_history.append(transaction)
account = BankAccount('Chris')
print(account.name, account.balance, account.transaction_history)
Chris 0 []
account.deposit(10000, 'Setting up my account.')
account.balance
10000
account.transaction_history
[{'amount': 10000,
  'type': 'deposit',
  'details': 'Setting up my account.',
  'date': '2021/01/23/ 07:54:08'}]
account.deposit(100, 'money I got as a gift.')
account.balance
10100
account.transaction_history
[{'amount': 10000,
  'type': 'deposit',
  'details': 'Setting up my account.',
  'date': '2021/01/23/ 07:54:08'},
 {'amount': 100,
  'type': 'deposit',
  'details': 'money I got as a gift.',
  'date': '2021/01/23/ 07:54:08'}]
account.deposit(3000)
account.balance
13100
account.transaction_history
[{'amount': 10000,
  'type': 'deposit',
  'details': 'Setting up my account.',
  'date': '2021/01/23/ 07:54:08'},
 {'amount': 100,
  'type': 'deposit',
  'details': 'money I got as a gift.',
  'date': '2021/01/23/ 07:54:08'},
 {'amount': 3000,
  'type': 'deposit',
  'details': None,
  'date': '2021/01/23/ 07:54:08'}]

Okay, so now we need to create a function for handling withdrawals (taking money out of the account). We can create a withdrawal method which behaves just like the deposit method. The only difference is that it should decrease the balance and it is typical to store such values as negative.

from datetime import datetime

class BankAccount:
    def __init__(self, name):
        self.name = name
        self.balance = 0
        self.transaction_history = []
    
    def deposit(self, amount, details=None):
        self.balance = self.balance + amount
        now = datetime.now()
        transaction = {
            'amount': amount,
            'type': 'deposit',
            'details': details,
            'date': now.strftime("%Y/%m/%d/ %H:%M:%S") # format date as string
        }
        self.transaction_history.append(transaction)
        
    def withdrawal(self, amount, details=None):
        self.balance = self.balance - amount
        now = datetime.now()
        transaction = {
            'amount': -1 * amount,
            'type': 'withdrawal',
            'details': details,
            'date': now.strftime("%Y/%m/%d/ %H:%M:%S") # format date as string
        }
        self.transaction_history.append(transaction)
account = BankAccount('Chris')
account.deposit(100, 'setup')
account.deposit(100, 'paid')
account.withdrawal(50, 'food')
account.balance
150
account.transaction_history
[{'amount': 100,
  'type': 'deposit',
  'details': 'setup',
  'date': '2021/01/23/ 07:54:08'},
 {'amount': 100,
  'type': 'deposit',
  'details': 'paid',
  'date': '2021/01/23/ 07:54:08'},
 {'amount': -50,
  'type': 'withdrawal',
  'details': 'food',
  'date': '2021/01/23/ 07:54:08'}]

The dates are the same because the code got executed together close together in time. Notice that the amount for withdrawals is negative.

Now there is one more thing we need to handle for withdrawals. What if someone tries to withdraw money or make a purchase but there is not enough money in the account? Then we need to stop the transaction from completing and print a message that says insufficient funds. Let’s update the withdrawal method to handle this logic.

from datetime import datetime

class BankAccount:
    def __init__(self, name):
        self.name = name
        self.balance = 0
        self.transaction_history = []
    
    def deposit(self, amount, details=None):
        self.balance = self.balance + amount
        now = datetime.now()
        transaction = {
            'amount': amount,
            'type': 'deposit',
            'details': details,
            'date': now.strftime("%Y/%m/%d/ %H:%M:%S") # format date as string
        }
        self.transaction_history.append(transaction)
        
    def withdrawal(self, amount, details=None):
        new_balance = self.balance - amount
        if new_balance < 0:
            print(f'Sorry! You only have ${self.balance} in your account.')
            print(f'You can not complete this transaction of ${amount} at this time.')
            
            # will return None and function will exit.
            # No logic after the return will be executed
            return  
        
        self.balance = new_balance
        now = datetime.now()
        transaction = {
            'amount': -1 * amount,
            'type': 'withdrawal',
            'details': details,
            'date': now.strftime("%Y/%m/%d/ %H:%M:%S") # format date as string
        }
        self.transaction_history.append(transaction)
account = BankAccount('Chris')
account.deposit(100)
account.deposit(200)
account.deposit(300)
account.balance
600
account.withdrawal(400)
account.balance
200
account.withdrawal(10)
account.balance
190
account.withdrawal(201)
Sorry! You only have $190 in your account.
You can not complete this transaction of $201 at this time.
account.transaction_history
[{'amount': 100,
  'type': 'deposit',
  'details': None,
  'date': '2021/01/23/ 07:54:08'},
 {'amount': 200,
  'type': 'deposit',
  'details': None,
  'date': '2021/01/23/ 07:54:08'},
 {'amount': 300,
  'type': 'deposit',
  'details': None,
  'date': '2021/01/23/ 07:54:08'},
 {'amount': -400,
  'type': 'withdrawal',
  'details': None,
  'date': '2021/01/23/ 07:54:08'},
 {'amount': -10,
  'type': 'withdrawal',
  'details': None,
  'date': '2021/01/23/ 07:54:08'}]

The class BankAccount we created is quite simple. But I hope you start to see why classes are so powerful. It gives you the ability to create new objects which can have their own attributes and data together in a single place. Most application code in the “real world” will be written using the Object-oriented programming (OOP) paradigm which is based on using classes. We will not be teaching everything there is to know about OOP in this course. However, we will go cover some of the basics about working with classes in Python so you can get started.