This lesson is being piloted (Beta version)

Object Oriented Programming with Python

Overview

Teaching: 0 min
Exercises: 0 min
Questions
  • Why Object-Oriented programming is crucial in Python?

  • Why you should create your own classes?

  • How to start OO with Python?

Objectives

The purpose of this episode is to give a short introduction on how to improve your Python code with Object-Oriented programming.

Is Python an Object-Oriented Programming language?

Different programming languages define object in different ways.

Objects are a representation of the real world objects like cars, cats, cups, etc.

Objects usually share two main characteristics:

For instance, cats have data like number of legs, number of ears and also have behavior: run, stop, meow and scratch, etc.

When using Object-oriented programming, we call data:

and behavior:

And the two are “grouped” together in what we call “objects”.

Class = functions + data (variables) in one unit

Good to know

Up to now, we have written Python code using a procedural programming approach i.e. the focus is on writing functions which operate on data. For instance, we have functions to read temperature anomalies CSV files and to plot them. In object-oriented programming the focus is on the creation of objects which contain both data and functionality together.

What is an object in Python?

In Python everything is an object:

In previous episodes, we have use type() to check the type of an object:

year=2019
print(type(year))
print(type(2.4))
print(type([]))
<class 'int'>
<class 'float'>
<class 'list'>

In Python, the “class” keyword is used to define new types of objects.

As a programmer you can create a new object type (similar to those you have already encountered - float, string, list, file, etc.) i.e. a new class. Once you have created a class you can create many objects of that type, just as you can have many int or float objects.

Python programming makes heavy use of classes therefore it is an important concept to understand.

What is a Class?

Classes are quite unlike the other data types, in that they are much more flexible.

Classes allow you to define the data and behavior that characterize anything you want to model in your program.

Class definition

For example, let’s consider the function that converts temperature from fahrenheit to degree celsius:

def fahr_to_celsius(temp):
    return ((temp - 32) * (5/9))

This function was defined independently from any variables, using a procedural programming approach. Let’s now define a class to hold temperatures in celsius. One of the first things you’d want to do is to save the temperature in variable and track the location i.e. the x and y coordinates of the measurement.

Here is what a simple Temperature class looks like in code:

class Temperature():
    '''This is a Temperature.'''
    
    def __init__(self, station = "Unknown", x=0, y=0, fahrenheit=32):
        # Each temperature has a value fahrenheit.
		self.fahrenheit = fahrenheit
		self.station = station
        self.x = x
        self.y = y

    def fahr_to_celsius(self):
        return ((self.fahrenheit - 32) * (5/9))

If the first line after the class header is a string, it becomes the docstring of the class, and will be recognized by various tools. (This is also the way docstrings work in functions.)

print (Temperature.__doc__)
This is a Temperature.

The ‘.’ is used to access class information (both attributes and methods).

We can also invoke help for the Temperature class:

help(Temperature)
Help on class Temperature in module __main__:

class Temperature(builtins.object)
 |  This is a Temperature.
 |  
 |  Methods defined here:
 |  
 |  __init__(self, station="unknown", x=0, y=0, fahrenheit=32)
 |      Initialize self.  See help(type(self)) for accurate signature.
 |  
 |  fahr_to_celsius(self)
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)

__init__

Every class should have a method with the special name __init__ (mind the double underscores at the beginning and end). The __init__() method sets the values for any parameters that need to be defined when an object is first created.

self

The self parameter is in all the Temperature methods. What is it?

The self parameter (we could choose any other name, but self is the convention) is automatically set to reference the newly created object that needs to be initialized. It’s a syntax that allows you to access a variable from anywhere else in the class.

How to instantiate a class in Python?

The Temperature class can now store some information, and it can do something (convert temperatures from fahrenheit to celsius). But this code has not actually created any variable of type Temperature yet.

Here is how you actually make a variable of type Temperature:

temp = Temperature()
print(temp.station, temp.x, temp.y, temp.celsius)
unknown 0 0 0.0

This initializer method init is automatically called whenever a new instance of Temperature is created. It gives you the opportunity to set up the attributes required (x,y,farenheit) within the new instance by giving them their initial state/values.

temp_oslo = Temperature("Oslo", 10.75, 59.9, 24.8)
print(temp.station, temp_oslo.x, temp_oslo.y, temp_oslo.celsius)
Oslo 10.75 59.9 -4.0

Like for functions, you have to set variables in the right order (x,y,farenheit) or you can use their dummy name to pass information:

temp_oslo = Temperature(fahrenheit=4.8, y=59.9, x=10.75, station="Oslo")
print(temp_oslo.x, temp_oslo.y, temp_oslo.celsius)
Oslo 10.75 59.9 -4.0

Be careful with __init__

After __init__ has finished, the caller can rightly assume that the object is ready to use. That is, after temp = Temperature(), we can start converting to celsius on temp; temp is a fully-initialized object.

Let’s define Temperature class slightly differently:

class Temperature():
   '''This is a Temperature.'''
    
    def __init__(self, station="unknown"):
        self.station = station

    def set_coords(self, x=0, y=0):
        self.x = x
        self.y = y

    def set_temperature(self, fahrenheit=32):
        # Each temperature has a value fahrenheit.
        self.fahrenheit = fahrenheit

    def fahr_to_celsius(self):
        return ((self.fahrenheit - 32) * (5/9))

This may look like a reasonable alternative; we simply need to call set_coords and set_temperature before we begin using the instance. There’s no way, however, to communicate this to the caller. Even if we document it extensively, we can’t force the caller to call temp.set_temperature() before calling temp.fahr_to_celsius(80.0). Since the temp instance doesn’t even have a temperature and coordinate attributes until temp.set_temperature and temp.set_coords are called, this means that the object hasn’t been “fully” initialized.

Recommendation

While you will be using classes in your Python code, avoid to introduce a new attribute outside of the __init__ method, otherwise you’ve given the caller an object that isn’t fully initialized. There are exceptions, of course, but it’s a good principle to keep in mind. This is part of a larger concept of object consistency: there shouldn’t be any series of method calls that can result in the object entering a state that doesn’t make sense.

Attributes and Methods

Instance methods

The method fahr_to_celsius is called an instance method.

These are the most common type of methods in Python classes. They can only access data of their instance. If you have two objects each created from a Temperature class, then they each may have different properties. They may have different station name, coordinates, temperature values.

Instance methods must have self as a parameter and you can use self to access any data or methods that may reside in your class. You won’t be able to access them without going through self.

To remember

Don’t forget that the name self can be replaced by any other variable name. By convention, we usually use self (coding norm).

Any method you create will automatically be created as an instance method, unless you tell Python otherwise.

Static methods

Class attributes are attributes that are set at the class-level, as opposed to the instance-level. Normal attributes are introduced in the __init__ method, but some attributes of a class hold for all instances in all cases. For example, consider the following definition of a Temperature object:

class Temperature(object):
	'''This is a Temperature.'''
    T0 = 32.0
    
    def __init__(self, station = "Unknown", x=0, y=0, fahrenheit=32):
        # Each temperature has a value celsius.
		self.fahrenheit = fahrenheit
		self.station = station
		self.x = x
		self.y = y

    def fahr_to_celsius(self):
        return ((self.fahrenheit - self.T0) * (5/9))

temp_oslo = Temperature(fahrenheit=4.8, y=59.9, x=10.75, station="Oslo")
print(temp_oslo.T0)
print(Temperature.T0)
32.0
32.0

A Temperature object always has T0 (the melting point of ice) equals to 32 °F, regardless of the measurement station and location. Instance methods can access these attributes in the same way they access regular attributes: through self (i.e. self.T0).

There is a class of methods, though, called static methods, that don’t have access to self. Just like class attributes, they are methods that work without requiring an instance to be present. Since instances are always referenced through self, static methods have no self parameter.

The following would be a valid static method on the Temperature class:

class Temperature(object):
	'''This is a Temperature.'''
	T0 = 32.0
	...
	def print_info():
		print('Static method for Temperature class')

Temperature.print_info()
Static method for Temperature class

As you can see, we do not need to instantiate an object to use print_info method.

To make it clear that this method should not receive the instance as the first parameter (i.e. self on “normal” methods), the @staticmethod decorator is used, turning our definition into:

class Temperature(object):
	'''This is a Temperature.'''
	T0 = 32.0
	...
	@staticmethod
	def print_info():
		print('Static method for Temperature class')

Temperature.print_info()
Static method for Temperature class

Class Methods

A variant of the static method is the class method. Instead of receiving the instance as the first parameter, the class is passed:

class Observation(object):
	levels = 1
	def __init__(self, station = "Unknown", x=0, y=0, , fahrenheit):
		self.station = station
		self.x = x
		self.y = y
		self.fahrenheit = fahrenheit
		
    ...
	
	@classmethod
	def is_surface(cls):
		return cls.levels == 1

By convention, we use cls for class methods.

Decorators in Python

Python has an interesting feature called decorators to add functionality to an existing code.

This is also called metaprogramming mostly because the program tries to modify another part of the program at compile time.

A decorator takes in a function, adds some functionality and returns it. So we can use a decorator to give a function a new behavior without changing the function itself.

Let’s illustrate it with an example:

def myDecorator(behavior):
    def new_behavior(station, x, y, fahrenheit):
        return 'Station: ' + behavior(station, x, y, fahrenheit)        
    return new_behavior

@myDecorator
def get_info(station, x, y, fahrenheit):
    return(station + ' (' + str(x) + ' ' + str(y) + ' ' + str(fahrenheit)) + ')'

# call the decorated function
print(get_info(fahrenheit=99.0, y=59.9, x=10.75, station="Oslo"))

Class methods may not make much sense right now, but that’s because they’re used most often in connection with our next topic: inheritance.

Inheritance

Using classes to group attributes and methods can already make your Python codes easier to understand but the strength of using Object-oriented programming resides on using “inheritance”.

Inheritance is a way to form new classes using classes that have already been defined. The newly formed classes are called derived classes, the classes that we derive from are called base classes.

The derived classes (descendants) override or extend the functionality of base classes (ancestors).

Let’s take an example:

import numpy

class SingleObs(object):
    '''This is a single point observation.'''
    levels=1
    def __init__(self, station = "Unknown", x=0, y=0, value = numpy.nan):
        # Each observation has a value .
        self.value = value
        self.station = station
        self.x = x
        self.y = y

    def printobs(self):
        print("Single point observation")
        print("Station: ", self.station, '(x=',self.x, ', y=', self.y, ', val=', self.value , ')')
        
class TS(SingleObs):
    '''This is a class to hold surface temperature.'''
    T0=32.0

    def printobs(self):
        print("Surface Temperature")
        print("Station: ", self.station, '(x=',self.x, ', y=', self.y, ', val=', self.value , ')')
		
    def fahr_to_celsius(self):
        return ((self.value - self.T0) * (5/9))

In this example, we have two classes: SingleObs and TS. The SingleObs is the base class, the TS class is the derived class.

The derived class inherits the functionality of the base class.

It is shown by the __init__ method.

The derived class modifies existing behavior of the base class as shown by the printobs() method.

Finally, the derived class extends the functionality of the base class, by defining a new fahr_to_celsius() method.

Polymorphism

In Python, polymorphism refers to the way in which different object classes can share the same method name, and those methods can be called from the same place even though a variety of different objects might be passed in.

This is what we do when calling printobs method for SingleObs or TS objects:

obs_oslo = SingleObs(y=59.9, x=10.75, station="Oslo")
print(obs_oslo.printobs())
temp_oslo = TS(value=99, y=59.9, x=10.75, station="Oslo")
print(temp_oslo.printobs())
Single point observation
Station:  Oslo (x= 10.75 , y= 59.9 , val= nan )
None
Surface Temperature
Station:  Oslo (x= 10.75 , y= 59.9 , val= 99 )
None

Here we have a SingleObs class and a TS class, and each has a .printobs() method. When called, each object’s .printobs() method returns a result unique to the object.

Inheritance and the Liskov Substitution Principle (LSP)

Abstraction is the key to understanding inheritance. We’ve seen how one side-effect of using inheritance is that we reduce duplicated code, but what about from the caller’s perspective. How does using inheritance change that code?

There a few different ways to demonstrate polymorphism. First, with a for loop:

for obs in [obs_oslo,temp_oslo]:
    print(obs.printobs())
Single point observation
Station:  Oslo (x= 10.75 , y= 59.9 , val= nan )
None
Surface Temperature
Station:  Oslo (x= 10.75 , y= 59.9 , val= 99 )
None

or using functions:

def printobs(obs):
    print(obs.printobs())

printobs(obs_oslo)
printobs(temp_oslo)
Single point observation
Station:  Oslo (x= 10.75 , y= 59.9 , val= nan )
None
Surface Temperature
Station:  Oslo (x= 10.75 , y= 59.9 , val= 99 )
None

In both cases we were able to pass in different object types, and we obtained object-specific results from the same mechanism.

Abstract Classes

A more common practice is to use abstract classes and inheritance. An abstract class is one that never expects to be instantiated. For example, we could decide to never have an SingleObs object, only TS. We could also define new classes for holding different parameters. For instance U (zonal wind) objects.

class SingleObs(object):
    '''This is a single point observation.'''
    levels=1
    def __init__(self, station = "Unknown", x=0, y=0, value = numpy.nan):
        # Each observation has a value .
        self.value = value
        self.station = station
        self.x = x
        self.y = y

    def printobs(self):   # Abstract method, defined by convention only
        raise NotImplementedError("Subclass must implement abstract method")

class TS(SingleObs):
    '''This is a class to hold surface temperature.'''
    T0=32.0

    def printobs(self):
        print("Surface Temperature")
        print("Station: ", self.station, '(x=',self.x, ', y=', self.y, ', val=', self.value , 'F)')
    
    def fahr_to_celsius(self):
        return ((self.value - self.T0) * (5/9))
    

class U(SingleObs):
    '''This is a class to hold zonal wind.'''

    def printobs(self):
        print("Zonal Wind")
        print("Station: ", self.station, '(x=',self.x, ', y=', self.y, ', val=', self.value , 'm/s)')

TS = TS(value=99, y=59.9, x=10.75, station="Oslo")
U = U(value=3.2, y=59.9, x=10.75, station="Oslo")

print(TS.printobs())
print(U.printobs())

Real life examples of polymorphism include:

Special Methods

Finally let’s go over special methods. Classes in Python can implement certain operations with special method names. These methods are not actually called directly but by Python specific language syntax. For example let’s take our Temperature class:

class Temperature(object):
    '''This is a Temperature.'''
    T0 = 32.0
    levels = 1
    def __init__(self, station = "Unknown", x=0, y=0, fahrenheit=32):
        # Each temperature has a value celsius.
        self.fahrenheit = fahrenheit
        self.station = station
        self.x = x
        self.y = y

    def fahr_to_celsius(self):
        return ((self.fahrenheit - self.T0) * (5/9))

    def __str__(self):
        return "Station: %s" %(self.station)

    def __len__(self):
        return self.levels

    def __del__(self):
        print("A Temperature object is destroyed")


temp_oslo = Temperature(fahrenheit=99, y=59.9, x=10.75, station="Oslo")

#Special Methods
print(temp_oslo)
print(len(temp_oslo))
del temp_oslo
Station: Oslo
1
A Temperature object is destroyed

The __init__(), __str__(), __len__() and __del__() methods.

These special methods are defined by their use of underscores. They allow us to use Python specific functions on objects created through our class.

Benefits of inheritance

  • code reuse
  • reduction of complexity of a program.

References

To build this episode, we have use the following material:

Key Points