Go to top, next, previous, or johnstachurski.net

Object-Oriented Programming in Python

The subject is large and complex

Take a deep breath, because this lecture is not easy

At first glance the OOP techniques look complicated

But once you get used to it, usage is pretty simple

I talk a lot about how things work

Overview

Python supports both procedural and object-oriented programming (OOP)

Any task can be accomplished using the older procedural style

But OOP has become a central part of modern program design because

Fits well with mathematics because it encourages abstraction

Key Concepts

The procedural programming paradigm: based around functions (procedures)

The OOP paradigm: data and functions bundled together into abstract data types (ADTs)

Example

A class definition is a blueprint for an ADT

An object or instance is one realization of the ADT

In Python

Examples

Let's try to clarify this with some examples

Example 1: Aquarium

Suppose we are developing an aquarium screensaver

Contains

All of these can be represented in the program as objects

Class definitions specify what data and behavior (methods) they will have

Here's the psuedocode for a some class definitions

class Fish:

    data:
        size
        color
        location

    methods:
        swim_forwards
        turn_left
        turn_right
        eat_fish_food
class Crab:

    data:
        size
        color
        location

    methods:
        crawl_left
        crawl_right
        move_claws

When the program starts, we instantiate (create instances of) fish, crabs, etc. from these definitions

Note that different instances can have different data

Here's the psuedocode for instantiation:

crab1 = Crab(small, orange, bottom_left)  # Create a Crab, passing instance data
fish1 = Fish(big, blue, middle)           # Create a Fish, passing instance data
fish2 = Fish(small, yellow, top_right)    # Create a Fish, passing instance data

Now the main loop calls the methods of the objects to make them swim, crawl, eat, etc.

fish1.swim_forwards()
fish2.eat_fish_food()
crab1.move_claws()

Note that these methods may affect the internal data of the object

Example 2: Built-in ADTs

We have met several built-in types

Let's look at the case of lists

Given X = [1, 2], interpreter creates an instance of a list

We know some methods that can act on this data

In fact X[i] is a method call: equivalent to X.__getitem__(i)

>>> X = ['a', 'b']
>>> X[0]
'a'
>>> X.__getitem__(0)
'a'

The attributes of X are things that can be accessed via X.attribute_name

The dir() function returns the attribute names as a list:

>>> dir(X)
['__add__', '__class__', '__doc__', ..., 'pop', 'remove', 'reverse', 'sort']

User-Defined ADTs

With Python, it is simple to build our own ADTs

Example: Dice

Let's build a class to represent dice

Data

Method(s)

Psuedocode:

class Dice:

    data:
        dots -- the side facing up (i.e., number of dots showing)

    methods:
        roll -- roll the dice (i.e., change dots)

Here's the Python code, in file dice.py

import random

class Dice:

    def __init__(self, dots):
        self.dots = dots 

    def roll(self):
        self.dots = random.choice((1, 2, 3, 4, 5, 6)) 

The use of self is explained below

The class has two methods: __init__ and roll

The __init__ method is a constructor

The roll method rolls the dice, changing the state (data) of a particular instance

Class Objects

Let's run it:

john@c246:~$ python -i dice.py 
>>> Dice
<class '__main__.Dice'>

At this stage, the class definition has been compiled into a class object

In particular, it has a namespace Dice.__dict__ where some names are bound

>>> print Dice.__dict__
{'__module__': '__main__',..., 'roll': <function roll at 0xb7cf4a3c>, '__init__': <function __init__ at 0xb7cf47d4>}
>>> Dice.__init__   # This is the constructor method that we defined
<unbound method Dice.__init__>
>>> Dice.roll       # And the roll method that we defined
<unbound method Dice.roll>

Instance Objects

Now let's create an instance of the class

>>> d1 = Dice(6)   # Create an instance, passing 6 to the instance variable dots
>>> d1
<__main__.Dice object at 0xb7da348c>
>>> d1.dots        # Accessing the instance data
6
>>> d1.__dict__    # Let's have a look at the namespace of d1
{'dots': 6}

Now let's roll the dice:

>>> d1.roll()
>>> d1.dots
3

The instance variable dots was changed by the call to roll()

Let's create some more instances

>>> d2 = Dice(3)
>>> d3 = Dice(1)
>>> d2.dots
3
>>> d3.dots
1
>>> d3.roll()
>>> d3.dots
4

All instance we create have their own namespace for instance data

How does it work?

Here's the code for roll()

    def roll(self):
        self.dots = random.choice((1, 2, 3, 4, 5, 6)) 

In fact, the call d1.roll() is equivalent to (an abbreviation for) the call Dice.roll(d1)

Therefore, when roll() executes, self is bound to d1

In this way, self.dots = random.choice((1, 2, 3, 4, 5, 6)) affects d1.dots, which is what we want

The formal rules for using self are (don't worry if you can't remember them all now):

Example: The Quadratic Map

The quadratic map difference equation is given by



Let's write a class for generating time series

class QuadMap:

    def __init__(self, initial_state):
        self.x = initial_state

    def update(self):
        "Apply the quadratic map to update the state."
        self.x = 4 * self.x * (1 - self.x)

    def generate_series(self, n): 
        """
        Generate and return a trajectory of length n, starting at the 
        current state.
        """
        trajectory = []
        for i in range(n):
            trajectory.append(self.x)
            self.update()
        return trajectory

Notice the call self.update()

Here's an example of usage:

>>> q = QuadMap(0.2)
>>> q.x
0.20000000000000001
>>> q.update()  # Equivalent to QuadMap.update(q)
>>> q.x
0.64000000000000012
>>> q.generate_series(5)  # The *second* parameter (i.e., n) in the method definition is bound to 5
[0.64000000000000012, 0.92159999999999986, 0.28901376000000045, 0.82193922612265036, 0.58542053873419597]
>>> q.x = 0.4  # Reset the state to 0.4
>>> q.generate_series(5)
[0.40000000000000002, 0.95999999999999996, 0.15360000000000013, 0.52002816000000029, 0.9983954912280576]

Example: A Class for Polynomials

Let's build a simple class to represent and manipulate polynomial functions.

Data is the coefficients, which define a unique polynomial



Two methods

## Filename: polyclass.py
## Author: John Stachurski


class Polynomial:

    def __init__(self, coefficients):
        """
        Creates an instance of the Polynomial class representing 
        p(x) = a_0 x^0 + ... + a_N x^N, where a_i = coefficients[i].
        """
        self.coefficients = coefficients

    def evaluate(self, x):
        y = 0
        for i, a in enumerate(self.coefficients):
            y += a * x**i  
        return y

    def differentiate(self):
        new_coefficients = []
        for i, a in enumerate(self.coefficients):
            new_coefficients.append(i * a)
        # Remove the first element, which is zero
        del new_coefficients[0]  
        # And reset coefficients data to new values
        self.coefficients = new_coefficients

Here's an example of usage

>>> from polyclass import Polynomial
>>> data = [2, 1, 3]
>>> p = Polynomial(data)  # create instance of Polynomial
>>> p.evaluate(1)         
6
>>> p.coefficients        
[2, 1, 3]
>>> p.differentiate()     # Modifies coefficients of p
>>> p.coefficients        
[1, 6]
>>> p.evaluate(1)         
7

Note: if we replace evaluate with __call__ then

>>> p.__call__(1) == p(1)

This is an example of a special method

Exercise

Recall the empirical distribution function



The fraction of the sample which falls below x

Glivenko--Cantelli Theorem: converges to the true distribution function F

Implement as a class, where

Example of usage

>>> from random import uniform
>>> samples = [uniform(0, 1) for i in range(10)]
>>> F = ecdf(samples)
>>> F(0.5)  
>>> 0.29
>>> F.observations = [uniform(0, 1) for i in range(1000)]
>>> F(0.5)  
>>> 0.479

Solution

## Filename: ecdf.py
## Author: John Stachurski

class ECDF:

    def __init__(self, observations):
        self.observations = observations

    def __call__(self, x):
        counter = 0.0
        for obs in self.observations:
            if obs <= x:
                counter += 1
        return counter / len(self.observations)