This lesson is still being designed and assembled (Pre-Alpha version)

Object Oriented Programming

Overview

Teaching: 0 min
Exercises: 0 min
Questions
  • What is Object Oriented Programming?

  • Why should I use Object Oriented Programming?

Objectives
  • Understand the concepts behind Object Oriented Programming.

Object Oriented Programming

Object Oriented Programming (OOP) is a style of programming that promotes the creation of objects that contain data and methods that act on their data. A program developed using OOP will consist of a number of objects that interact with one another. The primary concepts of OOP are Encapsulation, Data Abstraction, Polymorphism, and Inheritance. This introduction will briefly cover each of these concepts and why they are useful to a software developer.

Encapsulation

Encapsulation is the concept of enclosing related data and methods acting on those data within a single unit called a class. A class will consist of a set of data (variables) and a set of methods that interact with the data. There are a number of benefits to creating classes.

  1. It aids in the understanding of the code being developed. It is much easier to understand how an object will behave if all the data and methods that interact with that data are enclosed within its class. It means all of the information about that object is grouped into a single location.
  2. As an extension of the first benefit, anyone developing code that utilizes your objects will have a clear understanding of how they are allowed to interact with it. The methods they can use will be located within the class of the object.
  3. It promotes security of data by restricting the ways to access data to the specific methods within the class.
  4. Methods have full access to their data so that you don’t have to keep passing data and parameters between methods. Also, this way, you avoid the use of global variables.

Classes are used in code to provide a general definition of an object. We call an object an instance of a class, meaning it takes the structure of the class and fills in any necessary data. Using classes as a general definition allows you to construct many instances of the class with little work. Consider the following code to define a molecule without using a class.

molecule_name = "water molecule"
molecule_charge = 0.0
molecule_symbols = ["O", "H", "H"]

print(f'name: {molecule_name}\ncharge: {molecule_charge}\nsymbols: {molecule_symbols}')

molecule2_name = "He"
molecule2_charge = 0.0
molecule2_symbols = ["He"]

print(f'name: {molecule2_name}\ncharge: {molecule2_charge}\nsymbols: {molecule2_symbols}')

For each new molecule we want to build using this method, we need to create a new variable name for each of the variables and redefine how we are printing them. For a single instance, this may not seem terrible, but what if we need to make tens or hundreds of instances of the same type of object? The amount of additional code that needs to be written grows very quickly.

For this type of problem, we will want to create something called a class. Classes provide a way to bundle data and other functionality together.

Now consider code that creates and instantiates a class multiple times instead.

class Molecule:
    def __init__(self, name, charge, symbols):
        self.name = name
        self.charge = charge
        self.symbols = symbols

This is a simple definition of a class named Molecule. Let’s look at what each line does.

The first line of this code

class Molecule:

is defining the name of the class as Molecule. We then have a method called a constructor and it is called whenever you are instantiating an object of the class. We have the definition of the constructor

    def __init__(self, name, charge, symbols):

that has three parameters: name, charge, and symbols. The parameters of a constructor are required anytime you want to create an instance of the class. These can have default values if they are non-required. The next three lines

        self.name = name
        self.charge = charge
        self.symbols = symbols

set the value of the local object variables to the value of the parameters. Here, the self syntax refers to the instance of the class. Any time you want to set or create a variable associated with a class in its definition, you use this syntax.

We can now use this class definition in our code. For example, to create our water molecule, we use the class. This is called creating an instance of the class.

mol1 = Molecule(name='water molecule', charge=0.0, symbols=["O", "H", "H"])

mol1 in our code is now an object. We can access the variables associated with this instance of the molecule class using a dot notation. Variables associated with a class are also called attributes.

print(mol1.name)
print(mol1.charge)
print(mol1.symbols)

You should see the output

water molecule
0.0
['O', 'H', 'H']

Check your understanding

Create another instance of the class called mol2. This molecule should be an He molecule with 0 charge. After you have created this, print the molecule name and charge.

Solution

mol2 = Molecule(name="He", charge=0.0, symbols=["He"])

print(mol2.name)
print(mol2.charge)
He
0.0

You may notice that if you print your molecules, you get something that is confusing and not so pretty.

print(mol1)
<__main__.Molecule object at 0x103f046d8>

We can create a nicer representation for printing by writing a __str__ method for the class. In Python, there are special methods associated with classes which you can use for customization. These are also called “magic” methods. They exist inside a class, and begin and end with two underscores (__). The __init__ we have already used is a magic method used to set initial properties of a class instance. The __str__ method is called by built-in Python functions print() and format(). The return value of this function must be a string.

The __str__ method is simply a method to compute the string representation of our Molecule object to be used in printing, similar to how we defined it without any class, but it now will work for each instance of a Molecule without any modification. Let’s add this to our class definition, making the whole definition look like the following

class Molecule:
    def __init__(self, name, charge, symbols):
        self.name = name
        self.charge = charge
        self.symbols = symbols
		
    def __str__(self):
        return f'name: {self.name}\ncharge: {self.charge}\nsymbols: {self.symbols}'
		
mol1 = Molecule('water molecule', 0.0, ["O", "H", "H"])
mol2 = Molecule('He', 0.0, ["He"])

Now print these objects

print(mol1)
print(mol2)

You should see output that looks like this

name: water molecule
charge: 0.0
symbols: ['O', 'H', 'H']
name: He
charge: 0.0
symbols: ['He']

The construction and use of an object constructed through a class is simpler and more intuitive that trying to construct one from an arbitrary set of variables. Now anytime we wish to create a new molecule object and print out its values, we only require two new lines of code.

Check your understanding

Add an additional attribute to the Molecule class (in __init__) which stores the number of atoms in the molecule. Print the number of atoms for the water molecule.

Solution

class Molecule:
    def __init__(self, name, charge, symbols):
        self.name = name
        self.charge = charge
        self.symbols = symbols
        self.num_atoms = len(symbols)

    def __str__(self):
        return f'name: {self.name}\ncharge: {self.charge}\nsymbols: {self.symbols}'

mol1 = Molecule('water molecule', 0.0, ["O", "H", "H"])

print(f'{mol1.name} has {mol1.num_atoms} atoms.')
water molecule has 3 atoms.

Data Abstraction

Data abstraction is the concept of hiding implementation details from the user, allowing them to know how to use the code/class not how it actually works or implemented. For example, when you use a Coffee machine, you interact with its interface, but you don’t actually know how it is preparing the coffee inside. Another example is that when a Web browser connects to the Internet, it interacts with the Operating system to get the connection, but it doesn’t know if you are connecting using a dial-up or a wifi.

Clearly there are many benefits of using data abstraction:

  1. Can have multiple implementations
  2. Can build complex software by splitting functionality internally into steps, and only exposing one method to the user
  3. Change implementation later without affecting the user by moving frequently changing code into separate methods.
  4. Easier code collaboration since developers don’t need to know the details of every class, only how to use it.
  5. One of the main concepts that makes the code flexible and maintainable.

In Python, data abstraction can be achieved by the use of private and public attributes and methods.

Generally, variables can be public, in which case they are directly modifiable, or private, meaning interaction with their values is only possible through internal class methods. In python, there are no explicitly public or private variables, all variables are accessible within an object. However, the predominantly accepted practice is to treate names prefixed with an underscore as non-public. (See Python Private Variables)

The best way to achieve data abstraction in python is to use the ‘@property’ decorator, and the ‘setter’ decorator. These allow attributes to be used in a pythonic way, while allowing more control over their values. Consider our Molecule class:

class Molecule:
    def __init__(self, name, charge, symbols):
        self.name = name
        self.charge = charge
        self.symbols = symbols
        self.num_atoms = len(symbols)

    def __str__(self):
        return f'name: {self.name}\ncharge: {self.charge}\nsymbols: {self.symbols}'

We already can see a prime candidate for this approach in our ‘symbols’ and ‘num_atoms’. Since ‘num_atoms’ is based on the number of symbols, we dont want the user to modify it, and in addition, we want to update it whenever the number of symbols is updated. We can do this by creating a property and setter method for the ‘symbols’ variable.

class Molecule:
    def __init__(self, name, charge, symbols):
        self.name = name
        self.charge = charge
        self.symbols = symbols
        self.num_atoms = len(symbols)

    @property
    def symbols(self):
        return self._symbols
        
    @symbols.setter
    def symbols(self, symbols):
        self._symbols = symbols
        self.num_atoms = len(symbols)

    def __str__(self):
        return f'name: {self.name}\ncharge: {self.charge}\nsymbols: {self.symbols}'

Now, whenever someone tries to access ‘symbols’, it will properly return the value stored in our private variable. When someone tries to update the value of symbols directly, it will update the private variable, and also update the ‘num_atoms’ variable.

Inheritance

Inheritance is the principle of extending a class to add capabilities without modifying the original class. We call the class that is being inherited the parent, and the class that is inheriting the child. The child class obtains the properties and behaviors of its parent unless it overrides them.

In coding terms, this means a class that inherits from a parent class by default will contain all of the data variables and methods of the parent class. The child class can either utilize the methods as is or they can override the methods to modify their behavior without affecting the parent class or any objects that have instantiated that class.

Using inheritance in code development creates a hierarchy of objects, which often improves the readability of your code. It also saves time end effort by avoiding duplicate code production, i.e., inheriting from classes that have similar behavior and modifying them instead of writting a new class from scratch.

Let us consider an example of a records system for a university. A university has a large number of people, whether they are students or faculty. We will start by creating some classes for each of these types of people. First is a student class, at its simplest, a student has a name, a surname, and maybe a set of courses that they are registered for. Lets create a student class that takes a name and surname as parameters.

class Student:
    def __init__(self, name, surname):
        self.name = name
        self.surname = surname
        self.courses = []

Methods that only act upon the students data should be contained by the student class, this helps structure the methods in a more readible and accessible way. Lets add three methods, a method to enroll a student in a course, a method to let the student drop a course, and the built in __str__ method for the student class.

class Student:
    def __init__(self, name, surname):
        self.name = name
        self.surname = surname
        self.courses = []

    def enroll(self, new_course):
        self.courses.append(new_course)

It is often useful to generate a string representation of our class, so we want to override the built in method __str__.

class Student:
    def __init__(self, name, surname):
        self.name = name
        self.surname = surname
        self.courses = []

    def enroll(self, new_course):
        self.courses.append(new_course)
        
    def __str__(self):
        return f'{self.surname}, {self.name}\nCourses:\n{self.courses}'

Check your understanding

Add an additional method to the Student class to remove a course from the students enrolled courses.

Solution

class Student:
    def __init__(self, name, surname):
        self.name = name
        self.surname = surname
        self.courses = []

    def enroll(self, new_course):
        self.courses.append(new_course)

    def drop_course(self, course):
        self.courses.remove(course)

    def __str__(self):
        return f'{self.surname}, {self.name}\nCourses:\n{self.courses}'

Similar to the student, lets build a Faculty class to represent the instructors of the university. Like the students, they have a name and a surname, but unlike the student they have a position denoting if they are a professor or lecturer and a salary.

class Faculty:
    def __init__(self, name, surname, position, salary):
        self.name = name
        self.surname = surname
        self.position = position
        self.salary = salary
        self.courses = []
        
    def __str__(self):
        return f'{self.surname}, {self.name}\nCourses:\n{self.courses}'

Like a student, a faculty has a set of courses, so we need to have methods to assign and unassign courses from their teaching load.

class Faculty:
    def __init__(self, name, surname, position, salary):
        self.name = name
        self.surname = surname
        self.position = position
        self.salary = salary
        self.courses = []
        
    def assign_course(self, new_course):
        self.courses.append(new_course)
    
    def unassign_course(self, course):
        self.courses.remove(course)
        
    def __str__(self):
        return f'{self.surname}, {self.name}\nCourses:\n{self.courses}'

Having built both a Student class and a Faculty class, notice the similarities between the two. For variables, both classes have a name, a surname, and a set of courses. For methods, both classes have a similar __init__ method and a similar __str__ method. What if we want to add a new method to both classes? Consider a university ID number; most, if not all, universities generate id numbers for their students, faculty, and staff to avoid ambiguity that can arise from similar names.

If we want to add a new method to generate the id number of a given student or faculty, we have to add the method to both classes, which is duplicating the code in multiple places. This leads to more work for no tangible gain, not to mention, leads to multiple opportunities for mistakes to be made. We can use inheritance to combat these problems. We want to make a person class that contains the similarities of each class to act as their parent.

class Person:
    def __init__(self, name, surname):
        self.name = name
        self.surname = surname
        self.id = self.generate_id()
    
    def generate_id(self):
        id_hash = 0
        for s in self.name:
            id_hash += ord(s)
        for s in self.surname:
            id_hash *= ord(s)
        return id_hash % 1000000000
    
    def __str__(self):
        return f'{self.surname}, {self.name}\tID: {self.id}'

Now we can make the student class a child of the person class.

class Student(Person):
    def __init__(self, name, surname):
        self.courses = []
        super().__init__(name, surname)
    
    def __str__(self):
        return super().__str__() + f'\nCourses:\n{self.courses}'
        
    def enroll(self, new_course):
        self.courses.append(new_course)
        
    def drop_course(self, course):
        self.courses.remove(course)

In both the __init__ and __str__ methods, we are using super(), which references the parent class of student, in this case Person, and calls the __init__ and __str__ methods to help initialize the class. super().__init__(name,surname) tells python to call the __init__ method of the parent class to initialize the name and surname variables. Now any changes to Person’s __init__ will also update the Student’s __init__.

Check your understanding

Update the Faculty class to use the Person class as a parent.

Solution

class Faculty(Person):
    def __init__(self, name, surname, position, salary):
        self.position = position
        self.salary = salary
        self.courses = []
        super().__init__(name, surname)
 
    def __str__(self):
        return super().__str__() + f'\nCourses:\n{self.courses}'
 
    def assign_course(self, new_course):
        self.courses.append(new_course)
 
    def unassign_course(self, course):
        self.courses.remove(course)

We initially created the person class to simplify the method to generate ids, but that method is not present in either class. This is because both classes inherit the generate_id method from the Person class. Since they are not modifying the method, it does not need to appear within either child. However, we can test the method to make sure it is working.

student1 = Student("John", "Smith")
print(student1)

gives the output:

Smith, John	ID: 546320160
Courses:
[]

The generate_id method is called in the __init__ method of Person, so each Student and Faculty will autimatically generate their id upon initialization.

We can create further classes that inherit from person to cover different people at the university, such as Staff.

Composition and Aggregation

In addition to primitive types, variables in a class can be instances of different classes. This can often be useful to group relevant data together within a class, such as the molecule class in the Encapsulation lesson, or because a class needs to have ownership of other objects. There are two different forms that this can take, Composition and Aggregation. The main difference is the ownership of the object.

Consider the university example we have been using. A university has a large number of students and faculty, but they are not owned by the university. If the university closes, the students and faculty still exist, they just attend or work for a different university. A university is an aggregation of students and faculty. A university owns courses, if the university closes then the courses cease to exist. A university is composed of courses.

Interfaces and Abstract Classes

An interface is a way to define how a class will be designed without implementing any of the methods. An interface can be inherited by another interface, in which case it is usually extended into a larger interface by the child interface, or it can be inherited by a class, which implements at least all of the methods from the interface. An interface creates a structure for code to look for without defining how the methods are implemented.

Abstract Classes are interfaces that have implementations for one or more of their methods. The implementations within an abstract class can be used by their children or overridden to achieve new behavior.

In python, we construct interfaces and abstract classes in a similar way. We first need to import from the python library the abstract base class and abstract method decorator.

from abc import ABC, abstractmethod

ABC is the abstract base class. Inheriting from the abstract base class enforces that any child classes of the interface must have some implementation of any abstract methods.

class interface_sample(ABC):

Here we define the name of our interface. Note ABC in the class definition to denote the inheritance. Then we create a definition for each method we would like any of the interfaces children to implement.

    @abstractmethod
    def first_method(self):
        pass

    @abstractmethod
    def second_method(self):
        pass

Since we have used the @abstractmethod decorator for each of these methods, any child class must implement a method with the same name as the interface.

To convert this class from an Interface to an Abstract Class, we simply include one or more methods that are already implemented.

class abstract_class_sample(ABC):
    @abstractmethod
    def first_method(self):
        pass

    def second_method(self):
        print('Doing something in this method so it is implemented.')

Polymorphism

Polymorphism the concept of using different classes in place of one another. Specifically, an object is polymorphic if it can be used in place of one or more classes or interfaces. The intended use of a polymorphic object is to allow objects that are children of a parent class to be used as their parent class or for multiple objects that inherit from an interface to be used as the interface.

Static Methods

For further reading on Object Oriented Programming in Python, consider this tutorial or the Python3 documentation found here.

Key Points

  • Encapsulation

  • Inheritance