Created by Zhuo Chen and Nathan Kelber for JSTOR Labs under Creative Commons CC BY License


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

Knowledge Required:

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)
Help on class str in module builtins:

class str(object)
 |  str(object='') -> str
 |  str(bytes_or_buffer[, encoding[, errors]]) -> str
 |  
 |  Create a new string object from the given object. If encoding or
 |  errors is specified, then the object must expose a data buffer
 |  that will be decoded using the given encoding and error handler.
 |  Otherwise, returns the result of object.__str__() (if defined)
 |  or repr(object).
 |  encoding defaults to sys.getdefaultencoding().
 |  errors defaults to 'strict'.
 |  
 |  Methods defined here:
 |  
 |  __add__(self, value, /)
 |      Return self+value.
 |  
 |  __contains__(self, key, /)
 |      Return key in self.
 |  
 |  __eq__(self, value, /)
 |      Return self==value.
 |  
 |  __format__(self, format_spec, /)
 |      Return a formatted version of the string as described by format_spec.
 |  
 |  __ge__(self, value, /)
 |      Return self>=value.
 |  
 |  __getattribute__(self, name, /)
 |      Return getattr(self, name).
 |  
 |  __getitem__(self, key, /)
 |      Return self[key].
 |  
 |  __getnewargs__(...)
 |  
 |  __gt__(self, value, /)
 |      Return self>value.
 |  
 |  __hash__(self, /)
 |      Return hash(self).
 |  
 |  __iter__(self, /)
 |      Implement iter(self).
 |  
 |  __le__(self, value, /)
 |      Return self<=value.
 |  
 |  __len__(self, /)
 |      Return len(self).
 |  
 |  __lt__(self, value, /)
 |      Return self<value.
 |  
 |  __mod__(self, value, /)
 |      Return self%value.
 |  
 |  __mul__(self, value, /)
 |      Return self*value.
 |  
 |  __ne__(self, value, /)
 |      Return self!=value.
 |  
 |  __repr__(self, /)
 |      Return repr(self).
 |  
 |  __rmod__(self, value, /)
 |      Return value%self.
 |  
 |  __rmul__(self, value, /)
 |      Return value*self.
 |  
 |  __sizeof__(self, /)
 |      Return the size of the string in memory, in bytes.
 |  
 |  __str__(self, /)
 |      Return str(self).
 |  
 |  capitalize(self, /)
 |      Return a capitalized version of the string.
 |      
 |      More specifically, make the first character have upper case and the rest lower
 |      case.
 |  
 |  casefold(self, /)
 |      Return a version of the string suitable for caseless comparisons.
 |  
 |  center(self, width, fillchar=' ', /)
 |      Return a centered string of length width.
 |      
 |      Padding is done using the specified fill character (default is a space).
 |  
 |  count(...)
 |      S.count(sub[, start[, end]]) -> int
 |      
 |      Return the number of non-overlapping occurrences of substring sub in
 |      string S[start:end].  Optional arguments start and end are
 |      interpreted as in slice notation.
 |  
 |  encode(self, /, encoding='utf-8', errors='strict')
 |      Encode the string using the codec registered for encoding.
 |      
 |      encoding
 |        The encoding in which to encode the string.
 |      errors
 |        The error handling scheme to use for encoding errors.
 |        The default is 'strict' meaning that encoding errors raise a
 |        UnicodeEncodeError.  Other possible values are 'ignore', 'replace' and
 |        'xmlcharrefreplace' as well as any other name registered with
 |        codecs.register_error that can handle UnicodeEncodeErrors.
 |  
 |  endswith(...)
 |      S.endswith(suffix[, start[, end]]) -> bool
 |      
 |      Return True if S ends with the specified suffix, False otherwise.
 |      With optional start, test S beginning at that position.
 |      With optional end, stop comparing S at that position.
 |      suffix can also be a tuple of strings to try.
 |  
 |  expandtabs(self, /, tabsize=8)
 |      Return a copy where all tab characters are expanded using spaces.
 |      
 |      If tabsize is not given, a tab size of 8 characters is assumed.
 |  
 |  find(...)
 |      S.find(sub[, start[, end]]) -> int
 |      
 |      Return the lowest index in S where substring sub is found,
 |      such that sub is contained within S[start:end].  Optional
 |      arguments start and end are interpreted as in slice notation.
 |      
 |      Return -1 on failure.
 |  
 |  format(...)
 |      S.format(*args, **kwargs) -> str
 |      
 |      Return a formatted version of S, using substitutions from args and kwargs.
 |      The substitutions are identified by braces ('{' and '}').
 |  
 |  format_map(...)
 |      S.format_map(mapping) -> str
 |      
 |      Return a formatted version of S, using substitutions from mapping.
 |      The substitutions are identified by braces ('{' and '}').
 |  
 |  index(...)
 |      S.index(sub[, start[, end]]) -> int
 |      
 |      Return the lowest index in S where substring sub is found,
 |      such that sub is contained within S[start:end].  Optional
 |      arguments start and end are interpreted as in slice notation.
 |      
 |      Raises ValueError when the substring is not found.
 |  
 |  isalnum(self, /)
 |      Return True if the string is an alpha-numeric string, False otherwise.
 |      
 |      A string is alpha-numeric if all characters in the string are alpha-numeric and
 |      there is at least one character in the string.
 |  
 |  isalpha(self, /)
 |      Return True if the string is an alphabetic string, False otherwise.
 |      
 |      A string is alphabetic if all characters in the string are alphabetic and there
 |      is at least one character in the string.
 |  
 |  isascii(self, /)
 |      Return True if all characters in the string are ASCII, False otherwise.
 |      
 |      ASCII characters have code points in the range U+0000-U+007F.
 |      Empty string is ASCII too.
 |  
 |  isdecimal(self, /)
 |      Return True if the string is a decimal string, False otherwise.
 |      
 |      A string is a decimal string if all characters in the string are decimal and
 |      there is at least one character in the string.
 |  
 |  isdigit(self, /)
 |      Return True if the string is a digit string, False otherwise.
 |      
 |      A string is a digit string if all characters in the string are digits and there
 |      is at least one character in the string.
 |  
 |  isidentifier(self, /)
 |      Return True if the string is a valid Python identifier, False otherwise.
 |      
 |      Call keyword.iskeyword(s) to test whether string s is a reserved identifier,
 |      such as "def" or "class".
 |  
 |  islower(self, /)
 |      Return True if the string is a lowercase string, False otherwise.
 |      
 |      A string is lowercase if all cased characters in the string are lowercase and
 |      there is at least one cased character in the string.
 |  
 |  isnumeric(self, /)
 |      Return True if the string is a numeric string, False otherwise.
 |      
 |      A string is numeric if all characters in the string are numeric and there is at
 |      least one character in the string.
 |  
 |  isprintable(self, /)
 |      Return True if the string is printable, False otherwise.
 |      
 |      A string is printable if all of its characters are considered printable in
 |      repr() or if it is empty.
 |  
 |  isspace(self, /)
 |      Return True if the string is a whitespace string, False otherwise.
 |      
 |      A string is whitespace if all characters in the string are whitespace and there
 |      is at least one character in the string.
 |  
 |  istitle(self, /)
 |      Return True if the string is a title-cased string, False otherwise.
 |      
 |      In a title-cased string, upper- and title-case characters may only
 |      follow uncased characters and lowercase characters only cased ones.
 |  
 |  isupper(self, /)
 |      Return True if the string is an uppercase string, False otherwise.
 |      
 |      A string is uppercase if all cased characters in the string are uppercase and
 |      there is at least one cased character in the string.
 |  
 |  join(self, iterable, /)
 |      Concatenate any number of strings.
 |      
 |      The string whose method is called is inserted in between each given string.
 |      The result is returned as a new string.
 |      
 |      Example: '.'.join(['ab', 'pq', 'rs']) -> 'ab.pq.rs'
 |  
 |  ljust(self, width, fillchar=' ', /)
 |      Return a left-justified string of length width.
 |      
 |      Padding is done using the specified fill character (default is a space).
 |  
 |  lower(self, /)
 |      Return a copy of the string converted to lowercase.
 |  
 |  lstrip(self, chars=None, /)
 |      Return a copy of the string with leading whitespace removed.
 |      
 |      If chars is given and not None, remove characters in chars instead.
 |  
 |  partition(self, sep, /)
 |      Partition the string into three parts using the given separator.
 |      
 |      This will search for the separator in the string.  If the separator is found,
 |      returns a 3-tuple containing the part before the separator, the separator
 |      itself, and the part after it.
 |      
 |      If the separator is not found, returns a 3-tuple containing the original string
 |      and two empty strings.
 |  
 |  removeprefix(self, prefix, /)
 |      Return a str with the given prefix string removed if present.
 |      
 |      If the string starts with the prefix string, return string[len(prefix):].
 |      Otherwise, return a copy of the original string.
 |  
 |  removesuffix(self, suffix, /)
 |      Return a str with the given suffix string removed if present.
 |      
 |      If the string ends with the suffix string and that suffix is not empty,
 |      return string[:-len(suffix)]. Otherwise, return a copy of the original
 |      string.
 |  
 |  replace(self, old, new, count=-1, /)
 |      Return a copy with all occurrences of substring old replaced by new.
 |      
 |        count
 |          Maximum number of occurrences to replace.
 |          -1 (the default value) means replace all occurrences.
 |      
 |      If the optional argument count is given, only the first count occurrences are
 |      replaced.
 |  
 |  rfind(...)
 |      S.rfind(sub[, start[, end]]) -> int
 |      
 |      Return the highest index in S where substring sub is found,
 |      such that sub is contained within S[start:end].  Optional
 |      arguments start and end are interpreted as in slice notation.
 |      
 |      Return -1 on failure.
 |  
 |  rindex(...)
 |      S.rindex(sub[, start[, end]]) -> int
 |      
 |      Return the highest index in S where substring sub is found,
 |      such that sub is contained within S[start:end].  Optional
 |      arguments start and end are interpreted as in slice notation.
 |      
 |      Raises ValueError when the substring is not found.
 |  
 |  rjust(self, width, fillchar=' ', /)
 |      Return a right-justified string of length width.
 |      
 |      Padding is done using the specified fill character (default is a space).
 |  
 |  rpartition(self, sep, /)
 |      Partition the string into three parts using the given separator.
 |      
 |      This will search for the separator in the string, starting at the end. If
 |      the separator is found, returns a 3-tuple containing the part before the
 |      separator, the separator itself, and the part after it.
 |      
 |      If the separator is not found, returns a 3-tuple containing two empty strings
 |      and the original string.
 |  
 |  rsplit(self, /, sep=None, maxsplit=-1)
 |      Return a list of the substrings in the string, using sep as the separator string.
 |      
 |        sep
 |          The separator used to split the string.
 |      
 |          When set to None (the default value), will split on any whitespace
 |          character (including \n \r \t \f and spaces) and will discard
 |          empty strings from the result.
 |        maxsplit
 |          Maximum number of splits.
 |          -1 (the default value) means no limit.
 |      
 |      Splitting starts at the end of the string and works to the front.
 |  
 |  rstrip(self, chars=None, /)
 |      Return a copy of the string with trailing whitespace removed.
 |      
 |      If chars is given and not None, remove characters in chars instead.
 |  
 |  split(self, /, sep=None, maxsplit=-1)
 |      Return a list of the substrings in the string, using sep as the separator string.
 |      
 |        sep
 |          The separator used to split the string.
 |      
 |          When set to None (the default value), will split on any whitespace
 |          character (including \n \r \t \f and spaces) and will discard
 |          empty strings from the result.
 |        maxsplit
 |          Maximum number of splits.
 |          -1 (the default value) means no limit.
 |      
 |      Splitting starts at the front of the string and works to the end.
 |      
 |      Note, str.split() is mainly useful for data that has been intentionally
 |      delimited.  With natural text that includes punctuation, consider using
 |      the regular expression module.
 |  
 |  splitlines(self, /, keepends=False)
 |      Return a list of the lines in the string, breaking at line boundaries.
 |      
 |      Line breaks are not included in the resulting list unless keepends is given and
 |      true.
 |  
 |  startswith(...)
 |      S.startswith(prefix[, start[, end]]) -> bool
 |      
 |      Return True if S starts with the specified prefix, False otherwise.
 |      With optional start, test S beginning at that position.
 |      With optional end, stop comparing S at that position.
 |      prefix can also be a tuple of strings to try.
 |  
 |  strip(self, chars=None, /)
 |      Return a copy of the string with leading and trailing whitespace removed.
 |      
 |      If chars is given and not None, remove characters in chars instead.
 |  
 |  swapcase(self, /)
 |      Convert uppercase characters to lowercase and lowercase characters to uppercase.
 |  
 |  title(self, /)
 |      Return a version of the string where each word is titlecased.
 |      
 |      More specifically, words start with uppercased characters and all remaining
 |      cased characters have lower case.
 |  
 |  translate(self, table, /)
 |      Replace each character in the string using the given translation table.
 |      
 |        table
 |          Translation table, which must be a mapping of Unicode ordinals to
 |          Unicode ordinals, strings, or None.
 |      
 |      The table must implement lookup/indexing via __getitem__, for instance a
 |      dictionary or list.  If this operation raises LookupError, the character is
 |      left untouched.  Characters mapped to None are deleted.
 |  
 |  upper(self, /)
 |      Return a copy of the string converted to uppercase.
 |  
 |  zfill(self, width, /)
 |      Pad a numeric string with zeros on the left, to fill a field of the given width.
 |      
 |      The string is never truncated.
 |  
 |  ----------------------------------------------------------------------
 |  Static methods defined here:
 |  
 |  __new__(*args, **kwargs)
 |      Create and return a new object.  See help(type) for accurate signature.
 |  
 |  maketrans(...)
 |      Return a translation table usable for str.translate().
 |      
 |      If there is only one argument, it must be a dictionary mapping Unicode
 |      ordinals (integers) or characters to Unicode ordinals, strings or None.
 |      Character keys will be then converted to ordinals.
 |      If there are two arguments, they must be strings of equal length, and
 |      in the resulting dictionary, each character in x will be mapped to the
 |      character at the same position in y. If there is a third argument, it
 |      must be a string, whose characters will be mapped to None in the result.

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.')