Object Oriented Programming II.

OOP II.

Encapsulation
In procedural programming, the data and the functions are disjoint. With classes the data and the corresponding functions are in one class. The data and the functionality of the class are accessible via the interface: the methods of the class.
Class
The type of an object, contains every attribute of the object.
Attribute
Attribute can be a member: data (variable) or function (method). There are class attributes and object (instance) attributes. They can be accessed with the dot notation (dot and name).
Instance
A concrete object of a class, like 5 is an instance of int.
Instantiation
When you create a new object (__init___)
Instance variable
a variable of self, it is defined inside a method. It is the property of an instance, different instances can have different values.
Class variable
variable defined in a class, outside of any methods. It is a property of the class, every instance shares it.
Method
a function inside a class, its first parameter is (typically but not always) self.
Inheritance
a way to make a class on top of an other, existing one. The new class (child) is derived from the old class (parent). The child class will have every attribute that the parent have had.
Polymorphism
Different classes with similar functionality (interface). Heterogeneous objects can be used for the same thing, although behaving differently.

Inheritance

Let's write a python class Person which stores a name and a title (Mr, Ms, Dr ...). Let's also write a __str__ method which prints the name nicely.

After that we make a Knight class inherited from the Person. The knights are just like the persons but their title is "Sir".

See how the child class calles the parent's constructor (super).

In [1]:
class Person:
    def __init__(self, name, title):
        self.name = name
        self.title = title

    def __str__(self):
        return self.title + " " + self.name

class Knight(Person):
    def __init__(self, name):
        super().__init__(name, 'Sir')

smith = Person('Smith', 'Mr')
launcelot = Knight('Launcelot')
print(smith)
print(launcelot)
Mr Smith
Sir Launcelot

You can also define members in the child class, but they are only present in the child, not in the parent.

For example a Knight can have an optional epithet (a descriptive byname)!

In [2]:
class Person:
    def __init__(self, name, title):
        self.name = name
        self.title = title
    def __str__(self):
        return self.title + " " + self.name

class Knight(Person):
    def __init__(self, name, epithet=""):
        super().__init__(name, 'Sir')
        self.epithet = epithet
         
launcelot = Knight('Launcelot', 'the brave')
print(launcelot)
Sir Launcelot

Now we override the inherited __str__ method with a new one, a same method but in the child class. If you don't write a new method, the parent (inherited) method is used instead.

In [3]:
class Person:
    def __init__(self, name, title):
        self.name = name
        self.title = title

    def __str__(self):
        return self.title + " " + self.name

class Knight(Person):
    def __init__(self, name, epithet=""):
        super().__init__(name, 'Sir')
        self.epithet = epithet

    def __str__(self):
        if len(self.epithet) > 0:
            return self.title + " " + self.name + ", " + self.epithet
        else:
            return super().__str__()
         
launcelot = Knight('Launcelot', 'the brave')
black = Knight('Black')
robin = Knight('Robin', 'the Not-quite-so-brave-as-Sir-Launcelot')
print(launcelot)
print(black)
print(robin)
Sir Launcelot, the brave
Sir Black
Sir Robin, the Not-quite-so-brave-as-Sir-Launcelot

Instance vs. class members

One can define a variable in a class but outside a method. This is a class member, it is the same for all of the instances.

For example the "Sir" title of knights is the same for all knights. Let's call this special_title, not to interfere with the Persons title member.

The Knight.special_title will be the same as any instance's .special_title.

In [4]:
class Person:
    def __init__(self, name, title):
        self.name = name
        self.title = title
    def __str__(self):
        return self.title + " " + self.name

class Knight(Person):
    special_title = 'Sir'
    def __init__(self, name, epithet=""):
        super().__init__(name, "")
        self.epithet = epithet
    def __str__(self):
        return Knight.special_title + " " + self.name + ", " + self.epithet
         
launcelot = Knight('Launcelot', 'the brave')
robin = Knight('Robin', 'the Not-quite-so-brave-as-Sir-Launcelot')
print(launcelot)
print(robin)
Sir Launcelot, the brave
Sir Robin, the Not-quite-so-brave-as-Sir-Launcelot
In [5]:
print(robin.special_title, launcelot.special_title, Knight.special_title)
Sir Sir Sir

The child class can be a parent of a third class, the inheritence structure can be deeper and more complicated.

Exceptions

There are cases when your code does something wrong or encounters some invalid value. In this case an object of type Exception is raised (or thrown).

What happens:

  • the code stops its proper control flow
  • every function stops immediately
  • if the exception happend outside of a function, then the code stops at that point.

Unless

  • The code handles (catches) the exception with the following block:
    try:
        ...
    except ... :
        ...

An exception can be handled at any place with this block. If it is not handled, then the exception can break out from any functions.

For example let's raise an exception:

In [6]:
try:
    raise Exception('spam', 'eggs')
except Exception as inst:
    print(type(inst))    
    print(inst.args)      
    print(inst)           
    x, y = inst.args
    print('x =', x)
    print('y =', y)
<class 'Exception'>
('spam', 'eggs')
('spam', 'eggs')
x = spam
y = eggs

Now handle an other exception:

In [7]:
try:
    str(5) + 5
except TypeError as inst:
    print(type(inst))
    print(inst.args)     
    print(inst)
<class 'TypeError'>
('must be str, not int',)
must be str, not int

It is a natural way to handle exceptions in order to correct mistakes.

For example read an integer from the user, but prepare that the user might not enter a proper float.

In [8]:
while True:
    try:
        x = float(input("Please enter a real number: "))
        break
    except ValueError:
        print("Oops!  That was not a real number. Try again...")
print(2*x)
Please enter a real number: 
Oops!  That was not a real number. Try again...
Please enter a real number: wertzuio
Oops!  That was not a real number. Try again...
Please enter a real number: 123
246.0

Exception across functions

Look where the exception happens and where is it handled.

In [9]:
def f(x):
    return x + 5 # not good if x is a string

def g(x):
    return x + x # good for integers and strings too

x = "5"
y = g(x)
z = f(y)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-9-ec3d5081c890> in <module>
      7 x = "5"
      8 y = g(x)
----> 9 z = f(y)

<ipython-input-9-ec3d5081c890> in f(x)
      1 def f(x):
----> 2     return x + 5 # not good if x is a string
      3 
      4 def g(x):
      5     return x + x # good for integers and strings too

TypeError: must be str, not int
In [10]:
def read(n):
    maximum = float("-inf")
    for i in range(n):
        x = float(input())
        if x > maximum:
            maximum = x
    return maximum

try:
    y = read(3)
    print(y)
except ValueError as e:
    print(e)
2
3
wertzu
could not convert string to float: 'wertzu'

Custom exceptions

You can write your own exception class. You can use it to mark that the error happened in one of your own code.

All you have to do is to inherit from the built-in Exception class. Optionally, you can have other functions or members in the exception class.

In [11]:
class KnightException(Exception):
    pass

try:
    lancelot = Person("Lancelot", "Mr")
    x = str(lancelot)
    if x[:3] != "Sir":
        raise KnightException("Use correct title")    
except KnightException as s:
    print(s)
Use correct title

Iterable and iterator objects

As we have seen, the for loop can work on several data types:

for i in L:

L can be list, tuple, dict. What type of objects can follow for ... in ?

What happens in a for loop?

  • for ... in OBJ: calls the iter(OBJ) function (the __iter__(OBJ) method), which returns an iterator object. If OBJ has this method, it means, OBJ is iterable.
    • This marks the beginning of the loop.
  • The iterator object has a __next__() method which returns the next elements.
    • This goes through the elements
  • or raises a StopIteration exception if it ran out of elements.
    • This marks the end of the loop.

An object is iterable if one can iterate over, like the list, tuple. It generates an iterator when passed to iter() (__iter__()) method. Iterators have __next__() method, which returns the next item of the object. Every iterator is also an iterable, but not vicaversa. For example, a list is iterable but it is not an iterator. An iterable object needs a method __iter__(), which returns an iterator (or a __getitem__() method with sequential indexes starting with 0).

These are special methods!

In [12]:
r = range(3)
it = iter(r)
next(it)
Out[12]:
0
In [13]:
print(next(it))
print(next(it))
1
2
In [14]:
next(it)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-14-bc1ab118995a> in <module>
----> 1 next(it)

StopIteration: 

You can write your own iterable and tell python how to iterate over it.

First you need an __iter__ method, the returned object can be the self or a built-in iterable (list, set, tuple). The iterator object have to define a __next__() method which returns the elements one after the other.

Raise StopIteration when you want to stop.

In [15]:
class Group:
    def __init__(self, name, persons):
        self.persons = persons
        self.name = name
        
    def __iter__(self):
        self.index = 0
        return self
    
    def __next__(self):
        if self.index >= len(self.persons):
            raise StopIteration     # tell to stop
        self.index += 1
        return self.persons[self.index - 1]
        
kotrt = Group('Knights of The Round Table', 
              [Knight('Launcelot', 'the brave'), 
               Knight('Galahad', 'the pure'),
               Knight('Bedevere', 'the wise'), 
               Knight('Robin', 'the Not-quite-so-brave-as-Sir-Launcelot')])
for knight in kotrt:
    print(knight)
Sir Launcelot, the brave
Sir Galahad, the pure
Sir Bedevere, the wise
Sir Robin, the Not-quite-so-brave-as-Sir-Launcelot

In this case you could solve this easier, since a list is already iterable:

In [16]:
class Group:
    def __init__(self, name, persons):
        self.persons = persons
        self.name = name
        
    def __iter__(self):
        return iter(self.persons)
        
kotrt = Group('Knights of The Round Table', 
              [Knight('Launcelot', 'the brave'), 
               Knight('Galahad', 'the pure'),
               Knight('Bedevere', 'the wise'), 
               Knight('Robin', 'the Not-quite-so-brave-as-Sir-Launcelot')])
for knight in kotrt:
    print(knight)
Sir Launcelot, the brave
Sir Galahad, the pure
Sir Bedevere, the wise
Sir Robin, the Not-quite-so-brave-as-Sir-Launcelot
In [ ]: