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
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
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
object.data or object.method_name()
{'data': 42}
Let's try to clarify this with some examples
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
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
[1, 2] in memory
X is bound to this object
We know some methods that can act on this data
X.reverse() changes the data to [2, 1]
X.append('foo') changes it to [2, 1, 'foo']
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']
With Python, it is simple to build our own ADTs
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
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>
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)
roll() defined in class object Dice with instance d1 as the argument
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):
self as the first argumentdef roll(self), etc.
self prefixself.dots = random.choice((1, 2, 3, 4, 5, 6)), etc.
self is passed implicitlyd1.roll(), not d1.roll(d1)
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()
self.update(self), because self is passed implicitly
update() self that was passed in
to generate_series() as a parameter
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]
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
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
__call__ method evaluates Fn(x) for any x
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
## 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)