from IPython.core.display import HTML

HTML(open("custom.html", "r").read())

Optinal script

Introduction to objects and classes

We already used some object in Python: every data type in Python is an so called "object". In object oriented programming the objects have attributes and methods, we have mainly seen as methods. Examples:

  • upper is a method of the string object "visa". ("visa".upper())
  • append is a method of the object [1, 2, 3].
  • write is a method on the file handle object created by open(...)
  • closed is an attribute on such a file handle:
fh = open("/dev/zero", "r")
fh.closed
False

About classes

A class is like a template describing how an object is created and how the attached methods are implemented. One can also imagine a class being the type of an object.

Here we define our very first class:

class Greeter:
    
    def greet(self, who):
        print("hi %s !" % who)

This defines a class named Greeter having one single method named greet. This method takes one argument who, ignore the self for a moment, we will come back to this later !

Now that we know what method(s) the class Greeter provides we can create an object of this class:

g = Greeter()
print(g)
g.greet("john")
<__main__.Greeter object at 0x107daa438>
hi john !

Why objects ?

  • you can have the same method name for different classes. The type of the value left to . in a method call determines the class and then which method is acutally called
  • there are many advanced concepts in the field of object oriented programming which contribute to reusability and robust code.

The magic self argument in methods

As said a class is a template for creating objects. So we can (and often do) create many instances of the same class. For example there are many strings being instances of the string class.

In the definition of greet the self argument is the object from the left side of . from the method call. We can check this:

class Greeter:
    
    def who_am_i(self):
        print("I am %s" % self)
        
g1 = Greeter()
g2 = Greeter()

print("g1 is", g1)
print("g2 is", g2)

g1.who_am_i()
g2.who_am_i()
g1 is <__main__.Greeter object at 0x107daa748>
g2 is <__main__.Greeter object at 0x107daa6a0>
I am <__main__.Greeter object at 0x107daa748>
I am <__main__.Greeter object at 0x107daa6a0>

So if we call g1.who_am_i() we actually execute the who_am_i method like who_am_i(g1).

Attributes

We can attach arbitrary values to an object:

g1.x = 42
print(g1.x)
42

But the usual procedure is to do access attributes within a method:

class Incrementer:
    
    def set_increment(self, inc):
        self.inc = inc      # we set an attribute of the acutal object
        
    def increment(self, what):
        return what + self.inc   # we fetch an attribute of the actual object
    

i1 = Incrementer()

i1.set_increment(1)

print("i1.inc is", i1.inc)
print("42 incremented is", i1.increment(42))
print()

i1.set_increment(2)
print("is.inc is", i1.inc)
print("0 incremented is", i1.increment(0))
i1.inc is 1
42 incremented is 43

is.inc is 2
0 incremented is 2

The class initializer

Up to no we created objectes by calling the class name using (). To pass arguments to this call we need to implement a special method named __init__:

import math

class Point2D:
    
    def __init__(self, x, y):
        self.x = x
        self.y = y
        
v = Point2D(3.0, 4.0)  

The last call actually creates an temporary object (let's say obj) and calls __init__(obj, 3.0, 4.0). Then this object is assigned to v.

Afterwards we can check the attributes:

print(v.x, v.y)
3.0 4.0

About inheritance

We can extend an existing class by an mechanism called "inheritance". Inheriting from a given class simply attaches or overwrites existing methods. If a class A derives from a class B we say A is a base class of B.

We declare the base class within () brackets after the class name:

import math

class Vector2D(Point2D):
    
    def length(self):
        return math.hypot(self.x, self.y)

Here the class Vector2D uses the same __init__ as Point2D:

v1 = Vector2D(3, 4)

and we can see that the same initializer was executed:

print(v1.x, v1.y)
3 4

But v1 now has more methods than before:

print(v1.length())
5.0

More about "dunder" methods

The method names starting and ending with double _ are often called "dunder methods". They have special and defined meanings. We already know __init__ for initializing an object.

Let us start by extending the previous example with an method for adding two vectors. We add another special method named __str__ which is called when we try to convert the object to a string. This happens automatically when we print such an object and helps us to prettify the output:

import math

class Vector2D(Point2D):
    
    def length(self):
        return math.hypot(self.x, self.y)
    
    def __str__(self):
        l = self.length()
        return "Vector2D(%.3f, %.3f with length %.3f)" % (self.x, self.y, l)
    
v1 = Vector2D(2, 3)
print(v1)
Vector2D(2.000, 3.000 with length 3.606)

A final and more advanced example demonstrate how we can implement the additon for objects of our class.

We start with a "traditional" method named add:

import math

class Vector2D(Point2D):
    
    def length(self):
        return math.hypot(self.x, self.y)
    
    def __str__(self):
        l = self.length()
        return "Vector2D(%.3f, %.3f with length %.3f)" % (self.x, self.y, l)
    
    def add(self, other):
        assert isinstance(other, Vector2D)
        return Vector2D(self.x + other.x, self.y + other.y)

    
v1 = Vector2D(2, 3)
v2 = Vector2D(1, 1)
v3 = v1.add(v2)
print(v3)
Vector2D(3.000, 4.000 with length 5.000)

If we now replace add by __add__ we see a "magic" effect:

import math


class Vector2D(Point2D):
    
    def length(self):
        return math.hypot(self.x, self.y)
    
    def __str__(self):
        l = self.length()
        return "Vector2D(%.3f, %.3f with length %.3f)" % (self.x, self.y, l)
    
    def __add__(self, other):
        assert isinstance(other, Vector2D)
        return Vector2D(self.x + other.x, self.y + other.y)

v1 = Vector2D(2, 3)
v2 = Vector2D(1, 1)

print(v1 + v2)
Vector2D(3.000, 4.000 with length 5.000)

Here the Python interpreter translates the final v1 + v2 to v1.__add__(v2) !

Reference for all special methods: https://docs.python.org/2/reference/datamodel.html

Why classes ?

  • encapsulation: hide internal state and provide a nice to read and use "interface" (like file handles)
  • abstraction: represent a new "data type" (like strings)
  • reusability: classes help to avoid global variables (less coupling)

Best practices

  • A class should do "one thing" and not "many things"
  • decompose your code into distinct classes which you compose.

Example:

  • A class is used for communication with a specific measurement device
  • Another class provides a graphical user interface to operate the device

Exercises

  • Repeat the examples above
  • Rename the method length to __len__ and pass a 2d vector to the builtin len function (the one we used for lists and strings).
  • Implement a class Vector3D including methods length, __str__, and __add__.
  • Extend it with a method __sub__ for subtraction of two vectors.

Using inheritance cleverly

The following example shows how we can implement different "variations" of a given "base algorithm" by inheritance.

Lets say we want to implement an algorithm which sums all values in a list, and the variation multiplies all values.

We implement the "general" procedure in a base class and the specific variations in base classes:

class ListReducer:
    
    def reduce(self, li):
        assert len(li) > 0, "no empty list accepted"
        
        value = li[0]
        for vnext in li[1:]:
            value = self.combine(value, vnext)
        return value
    
class ListAdder(ListReducer):
    
    def combine(self, v1, v2):
        return v1 + v2

Calling the ListReducers reduce method does not work, because we call a missing method combine.

ListReducer().reduce([1, 2])
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-18-781bf3738f1f> in <module>()
----> 1 ListReducer().reduce([1, 2])

<ipython-input-17-c2ad05b98eca> in reduce(self, li)
      6         value = li[0]
      7         for vnext in li[1:]:
----> 8             value = self.combine(value, vnext)
      9         return value
     10 

AttributeError: 'ListReducer' object has no attribute 'combine'

In contrast the ListAdder inherits reduce and implements combine:

ListAdder().reduce([1, 2, 3, 4])
10

Implementing a new variation of our "algorithm" is now very easy:

class ListMultiplier(ListReducer):
    
    def combine(self, v1, v2):
        return v1 * v2
    
ListMultiplier().reduce([1, 2, 3, 4])
24

Practical use cases for this strategy (it is name "Template Method Pattern" https://en.wikipedia.org/wiki/Template_method_pattern):

  • as above: implement variations of a base algorithm by implementing the variations in inherited classes.
  • Communication with a measurement device: implement the general routines in a base class and device and protocol specific parts in derived classes
  • Implement a generic user interfaces for the different (but similar) computational routines.
  • Python standard library provides a Thread class: derive from this class and implement a method named start which contains the code to run in a different thread.