../_images/book_cover.jpg

This notebook contains an excerpt from the Python Programming and Numerical Methods - A Guide for Engineers and Scientists, the content is also available at Berkeley Python Numerical Methods.

The copyright of the book belongs to Elsevier. We also have this interactive book online for a better learning experience. The code is released under the MIT license. If you find this content useful, please consider supporting the work on Elsevier or Amazon!

< 7.2 Class and Object | Contents | 7.4 Summary and Problems >

Inheritance, Encapsulation and Polymorphism

We have already seen the modeling power of OOP using the class and object functions by combining data and methods. There are three more important concept, inheritance, which makes the OOP code more modular, easier to reuse and build a relationship between classes. Encapsulation can hide some of the private details of a class from other objects, while polymorphism can allow us to use a common operation in different ways. In this section, we will briefly discuss them.

Inheritance

Inheritance allows us to define a class that inherits all the methods and attributes from another class. Convention denotes the new class as child class, and the one that it inherits from is called parent class or superclass. If we refer back to the definition of class structure, we can see the structure for basic inheritance is class ClassName(superclass), which means the new class can access all the attributes and methods from the superclass. Inheritance builds a relationship between the child class and parent class, usually in a way that the parent class is a general type while the child class is a specific type. Let us try to see an example.

TRY IT! Define a class named Sensor with attributes name, location, and record_date that pass from the creation of an object and an attribute data as an empty dictionary to store data. Create one method add_data with t and data as input parameters to take in timestamp and data arrays. Within this method, assign t and data to the data attribute with ‘time’ and ‘data’ as the keys. In addition, it should have one clear_data method to delete the data.

class Sensor():
    def __init__(self, name, location, record_date):
        self.name = name
        self.location = location
        self.record_date = record_date
        self.data = {}
        
    def add_data(self, t, data):
        self.data['time'] = t
        self.data['data'] = data
        print(f'We have {len(data)} points saved')        
        
    def clear_data(self):
        self.data = {}
        print('Data cleared!')

Now we have a class to store general sensor information, we can create a sensor object to store some data.

EXAMPLE: Create a sensor object.

import numpy as np

sensor1 = Sensor('sensor1', 'Berkeley', '2019-01-01')
data = np.random.randint(-10, 10, 10)
sensor1.add_data(np.arange(10), data)
sensor1.data
We have 10 points saved
{'time': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
 'data': array([-4, -7,  2, -3, -8,  6,  4,  3,  5, -9])}

Inherit and extend new method

Say we have one different type of sensor: an accelerometer. It shares the same attributes and methods as Sensor class, but it also has different attributes or methods need to be appended or modified from the original class. What should we do? Do we create a different class from scratch? This is where inheritance can be used to make life easier. This new class will inherit from the Sensor class with all the attributes and methods. We can whether we want to extend the attributes or methods. Let us first create this new class, Accelerometer, and add a new method, show_type, to report what kind of sensor it is.

class Accelerometer(Sensor):
    
    def show_type(self):
        print('I am an accelerometer!')
        
acc = Accelerometer('acc1', 'Oakland', '2019-02-01')
acc.show_type()
data = np.random.randint(-10, 10, 10)
acc.add_data(np.arange(10), data)
acc.data
I am an accelerometer!
We have 10 points saved
{'time': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
 'data': array([ -2,   2, -10,   6,   2,  -8,   2,   3,   7,  -6])}

Creating this new Accelerometer class is very simple. We inherit from Sensor (denoted as a superclass), and the new class actually contains all the attributes and methods from the superclass. We then add a new method, show_type, which does not exist in the Sensor class, but we can successfully extend the child class by adding the new method. This shows the power of inheritance: we have reused most part of the Sensor class in a new class, and extended the functionality. Besides, the inheritance sets up a logical relationship for the modeling of the real-world entities : the Sensor class as the parent class is more general and passes all the characteristics to the child class Accelerometer.

Inherit and method overriding

When we inherit from a parent class, we can change the implementation of a method provided by the parent class, this is called method overriding. Let us see the following example.

EXAMPLE: Create a class UCBAcc (a specific type of accelerometer that created at UC Berkeley) that inherits from Accelerometer but replace the show_type method that prints out the name of the sensor.

class UCBAcc(Accelerometer):
    
    def show_type(self):
        print(f'I am {self.name}, created at UC Berkeley!')
        
acc_ucb = UCBAcc('UCBAcc', 'Berkeley', '2019-03-01')
acc_ucb.show_type()
I am UCBAcc, created at UC Berkeley!

We see that, our new UCBAcc class actually overrides the method show_type with new features. In this example, we are not only inheriting features from our parent class, but we are also modifying/improving some methods.

Inherit and update attributes with super

Let us create a class NewSensor that inherits from Sensor class, but with updated the attributes by adding a new attribute brand. Of course, we can re-define the whole __init__ method as shown below and overriding the parent function.

class NewSensor(Sensor):
    def __init__(self, name, location, record_date, brand):
        self.name = name
        self.location = location
        self.record_date = record_date
        self.brand = brand
        self.data = {}
        
new_sensor = NewSensor('OK', 'SF', '2019-03-01', 'XYZ')
new_sensor.brand
'XYZ'

However, there is a better way to achieve the same. We can use the super method to avoid referring to the parent class explicitly. Let us see how to perform this in the following example:

EXAMPLE: Redefine the attributes in inheritance.

class NewSensor(Sensor):
    def __init__(self, name, location, record_date, brand):
        super().__init__(name, location, record_date)
        self.brand = brand
        
new_sensor = NewSensor('OK', 'SF', '2019-03-01', 'XYZ')
new_sensor.brand
'XYZ'

Now we can see with the super method, we avoid to list all the definition of the attributes, this helps keep your code maintainable for the foreseeable future. But it really useful when you are doing multiple inheritance, which is beyond the discussion of this book.

Encapsulation

Encapsulation is one of the fundamental concepts in OOP. It describes the idea of restricting access to methods and attributes in a class. This will hide the complex details from the users, and prevent data being modified by accident. In Python, this is achieved by using private methods or attributes using underscore as prefix, i.e. single “_” or double “__”. Let us see the following example.

EXAMPLE:

class Sensor():
    def __init__(self, name, location):
        self.name = name
        self._location = location
        self.__version = '1.0'
    
    # a getter function
    def get_version(self):
        print(f'The sensor version is {self.__version}')
    
    # a setter function
    def set_version(self, version):
        self.__version = version
sensor1 = Sensor('Acc', 'Berkeley')
print(sensor1.name)
print(sensor1._location)
print(sensor1.__version)
Acc
Berkeley
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-8-ca9b481690ba> in <module>
      2 print(sensor1.name)
      3 print(sensor1._location)
----> 4 print(sensor1.__version)

AttributeError: 'Sensor' object has no attribute '__version'

The above example shows how the encapsulation works. With single underscore, we defined a private variable, and it should not be accessed directly. But this is just convention, nothing stops you from doing that. You can still get access to it if you want to. With double underscore, we can see that the attribute __version can not be accessed or modify it directly. Therefore, to get access to the double underscore attributes, we need to use getter and setter function to access it internally, as shown in the following example.

sensor1.get_version()
The sensor version is 1.0
sensor1.set_version('2.0')
sensor1.get_version()
The sensor version is 2.0

The single and double underscore also apply to private methods as well, we will not discuss these as they are similar to the private attributes.

Polymorphism

Polymorphism is another fundamental concept in OOP, which means multiple forms. Polymorphism allows us to use a single interface with different underlying forms such as data types or classes. For example, we can have commonly named methods across classes or child classes. We have already seen one example above, when we override the method show_type in the UCBAcc. For parent class Accelerometer and child class UCBAcc, they both have a method named show_type, but they have different implementation. This ability of using single name with many forms acting differently in different situations greatly reduces our complexities. We will not expand to discuss more of Polymorphism, if you are interested, check more online to get a deeper understanding.

< 7.2 Class and Object | Contents | 7.4 Summary and Problems >