Object Oriented Python

Or a class about classes!

Object Oriented Programming

Object-oriented programming is about using classes to define data abstraction.

A strong abstraction should make use of encapsulation, modularity, and inheritance.

The Simplest Class

In [23]:
class Simple(object):
    pass

I Lied

Actually it can be simpler

In [24]:
class Simple:
    pass

A Slightly More Complex Example

In [25]:
# %load ../code/image.py
class Image(object):
    """Base class for images."""

    def __init__(self, height, width):
        self.height, self.width = height, width

    def dimensions(self):
        return (self.height, self.width)

Class Initialization

When a new instance of a class is created, the __init__ method is called.

In [26]:
image = Image(100, 100)
image.dimensions()
Out[26]:
(100, 100)

Know Yourself

The self is the first argument to all class methods. self is similar to this in C++ or Java. Unlike this in C++ or Java, self is not a keyword only a strong convention. It represents the current class instance. It is automatically passed when the object calls the method.

Class Attributes

While can self be used to store information about the current instance, you can also use class level attributes to maintain information about all class instances. To do this we can use the __class__ attribute which is an attribute on all class instances.

In [27]:
# %load ../code/counter.py
class Counter(object):

    count = 0

    def __init__(self):
        self.__class__.count += 1
In [28]:
first = Counter()
first.count
Out[28]:
1
In [29]:
second = Counter()
second.count
Out[29]:
2

Private Methods

Part of encapsulation is being able to keep information hidden. To mark a method as private you prefix the name with a double underscore. When this is done you can only call the method from inside the class.

In [30]:
# %load ../code/private.py
class Hidden(object):

    def __init__(self, data):
        self.data = data
        self.__print_data()

    def __print_data(self):
        print(self.data)
In [31]:
example = Hidden(4)
4
In [32]:
# Will raise an error
example.__print_data()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-32-3c699316bc1e> in <module>()
      1 # Will raise an error
----> 2 example.__print_data()

AttributeError: 'Hidden' object has no attribute '__print_data'

Inheritance is Super

Classes do not always have to extend from object. They can extend from one or more base classes. Doing this we can extend the functionality provided by the base class.

The superfunction delegates method calls to a parent or sibling class of type. This is useful for accessing inherited methods that have been overridden in a class.

In [33]:
class SquareImage(Image):
    
    def __init__(self, side):
        super().__init__(side, side)
In [34]:
square = SquareImage(20)
square.dimensions()
Out[34]:
(20, 20)

Extending Built-In Types

You can also extend built in Python types such as int, float, dict, list.

Possible extensions:

  • Ordered dictionary (preserves key insersion order)
  • Multi-valued dictionary (allows keys to hold multiple values)
  • Queue (first in, first out)

Note on Extending Immutable Types

__init__ is called after the object is constructed. For immutable types (int, string, tuple) it is too late to modify the value. If you want to change how they are created you need to override the class constructor __new__.

In [35]:
# %load ../code/newstring.py
class LanguageString(str):

    def __new__(cls, value, lang='en'):
        obj = str.__new__(cls, value)
        obj.lang = lang
        return obj
In [36]:
english_string = LanguageString('Hello')
spanish_string = LanguageString('Hola', lang='sp')

__new__ vs __init__

__new__ takes the class (cls) as its first argument. If it returns a new instance of the class then __init__ is called passing the new instance and the rest of the arguments. If __new__ doesn't return a new instance then __init__ will not be called.

If you are extending object or a subclass of object will typically not need to change this method.

Singleton

One use of overriding __new__ for a mutable type is the Singleton pattern.

The Singleton pattern is a common programming pattern for having a class which only allows for a single instance.

In [37]:
# %load ../code/singleton.py
class Singleton(object):
    __instance = None

    def __new__(cls, *args, **kwargs):
        if cls.__instance is None:
            cls.__instance = object.__new__(cls, *args, **kwargs)
        return cls.__instance
In [38]:
class ExampleSingleton(Singleton):
    pass
In [39]:
x = ExampleSingleton()
y = ExampleSingleton()
x is y
Out[39]:
True

The Borg Pattern

Some critics of the Singleton pattern have noted that it is often used in cases where you don't really care about object identity but really all you care about is shared state (such as global configuration).

In [40]:
# %load ../code/borg.py
class Borg(object):

    _state = {}

    def __new__(cls, *args, **kwargs):
        self = object.__new__(cls, *args, **kwargs)
        # override instance namespace with shared state
        self.__dict__ = cls._state
        return self
In [41]:
config1 = Borg()
config1.debug = True
In [42]:
config2 = Borg()
config2.debug
Out[42]:
True
In [43]:
config1 is config2
Out[43]:
False

Multiple Inheritance

Python supports multiple inheritance.

Multiple inheritance can cause some ambiguity in what version of the method will be called. In Python methods are resolved depth-first from left to right in the defined parent classes. This means the order of base classes matter in the class definition.

In [44]:
class TypeA(object):

    def name(self):
        print('Type A')


class TypeB(object):

    def name(self):
        print('Type B')
In [45]:
class TypeC(TypeA, TypeB):
    pass


class TypeD(TypeB, TypeA):
    pass
In [46]:
c = TypeC()
c.name()
c.__class__.__mro__
Type A
Out[46]:
(__main__.TypeC, __main__.TypeA, __main__.TypeB, object)
In [47]:
d = TypeD()
d.name()
d.__class__.__mro__
Type B
Out[47]:
(__main__.TypeD, __main__.TypeB, __main__.TypeA, object)

Mixins

Mixins are a sytle of using multiple inheritance. Each mixin class adds a small and specific piece of functionality.

This would be similar to interfaces in Java or C#.

Mixins Example

A great example comes right from the Python standard library: SocketServer.py

class ForkingUDPServer(ForkingMixIn, UDPServer): pass
class ForkingTCPServer(ForkingMixIn, TCPServer): pass

class ThreadingUDPServer(ThreadingMixIn, UDPServer): pass
class ThreadingTCPServer(ThreadingMixIn, TCPServer): pass

Defining Comparisons

Comparison methods __lt__, __le__, __eq__, __ne__, __gt__, and __ge__ can be used to define object comparisons. They should return either True or False but can return any value which will be converted to a bool.

In [48]:
class Student(object):

    def __init__(self, name, grade):
        self.name, self.grade = name, grade

    def __lt__(self, other):
        return self.name < other.name

    def __gt__(self, other):
        return self.grade > other.grade
In [49]:
sally = Student('Sally', 90)
susan = Student('Susan', 90)
In [50]:
sally < susan
Out[50]:
True
In [51]:
sally > susan
Out[51]:
False
In [52]:
sally >= susan
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-52-ada2b62cc324> in <module>()
----> 1 sally >= susan

TypeError: '>=' not supported between instances of 'Student' and 'Student'

Total Ordering

If you define all of these comparisons then you can potentially create a non-well defined set of ordering. To simplify the creation a well ordered type you can use functools.total_ordering. In that case you only need to define one of __lt__(), __le__(), __gt__(), or __ge__() along with __eq__. The rest of the comparisons will be derived from those.

In [53]:
from functools import total_ordering


@total_ordering
class Student(object):

    def __init__(self, name, grade):
        self.name, self.grade = name, grade

    def __lt__(self, other):
        return self.grade < other.grade
    
    def __eq__(self, other):
        return self.grade == other.grade
In [54]:
sally = Student('Sally', 90)
susan = Student('Susan', 80)
sally >= susan
Out[54]:
True
In [55]:
students = [sally, susan]
students.sort()  # Ascending sort is the default
[s.name for s in students]
Out[55]:
['Susan', 'Sally']
In [56]:
students.sort(reverse=True)  # Now desending sort
[s.name for s in students]
Out[56]:
['Sally', 'Susan']

Defining Operators

You can define common operators by defining __add__, __sub__, __mul__, __floordiv__, __mod__, __pow__, __lshift__, __rshift__, __and__, __or__, and __xor__.

In [57]:
class Media(object):
    
    def __init__(self, css=None, js=None):
        self.css = set(css or [])
        self.js = set(js or [])

    def __add__(self, other):
        css = self.css | other.css
        js = self.js | other.js
        return Media(css=css, js=js)
In [58]:
base = Media(css=['base.css'])
forms = Media(css=['base.css', 'forms.css'], js=['forms.js'])
In [59]:
new = base + forms
In [60]:
new.js
Out[60]:
{'forms.js'}
In [61]:
new.css
Out[61]:
{'base.css', 'forms.css'}

Up Next

Using the file system and handling problems.