In [1]:
name = '2017-11-13-building-classes'
title = 'Building classes'
tags = 'basics, oop'
author = 'Denis Sergeev'

In [2]:
from nb_tools import connect_notebook_to_post
from IPython.core.display import HTML

html = connect_notebook_to_post(name, title, tags, author)

First, let's refresh the terminology of object-oriented programming.

class

Tell Python to make a new type of thing.

object

Two meanings: the most basic type of thing, and any instance of some thing.

instance

What you get when you tell Python to create a class.

def

How you define a method of a class.

self

Inside the methods in a class, self is a variable for the instance/object being accessed.

inheritance

The concept that one class can inherit traits from another class, much like you and your parents.

attribute

A property that classes have that are from composition and are usually variables.

is-a

A phrase to say that something inherits from another, as in a "salmon" is-a "fish."

What am I really missing out on by not writing classes?

1. More convenient collections of fields

You might end up with lots of lists or dicts that share the same keys to access different kinds of data associated with a single logical (geographical) object:

obs_temperature[42] = 20  # Temperature at the observational point No. 23
obs_humidity[42] = 75  # Humidity at the observational point No. 23
obs_name[42] = 'research_vessel'  # Name of the obs. point No. 23

By switching to classes you could have a single list of objects, each of which has several named fields on it to address the associated data:

# Everyting at the point No. 42
obs[42].temperature = 20
obs[42].humidity = 75
obs[42].name = 'research_vessel'

Now you can keep all of the fields under one roof, which makes accessing and passing these objects around much more convenient.

It's also easier to pass around big tuples of stuff from function to function.

Note: we will have a look at Python's namedtuples in a future session.

2. The ability to chain objects together and let them interact in an expressive way

The OOP version:

if not garage.is_full:
    garage.add(my_car)
    my_car.turn_off()
    garage.close()

vs the non-OOP / procedural version:

if not is_garage_full(garage):
    add_car_to_garage(my_car, garage)
    turn_off_car(my_car)
    close_garage(garage)

3. Code written using OOP is less prone to error.

  • OOP enables you to mostly eliminate lengthy argument lists
  • It is much more difficult for a function to accidentally process data it should not process.

4. Programs written using OOP are more easily extended

  • New cases are easily added by creating new classes that have the interface methods defined for them
  • Functionality is also easily added by just adding new methods/attributes
  • Any changes to class definitions automatically propagate to all instances of the class.

5. Domain-specific models

E.g. GUI applications

6. Custom Exceptions


In [3]:
class MyDescriptiveError(Exception):
    pass

Special methods (aka Magic methods)

  • A class can implement certain operations that are invoked by special syntax (such as arithmetic operations or subscripting and slicing) by defining methods with special names. This is Python's approach to operator overloading, allowing classes to define their own behavior with respect to language operators.
  • For instance, if a class defines a method named __getitem__(), and x is an instance of this class, then x[i] is roughly equivalent to type(x).__getitem__(x, i).
  • Except where mentioned, attempts to execute an operation raise an exception when no appropriate method is defined (typically AttributeError or TypeError).

Note: dunder-methods vs private attributes

Let's build a class!

Exercise 1: create a custom class with __init__ method

  • the class will describe an observational point, e.g. a weather station
  • the class constructor should take temperature and pressure as input arguments
  • example of usage:
    >>> OP = ObsPoint(temperature=20, pressure=1013)
    >>> OP.temperature
    20
    

In [4]:
class ObsPoint:
    """
    Observational Point
    
    Attributes
    ----------
    temperature: float
        Air temperature (K)
    pressure: float
        Air pressure (Pa)
    """
    def __init__(self, temperature, pressure):
        """
        """
        self.temperature = temperature
        self.pressure = pressure

In [5]:
OP = ObsPoint(temperature=20, pressure=1013)

How can this class be useful?

  • Inheritance!
  • Can be used as a base class for a range of different observing platforms
  • For example:

    class WeatherBuoy(ObsPoint):
      # ... other code ...
    
      def calculate_wave_height(self):
          # ...
    

Exercise 2: add a method to calculate atmospheric density


In [6]:
class ObsPoint:
    def __init__(self, temperature, pressure):
        """
        """
        self.temperature = temperature
        self.pressure = pressure
  • Use ideal gas law equation $p = \rho R T$
  • Name it calc_density or calc_rho
  • Make specific gas constant $R$ an optional argument with the default value being $R=287~J~kg^{-1}~K^{-1}$
  • Initialise a new instance of ObsPoint with $T=25^{\circ}C$ and $p=1020~hPa$. CHECK THE UNITS!
  • Calculate the density using the new method and check your answer.

In [7]:
class ObsPoint:
    def __init__(self, temperature, pressure):
        self.temperature = temperature
        self.pressure = pressure
        
#         self.density = self.pressure / (self.temperature * Rd)
    def calc_density(self, Rd=287):
        return self.pressure / (self.temperature * Rd)

In [8]:
OP = ObsPoint(pressure=102000, temperature=298)

Result:


In [9]:
OP.calc_density


Out[9]:
<bound method ObsPoint.calc_density of <__main__.ObsPoint object at 0x7fefd4274710>>

In [10]:
OP.calc_density()


Out[10]:
1.1926197881346023

A possible improvement: store the result as an attribute


In [11]:
class ObsPoint:
    def __init__(self, temperature, pressure):
        self.temperature = temperature
        self.pressure = pressure
        
    def calc_density(self, Rd=287):
        self.density = self.pressure / (self.temperature * Rd)  # store the result as an attribute
        return self.density

In [12]:
OP = ObsPoint(pressure=102000, temperature=298)

In [13]:
d = OP.calc_density()

In [14]:
OP.density


Out[14]:
1.1926197881346023

Exercise 3: Add a title/name/location attribute to ObsPoint and change object representation


In [15]:
print(OP)


<__main__.ObsPoint object at 0x7fefd42b9d68>

__str__ vs __repr__

  • You can control to-string conversion in your own classes using the __str__ and __repr__ "dunder" methods. Writing your own Java-esque "tostring" methods is considered unpythonic.
  • The result of the __str__ method should be readable. The result of __repr__ should be unambiguous.
  • You should always add a __repr__ to your classes. The default implementation for __str__ just calls __repr__ internally, so by implementing repr support you'll get the biggest benefit.

In [16]:
class ObsPoint:
    def __init__(self, temperature, pressure, title='Unknown Observational Point'):
        self.temperature = temperature
        self.pressure = pressure
        self.title = title
        
    def calc_density(self, Rd=287):
        self.density = self.pressure / (self.temperature * Rd)  # store the result as an attribute
        return self.density
    
    def __str__(self):
        return '{self.title}\nwith:\ntemperature = {self.temperature:4.2f} K\npressure = {self.pressure:4.1f} Pa'.format(self=self)
    
    def __repr__(self):
        return 'ObsPoint(temperature={self.temperature!r}, pressure={self.pressure!r})'.format(self=self)

In [17]:
OP = ObsPoint(pressure=102000, temperature=298)

In [18]:
print(OP)


Unknown Observational Point
with:
temperature = 298.00 K
pressure = 102000.0 Pa

In [19]:
repr(OP)


Out[19]:
'ObsPoint(temperature=298, pressure=102000)'

Or if you're lazy, at least add __repr__:


In [20]:
class ObsStation:
    def __init__(self, temperature, pressure, title='Unknown Observational Point'):
        self.temperature = temperature
        self.pressure = pressure
        self.title = title
        
    def calc_density(self, Rd=287):
        self.density = self.pressure / (self.temperature * Rd)  # store the result as an attribute
        return self.density
    
    def __repr__(self):
        # __str__ falls back to __repr__
        return '{self.__class__.__name__}(title={self.title!r}, temperature={self.temperature!r}, pressure={self.pressure!r})'.format(self=self)

In [21]:
OP = ObsStation(title='UEA automatic weather station', pressure=102000, temperature=298)

In [22]:
print(OP)


ObsStation(title='UEA automatic weather station', temperature=298, pressure=102000)

In [23]:
OP.__class__.__name__


Out[23]:
'ObsStation'

Useful trick to avoid repetition

  • use the object's __class__.__name__ attribute, which will always reflect the class' name as a string.
def __repr__(self):
    return (f'{self.__class__.__name__}('
            f'{self.pressure!r}, {self.temperature!r})')

Exercise 4: make the class callable

  • Use __call__ method

In [24]:
class ObsPoint:
    def __init__(self, temperature, pressure, title='Unknown Observational Point'):
        self.temperature = temperature
        self.pressure = pressure
        self.title = title
        
    def calc_density(self, Rd=287):
        self.density = self.pressure / (self.temperature * Rd)  # store the result as an attribute
        return self.density
    
    def __repr__(self):
        # __str__ falls back to __repr__
        return 'ObsPoint(title={self.title!r}, temperature={self.temperature!r}, pressure={self.pressure!r})'.format(self=self)
    
    def __call__(self, value):
        """ Print summary """
        self.some_value = value
        print('Very-very-very long summary'*10)

In [25]:
OP = ObsPoint(pressure=102000, temperature=298)

In [26]:
OP


Out[26]:
ObsPoint(title='Unknown Observational Point', temperature=298, pressure=102000)

Now call this instance:


In [27]:
OP(123)


Very-very-very long summaryVery-very-very long summaryVery-very-very long summaryVery-very-very long summaryVery-very-very long summaryVery-very-very long summaryVery-very-very long summaryVery-very-very long summaryVery-very-very long summaryVery-very-very long summary

In [28]:
HTML(html)


Out[28]:

This post was written as an IPython (Jupyter) notebook. You can view or download it using nbviewer.