Created by Zhuo Chen and Nathan Kelber for JSTOR Labs under Creative Commons CC BY License
For questions/comments/improvements, email zhuo.chen@ithaka.org or nathan.kelber@ithaka.org.

Python Intermediate 4#

Description: This notebook defines:

  • Object-oriented programming (OOP), classes, objects

  • Attributes, methods and constructors

  • The difference between functional programming and OOP

  • Why OOP is useful

This notebook teaches

  • How to write a class

  • How to create objects using a class

  • How to inherit attributes and methods from a parent class to a child class

Use Case: For learners (detailed explanation)

Difficulty: Intermediate

Completion Time: 90 minutes

Knowledge Required:

Knowledge Recommended: None

Data Format: None

Libraries Used: None

Research Pipeline: None

Classes and objects#

Object-oriented programming (OOP) is a programming paradigm that relies on the concept of classes and objects. To understand OOP, we will need to understand classes and objects.

To make the concepts of classes and objects more concrete, let’s use a simple example.

Suppose you are looking to buy a house. You keep a record of the houses you are interested in in order to compare them.

bedroom

3

bathroom

1.5

price

600000

sqft

1500

price_per_sqft

def price_per_sqft (600000, 1500):
\(~~~\)price_per_sqft = 600000 / 1500
\(~~~\)return price_per_sqft

bedroom

4

bathroom

2

price

800000

sqft

2000

price_per_sqft

def price_per_sqft (800000, 2000):
\(~~~\)price_per_sqft = 800000 / 2000
\(~~~\)return price_per_sqft

For each house, you create a record. The record contains two kinds of information:

  • attributes - the properties of the house such as number of bedrooms, number of bathrooms, prices, and square footage

  • methods - the functions that analyze the houses properties and return new values, such as price_per_sqft

Python has a better way to organize such a collection of information: objects.

What is an object?#

An object is basically a collection of attributes and functions. With such a collection of information, an object can be used to represent anything, e.g. a person, a dog, a school, etc.

Coming back to our example, we are using the object below to represent house1. Of course, depending on what attributes and functions you want to include, you may represent house1 with a different set of information stored in the object. In our scenario, we choose to use the number of bedrooms, the number of bathrooms, the price of the house and a function that calculates and returns the price per square foot to represent house1. Let’s assign this object to the variable house1.

We have created another object representing house2. Let’s assign the object to the variable house2.

The two objects we have created to represent house1 and house2 are similar. They have the same set of attributes, e.g. number of bedrooms, number of bathrooms, house price, number of square feet, and they have the same set of methods, e.g. a function that calculates and returns the price per square foot. The objects are like two variants of a similar recipe.

Now, if the objects are created based on the same recipe, it will be ideal if we can write out that recipe and then use it to produce the same kind of objects. In the house-buying scenario, for example, you will want as many objects as there are houses you are interested in! The question now becomes: how do we write that recipe? This is exactly where classes come in.

What is a class?#

A class is an abstract blueprint from which we create individual instances of objects.

bedroom

bathroom

price

sqft

price_per_sqft

def price_per_sqft (price, sqft):
\(~~~\)price_per_sqft = price / sqft
\(~~~\)return price_per_sqft

Notice that the values assigned to the four variables, i.e. bedroom, bathroom, price, sqft, are not specified in this class. This is because a class does not refer to any specific object. A class refers to a broad category of objects. Here in the house-buying scenario, our class refers to the category of houses. The class specifies what attributes the houses have and also what functions operate on these houses.

Creating a Class in Python#

Now, let’s write some codes to create our house class!

# Create a class named 'House'
class House:
    """A simple class that models a house"""
    
    def __init__(self, bedroom, bathroom, price, sqft): # constructor
        """Initialize any new instances of class House with the following attributes"""
        self.bedroom = bedroom      ## instance attribute
        self.bathroom = bathroom    ## instance attribute
        self.price = price          ## instance attribute
        self.sqft = sqft            ## instance attribute
    
    def price_per_sqft(self): ## Method
        """Calculates the price per square foot based on price and square footage"""
        price_per_sqft = self.price / self.sqft
        return price_per_sqft

By convention, a class name in Python starts with a capital letter and follows CamelCase. (Each word uses a capital letter unlike variable names that use lowercasing with snake_case.) We will use: House. Like a function definition, it is a good practice to include a docstring that describes the class. These docstrings are returned when using the help() function.

Next, we define a special function called __init__ that defines the attributes for the instances of the House class. (Make sure to always use two underscores before and after.) The __init__ function initializes the instances of a class and has the special name of constructor. It determines what attributes will be initialized when an instance of the class House is created. The first parameter for __init__ must be self. Then we can define any number of additional parameters.

In our __init__ function, we then set a number of instance variables prefixed with self.. This makes them available for each instance in our class. Here we have:

self.bedroom = bedroom     
self.bathroom = bathroom    
self.price = price      
self.sqft = sqft

Conventionally, the argument names and variables names are the same. Each argument passed is transformed into an attribute of a given instance by use of these assignment statements.

Finally, we define an additional function: price_per_sqft. Again, for any function in the class definition, we include the parameter: self. This function will become a method we can call with dot notation on a particular instance of the class House. Note that the price_per_sqft definition uses self.price and self.sqft, not the parameters price and sqft. Our function concludes by returning the price_per_sqft.

We create a particular instance of the House class by using an assignment statement. We call the House class like a function and pass in the corresponding required arguments that match the parameters in the House definition. Note: The self parameter is ignored. The first argument passed will be bedroom, then bathroom, etc.

# Create an object house1
# Each argument corresponds to an attribute: 
# bedroom, bathroom, price, sqft

house1 = House(3, 1.5, 600_000, 1500) 

We can access the attributes of house1 using dot notation. Since these are attributes (kind of like object properties), they do not require parentheses () at the end.

# Get the value of the attribute bedroom of house1
house1.bedroom
3
# Get the value of the attribute bathroom of house1
house1.bathroom
1.5
# Get the value of the attribute price of house1
house1.price
600000
# Get the value of the attribute sqft of house1
house1.sqft
1500

We can also access the .price_per_sqft() method using dot notation. A method is a function and can require parameters, so it always includes parentheses (even if no argument is passed).

# Use the method price_per_sqft of house1
house1.price_per_sqft()
400.0
# Create an object house2
house2 = House(4, 2, 800_000, 2000)
# Get the value of the attribute bedroom of house2
house2.bedroom
4

Modifying an instance attribute#

# Modify an existing instance attribute by assigning a new value
house1.price = 700_000
house1.price
700000

We can also add a new attribute to an existing instance, even if the attribute was not defined in the class’s constructor (the __init__ definition).

# Create a new instance attribute and assign a value to it
house1.lot_size = 3000
house1.lot_size
3000

But it will only be available for that instance and not any other instances of the class.

# The instance attribute is specific to the object house1
# house2 was not initialized with a `lot_size` attribute
house2.lot_size
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[12], line 3
      1 # The instance attribute is specific to the object house1
      2 # house2 was not initialized with a `lot_size` attribute
----> 3 house2.lot_size

AttributeError: 'House' object has no attribute 'lot_size'

If there is a property that you want to store in an instance attribute, but not every house has a value for that property (due to missing information, for instance), you could set the default value to a null value None. For those houses which have a non-null value for that property, the value you pass in will override the null value.

# Create a class House with lot_size set to None by default
class House:
    """A simple class that models a house"""
    
    def __init__(self, bedroom, bathroom, price, sqft, lot_size = None): ## constructor
        """Initialize any new instances of class House with the following attributes"""
        self.bedroom = bedroom      ## instance attribute
        self.bathroom = bathroom    ## instance attribute
        self.price = price          ## instance attribute
        self.sqft = sqft            ## instance attribute
        self.lot_size = lot_size    ## instance attribute
        
    def price_per_sqft(self): ## Method
        """Calculates the price per square foot based on price and square footage"""
        price_per_sqft = self.price / self.sqft
        return price_per_sqft
# Create an object house1
house1 = House(3, 1.5, 600_000, 1500) 
# Access the value of the instance attribute lot_size of house1
house1.lot_size
# Create an object house2
house2 = House(4, 2, 800_000, 2000, 3000)
# Access the value of the instance attribute lot_size of house2
house2.lot_size
3000

Coding Challenge! < / >

Create a class called Employee. In this class, create the instance variables first_name, last_name, salary and email. Also, create a method that prints out the full name of instances of this class. Then, create two instances of this class.

# Create a new class called Employee
# Create two instances of the class Employee

Instance attribute vs. class attribute#

In the class House, we have defined several instance attributes like bedroom, bathroom, price and sqft. We can also define class attributes. Instance attributes are the attributes of an instance of a class. Class attributes are the attributes of a class. Let’s use our class House to illustrate.

Suppose the houses you are interested in have all dropped their prices by 5%. You want to revise the House class you have created slightly so that you are able to calculate the new house prices.

# Define a new class attribute and write a new method in the class House to calculate new house prices
class House:
    """A simple class that models a house"""
    
    pct_change = -0.05   ## class attribute
    
    def __init__(self, bedroom, bathroom, price, sqft): ## constructor
        """Initialize any new instances of class House with the following attributes"""
        self.bedroom = bedroom      ## instance attribute
        self.bathroom = bathroom    ## instance attribute
        self.price = price          ## instance attribute
        self.sqft = sqft            ## instance attribute
        
    def price_per_sqft(self): ## method
        """Calculates the price per square foot based on price and square footage"""
        price_per_sqft = self.price / self.sqft
        return price_per_sqft
    
    def new_price(self): ## method
        """Calculates the new house price based on the class variable perc_change"""
        new_price = self.price * (1 + House.pct_change)
        return new_price
# Create an object house1 and use the new method
house1 = House(3, 1.5, 600_000, 1500) 
house1.new_price()
570000.0

Differences between instance attributes and class attributes:

  • Instance attributes are defined in the __init__ function. Class attributes are defined outside of it.

  • The values of instance attributes are possibily different for each instance of a class. However, the values of class attributes are shared by all the instances of a class.

If you change the value of a class attribute, it will affect all instances of that class. If you change the value of an instance attribute, it will only affect that one instance.

# Access the class attribute using the instance house1
house1.pct_change
-0.05
# Access the class attribute using the instance house2
house2 = House(4, 2, 800_000, 2000)
house2.pct_change
-0.05
# Change the class attribute pct_change to -0.06
House.pct_change = -0.06
# Access the class attribute pct_change again using house1
house1.pct_change
-0.06
# Access the class attribute pct_change again using house2
house2.pct_change
-0.06

Coding Challenge! < / >

The company has just announced a pay raise. Everyone will get a pay raise of 8%. Add a class variable pay_raise to the class Employee. For the two instances you created just now, create a new method that will calculate their new pay.

# Create a new method for the class Employee
# The method will calculate a 8% raise for each employee

Instance method vs. class method#

Instance method receives the instance of the class as the first argument, which is called self by convention. Using the self parameter, we can access the instance attributes of an object and change the object state by changing the values assigned to the instance variables.

We have both instance attribute and class attribute. Do we have class method apart from instance method? The answer is yes.

In Python, the @classmethod decorator is used to declare a method in the class as a class method that can be called using ClassName.MethodName().

class House:
    """A simple class that models a house"""
    
    pct_change = -0.05   ## class attribute
    
    def __init__(self, bedroom, bathroom, price, sqft): ## constructor
        """Initialize any new instances of class House with the following attributes"""
        self.bedroom = bedroom      ## instance attribute
        self.bathroom = bathroom    ## instance attribute
        self.price = price          ## instance attribute
        self.sqft = sqft            ## instance attribute
        
    def price_per_sqft(self): ## instance method
        """Calculates the price per square foot based on price and square footage"""
        price_per_sqft = self.price / self.sqft
        return price_per_sqft
    
    def new_price (self): ## instance method
        """Calculates the new house price based on the class variable perc_raise"""
        new_price = self.price * (1 + House.pct_change)
        return new_price
    
    @classmethod ## Add a decorator on the top of the class method
    def print_pct_change(cls):  ## class method    
        if cls.pct_change >= 0:
            print(f'The house price has increased {cls.pct_change:.0%}.')
        else:
            print(f'The house price has decreased {-cls.pct_change:.0%}.')

You don’t need to create any instance of a class to access its class methods.

# Use the class method to update the percent of price change
House.print_pct_change()
The house price has decreased 5%.

Again, changing the value of a class attribute affects all the instances.

# Create house1 and house2 using this new class
house1 = House(3, 1.5, 600_000, 1500) 
house2 = House(4, 2, 800_000, 2000)
# Access the class attribute after the update using instance house1
house1.pct_change
-0.05
# Access the class attribute after the update using instance house2
house2.pct_change
-0.05

A perspective shift from functional programming to OOP#

In a class, we have some data and some functions that operate on those data. So, why don’t we just store the data in some format and write functions separately?

# Create a function that checks the first letter in a string
def startswith(string, letter): # A function that checks whether a string starts with a certain letter
    """Takes a string and a letter and outputs True/False
    depending on whether the string starts with the letter."""
    if string[0] == letter:
        return True
    return False
# Use the function startswith to check whether 'John' starts with letter 'J'
startswith('John', 'J')
True
# Create a function that checks whether a string ends with a certain letter
def endswith(string, letter): 
    """Takes a string and a letter and outputs True/False
    depending on whether the string ends with the letter."""
    if string[-1] == letter:
        return True
    return False
# Use the function endswith to check whether 'John' ends with letter 'J'
endswith('John', 'J')
False

From the perspective of functional programming, we are putting the functions at the center stage. Here we put the functions startswith and endswith at the center stage in particular. The strings, e.g. s1 and s2, are the input to the functions.

Since these two operations are so common with strings, it would be great if we have them always ready to use when we have a string. So, let’s shift our perspective and put the strings at the center stage. Here, s1 and s2 are not passively waiting to be taken by functions as input. Instead, they are active objects. The functions that we wrote before, startswith and endswith, are now the tools that s1 and s2 can use. This is the perspective of OOP.

# Treating 'John' as an object and using the .startswith() method
'John'.startswith('J')
True
# Treating 'John' as an object and using the .endswith() method
'John'.endswith('J')
False

You may not be aware of it, but you have been working with classes all the time! Did you notice that I did not create a class string and write the methods startswith and endswith myself, but somehow I can use them in the examples? That’s because Python already did it for us!

# Print out the type of the string 'John'
print(type('John'))
<class 'str'>
# Use the help function to check the attributes and methods for the string class
help(str)

The same with lists. When you create a list in Python, you create a list object.

ls = [1, 2, 3]
print(type(ls))
<class 'list'>
# Use the remove method of the list class
ls.remove(3)
ls
[1, 2]

Inheritance#

We have seen how OOP can help quickly create instances of objects. Another significant benefit of OOP is the opportunity to use inheritance.

Suppose in the process of house hunting, you find houses in suburbs and houses in the city both have advantages and disadvantages. Now, you are interested in the commute expenses you have to pay if you choose a house in the suburbs or a house in the city. You want to add this information to your house data and at the same time maintain the attributes and methods you have written in the House class. How can you do it? This is where inheritance comes in.

Inheritance in OOP allows us to inherit attributes and methods from a parent class to child classes. What makes OOP particularly attractive is exactly this reusability! Inheritance helps us avoid repeating ourselves when writing code.

class SuburbanHouse(House):
    """A child class that inherits from House for modeling suburban houses"""
    
    def __init__(self, bedroom, bathroom, price, sqft, distance): # constructor
        """Initialize all the attributes for a suburban house"""
        super().__init__(bedroom, bathroom, price, sqft) # let the parent class take care of the existing attributes
        self.distance = distance # add the new instance attribute
    
    def gas_expenses(self): # add the new method
        """Take the distance and calculate a monthly fuel expense"""
        expense = 0.5 * self.distance * 2 * 22 # assume $0.5/mile for the gas
        return expense

When we define the SuburbanHouse class, we add the House class as a parameter:

class SuburbanHouse(House):

This tells Python to inherit all the attributes and methods from the House class. In our SuburbanHouse constructor, we include the class attributes from the House class along with a new distance attribute that will be unique to the SuburbanHouse class.

The super().__init__() constructor informs Python of the attributes to pull from the House class. Then we are free to define additional child class attributes, in this case self.distance.

Finally, we also add a .gas_expenses() method that will only be available to SuburbanHouse objects but not regular House objects.

# Create an object of the new class SuburbanHouse
house3 = SuburbanHouse(4, 3.5, 900000, 2500, 20) 
# Use the attributes of the parent class
house3.bedroom
4
# Use the methods of the parent class
house3.price_per_sqft()
360.0
# Use the new instance attribute
house3.distance
20
# Use the new instance method
house3.gas_expenses()
440.0

Now imagine we are also considering a house in the city. We could use a train to commute instead. It would help to calculate whether the commute will be cheaper. We will create a new child class: CityHouse which has the method train_expenses().

class CityHouse(House):
    """A child class that inherits from House for modeling city houses"""
    
    def __init__(self, bedroom, bathroom, price, sqft, train_stops): ## constructor
        """Initialize all the attributes for a city house"""
        super().__init__(bedroom, bathroom, price, sqft) # let the parent class take care of the existing attributes
        self.train_stops = train_stops # add the new instance attribute
    
    def train_expenses(self):
        """Take the number of stops to job and calculate a monthly commute cost"""
        expense = 1.5 * self.train_stops * 2 * 22 # assume $1.50/stop for the train
        return expense
# Create an object of the new class City_house
house4 = CityHouse(3, 2, 1000000, 1200, 10)
# Use the attributes of the parent class
house4.sqft
1200
# Use the methods of the parent class
house4.price_per_sqft()
833.3333333333334
# Use the new instance attribute
house4.train_stops
10
# Use the new instance method
house4.train_expenses()
660.0

Coding Challenge! < / >

Use the class Employee you created as the parent class. Create two child classes, Accountants and Managers. Add a new instance variable and a new method to each child class.

Lesson Complete#

Congratulations! You have completed Python Intermediate 4.

Exercise Solutions#

Here are a few solutions for exercises in this lesson.

# Create a class Employee
class Employee:
    def __init__(self, first, last, salary):
        self.first = first
        self.last = last
        self.salary = salary
    def full_name(self):
        print(self.first + ' ' + self.last)
# Create two objects of the class Employee
john = Employee('John', 'Doe', 80000)
mary = Employee('Mary', 'Smith', 90000)
# Add a class variable pay_raise
class Employee:
    pay_raise = 0.05
    def __init__(self, first, last, salary):
        self.first = first
        self.last = last
        self.salary = salary
    def full_name(self):
        print(self.first + ' ' + self.last)
    def new_salary(self):
        return self.salary * (1 + 0.05)
# Calculate John's new salary
john = Employee('John', 'Doe', 80000)
john.new_salary()
84000.0
# Create two child classes of Employee
class Accountants(Employee):
    def __init__(self, first, last, salary, tenure):
        super().__init__(first, last, salary)
        self.tenure = tenure
    def bonus(self):
        if self.tenure%10 == 0:
            return 10000
        else:
            return 0

        
class Managers(Employee):
    def __init__(self, first, last, salary, team = None):
        super().__init__(first, last, salary)
        if team is None:
            self.team = []
        else:
            self.team = team
    def team_size(self):
        if len(self.team) > 50:
            print('Warning: team is too big to be managable.')
        else:
            print('Team size is managable.')