Object Oriented Programming with Python
Overview
Teaching: 0 min
Exercises: 0 minQuestions
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:
- data
- behavior
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:
- attributes
and behavior:
- methods
And the two are “grouped” together in what we call “objects”.
Class = functions + data (variables) in one unitGood 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:
- Strings are objects,
- Lists are objects,
- Functions are objects
- Even modules are objects!
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 useself
(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:
- opening different file types: different tools may be needed to display netCDF, HDF, geoTIFF files
- adding different objects: the + operator performs arithmetic and concatenation
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:
- Everything I know about Python… by Jeff Knupp
Key Points