Object Oriented Programming is an approach in programming in which properties and behaviors are bundled into individual objects. in real world, an object has some properties and functions: a car has color, model, engine type, etc. and can move, speed, brake, etc. or an email has recipient list, subject, body, etc., and behaviors like adding attachments and sending; or a person who has name, height, weight, address and can walk, talk, laugh, etc.

We can have the same approach in desigining programs based on objects that represent both properties and functions that can be applied to those properties.

Classes in Python

Each object is an instance of a class (each person is an instance of human beings class!) Class is a data structure that allows the user to define any needed properties and functions that the object will contain. So the objects are instances of an specific class. Like when Sara with 170 cm height and dark hair is an instance of human() class.

so:

  • A class provides the form or structure
  • an Object is an instance of a class which has:
    • features, that are called attributes
    • functions, that are called methods

Defining a class in python

Let's assume we want to file the subjects of an ongoing study.

We want to define a class called subjet with attributes such as ID, first name, last name, admission_date and a function that will just print out the full name of the subject. suppose we wnat to create two objects: 111, Jane Doe, 2019_01_01 112, John Smith, 2019_01_09


In [1]:
# first we need to creat a class. 
# If you like to create an empty class at first, you use the command pass inside the class
class Subject:  
    pass

In [2]:
# create the objects of that class. 
# we create 2 and call them S1 and S2: 
S1 = Subject()
S1.fname = "Jane"
S1.lname = "Doe"
S1.ID = 111
S1.date_of_admission = "2019_01_01"

S2 = Subject()
S2.fname = "John"
S2.lname = "Smith"
S2.ID = 112
S2.date_of_admission = "2019_01_09"

In [4]:
# you can use these objects in any form. Let's say we want to bring up some info about subject 1
print(S1.fname,S1.lname)


Jane Doe

method

We said in object oriented programming, we can have functions associated with the object. so, we want this printing of the full name to be a method of the object. We can rewrite the class and then run the above cell to fill the two objects again:


In [5]:
class Subject:
    def fullname(self):
        print(self.fname,self.lname)

In [7]:
# since we rewrite the class, we have to fill in the attributes again: 
S1 = Subject()
S1.fname = "Jane"
S1.lname = "Doe"
S1.ID = 111
S1.date_of_admission = "2019_01_01"

S2 = Subject()
S2.fname = "John"
S2.lname = "Smith"
S2.ID = 112
S2.date_of_admission = "2019_01_09"

Now if we look at the objects it has a method + the attributes:


In [8]:
S2.fullname()


John Smith

But the above approach is not efficient, we don't want to repeat filling in the attributes individually: both boring and prone to typos.

constructor

We actually don't need to. We can use a constructor to design the attributes within the class:


In [9]:
class Subject: 
    def __init__(self, fname,lname,ID,date_of_admission):
        self.fname = fname
        self.lname = lname
        self.ID = ID
        self.date_of_admission = date_of_admission
        
    def fullname(self):
        print(self.fname,self.lname)

In this way, to fill in the objects of a class, we can give it the first name, last name, the ID, and the data of admission in order.


In [11]:
# let's fill in the same subjects' info 
S1 = Subject("Jane","Doe",111,"2019_01_01")
S2 = Subject("John","Smith",112,"2019_01_02")

In [12]:
# now the method 'fullname' should give the same info as before:
S1.fullname()


Jane Doe

relationships between objects

We can have relationships between sets of objects:


In [13]:
# define a feature that relates the two objects, here we can literally say relative
S1.relatives = S2

In [14]:
S1.relatives.fullname()


John Smith

Example 2

Now let's work with another example:

Programing for a bank: Let's define a class called Client in which a new instance stores a client's name, balance, and account level.


In [15]:
# create the Client class below
class Client(object):
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance + 100
        
        #define account level
        if self.balance < 5000:
            self.level = "Basic"
        elif self.balance < 15000:
            self.level = "Intermediate"
        else:
            self.level = "Advanced"

Now, lets try creating some new clients named John_Doe, and Jane_Defoe (i.e. two instances of the 'Client' class, or two objects):


In [16]:
C1 = Client("John Doe", 500)
C2 = Client("Jane Defoe", 150000)

We can see the attributes of John_Doe, or Jane_Defoe by calling them:


In [17]:
C1.name


Out[17]:
'John Doe'

In [19]:
C2.level


Out[19]:
'Advanced'

In [21]:
C2.balance


Out[21]:
150100

We can also add, remove or modify attributes as we like:


In [23]:
C1.email = "jdoe23@gmail.com"
C2.email = "johndoe23@gmail.com"

In [24]:
C1.email


Out[24]:
'jdoe23@gmail.com'

In [25]:
del C1.email

In [28]:
# C1.email
C2.email


Out[28]:
'johndoe23@gmail.com'

Class Attributes vs. Normal Attributes

A class attribute is an attribute set at the class-level rather than the instance-level, such that the value of this attribute will be the same across all instances.

For our Client class, we might want to set the name of the bank, and the location, which would not change from instance to instance.


In [13]:
Client.bank = "TD"
Client.location = "Toronto, ON"

In the case of our 'Client' class, we may want to update a person's bank account once they withdraw or deposit money. Let's create these methods below.


In [1]:
# Use the Client class code above to now add methods for withdrawal and depositing of money

# create the Client class below
class Client(object):
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance + 100
        
        #define account level
        if self.balance < 5000:
            self.level = "Basic"
        elif self.balance < 15000:
            self.level = "Intermediate"
        else:
            self.level = "Advanced"
            
    def deposit(self, amount):
        self.balance += amount
        return self.balance
    
    def withdraw(self, amount):
        if amount > self.balance:
            raise RuntimeError("Insufficient for withdrawal")
        else:
            self.balance -= amount
        return self.balance

In [2]:
C1 = Client("John Doe", 500)

In [5]:
C1.level


Out[5]:
'Basic'

In [7]:
C1.deposit(150000)


Out[7]:
165600

In [10]:
C1.level


Out[10]:
'Basic'

In [ ]:
# yes, we have to repeat the part that defines the account level after each deposit

class Client():
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance + 100
        
        # define account level 
        if self.balance < 5000: 
            self.level = "Basic"
        elif self.balance < 15000:
            self.level = "Intermediate"
        else: 
            self.level = "Advanced"
        
    
    def deposit(self, amount):
            self.balance += amount         

            if self.balance < 5000: 
                self.level = "Basic"
            elif self.balance < 15000:
                self.level = "Intermediate"
            else: 
                self.level = "Advanced"
            return self.balance
        
            
        
    def withdraw(self, amount):
            if amount > self.balance: 
                raise RuntimeError("Insufficient for withdrawal")
            else: 
                self.balance -= amount
            return self.balance

Challenge:

can we write this Class in a more efficient way to not repeat assigining the balance?

Static Methods

Static methods are methods that belong to a class but do not have access to self and hence don't require an instance to function (i.e. it will work on the class level as well as the instance level).

We denote these with the line @staticmethod before we define our static method.

Let's create a static method called make_money_sound() that will simply print "Cha-ching!" when called.


In [6]:
# Add a static method called make_money_sound()
# create the Client class below
class Client(object):
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance + 100
        
        #define account level
        if self.balance < 5000:
            self.level = "Basic"
        elif self.balance < 15000:
            self.level = "Intermediate"
        else:
            self.level = "Advanced"
    
    def deposit(self, amount):
        self.balance += amount
        if self.balance < 5000: 
               self.level = "Basic"
        elif self.balance < 15000:
               self.level = "Intermediate"
        else: 
               self.level = "Advanced"
        return self.balance
   
    def withdraw(self, amount):
        if amount > self.balance:
            raise RuntimeError("Insufficient for withdrawal")
        else:
            self.balance -= amount
        return self.balance
            
    @staticmethod
    def make_money_sound():
        print("Cha-ching!")

In [7]:
Client.make_money_sound()


Cha-ching!

In [9]:
C1.make_money_sound()


Cha-ching!

Class Methods

A class method is a type of method that will receive the class rather than the instance as the first parameter. It is also identified similarly to a static method, with @classmethod.

Create a class method called bank_location() that will print both the bank name and location when called upon the class.


In [11]:
# Add a class method called bank_location()
# create the Client class below
class Client(object):
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance + 100
        
        #define account level
        if self.balance < 5000:
            self.level = "Basic"
        elif self.balance < 15000:
            self.level = "Intermediate"
        else:
            self.level = "Advanced"
    
    def deposit(self, amount):
        self.balance += amount
        return self.balance
    
    def withdraw(self, amount):
        if amount > self.balance:
            raise RuntimeError("Insufficient for withdrawal")
        else:
            self.balance -= amount
        return self.balance
            
    @staticmethod
    def make_money_sound():
        print("Cha-ching!")
            
    @classmethod
    def bank_location(cls):
        return str(cls.bank + " " + cls.location)

In [14]:
Client.bank_location()


Out[14]:
'TD Toronto, ON'

The code worked because we have assigned 'bank' and 'location' variables before. You can integrate this information in your class:


In [15]:
class Client(object):
    bank = "TD"
    location = "Toronto, ON"
    
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance + 100
        
        # define account level 
        if self.balance < 5000: 
            self.level = "Basic"
        elif self.balance < 15000:
            self.level = "Intermediate"
        else: 
            self.level = "Advanced"
        
    
    def deposit(self, amount):
            self.balance += amount         
            # define account level 
            if self.balance < 5000: 
                self.level = "Basic"
            elif self.balance < 15000:
                self.level = "Intermediate"
            else: 
                self.level = "Advanced"
            return self.balance
        
            
        
    def withdraw(self, amount):
            if amount > self.balance: 
                raise RuntimeError("Insufficient for withdrawal")
            else: 
                self.balance -= amount
            return self.balance
    
    @classmethod 
    def bank_location(cls):
        return str(cls.bank + " " + cls.location)

In [16]:
Client.bank_location()


Out[16]:
'TD Toronto, ON'

Key Concept: Inheritance

A 'child' class can be created from a 'parent' class, whereby the child will bring over attributes and methods that its parent has, but where new features can be created as well.

This would be useful if you want to create multiple classes that would have some features that are kept the same between them. You would simply create a parent class of these children classes that have those maintained features.

Imagine we want to create different types of clients but still have all the base attributes and methods found in client currently.

For example, let's create a class called Savings that inherits from the Client class. In doing so, we do not need to write another __init__ method as it will inherit this from its parent.


In [17]:
# create the Savings class below
class Savings(Client):
    interest_rate = 0.005
    
    def update_balance(self):
        self.balance += self.balance*self.interest_rate
        return self.balance

In [18]:
# create an instance the same way as a Client but this time by calling Savings instead
C3 = Savings("Tom Smith", 50)

In [19]:
# it now has access to the new attributes and methods in Savings...
print(C3.name)
print(C3.balance)
print(C3.interest_rate)


Tom Smith
150
0.005

In [20]:
# ...as well as access to attributes and methods from the Client class as well
C3.update_balance()


Out[20]:
150.75

In [21]:
#defining a method outside the class definition
def check_balance(self):
    return self.balance

Client.check_balance = check_balance

In [23]:
C3.check_balance()


Out[23]:
150.75

In [26]:
C2.check_balance()


Out[26]:
150100

Challenge:

make a child for Savings Class that doubles the amount of savings and adds 2 more dollars.


In [50]:
class special(Savings):
    def update_special(self):
        self.balance += self.balance + 2
        return self.balance

In [52]:
C4 = special("Sara",100)
print(C4.update_balance())
C4.update_special()


201.0
Out[52]:
404.0