Advanced Classes

This tutorial focuses on the invocation of dunder methods and demystifies the operators in Python.

Dunder

Dunder (Double underscore) methods __meth__ are names which are reserved for Python only and should not be invented by us (implemented; yes). Just to be precise, __meth is fine.

These methods are used to delegate the actual operator call. When we invoke any operator (+, ==, but also () and so on), the object that this is applied on is checked for a corresponding method. For the +, the object (on the left) is checked for a __add__ method. If this is not found or return a NotImplemented either alternatives are tried or an error is raised if all possibilities are tried.

Alternatives involve in this case to call the __radd__ (right add) on the object on the right IF the objects are of different types.

[1]:
class NamedValue:
    def __init__(self, name):
        self.name = name


class ValueLeft(NamedValue):
    def __add__(self, other):
        print(f"add called on {self.name}")
        return 42


class ValueRight(NamedValue):
    def __radd__(self, other):
        print("radd called on {self.name}")
        return 24


class Value(ValueRight, ValueLeft):
    pass

Exercise: which one can we add and which raise an error? Think or try it out!

[2]:
valleft = ValueLeft('val left')
valleft2 = ValueLeft('val left2')
[3]:
valleft + valleft2
add called on val left
[3]:
42

len

The len method simply checks if there is a __len__ implemented.

str

To have a nice, representable, human readable string, __str__ should be implemented. There is a similar one, which is __repr__. This is also a string representation of the object, yet more targeted towards the developers.

If no __str__ is provided, it falls back to __repr__, which, if not provided, uses a default implementation.

[4]:
class Name:
    def __init__(self, name):
        self.name = name


class NameRepr(Name):
    def __repr__(self):
        return self.name


class NameStr(Name):
    def __str__(self):
        return f'I am {self.name}'


class NameStrRepr(NameStr, NameRepr):
    pass

Exercise: try it out by using str(...) and repr(...)

Callable

In Python, a callable is any object that can be called. Calling an object means to have (...) attached behind it. This operator looks for a __call__ method.

[5]:
class Callable:
    def __call__(self, *args, **kwargs):
        print(f"called with args {args} and kwargs {kwargs}")


class NotCallable:
    pass
[6]:
call = Callable()
noncall = NotCallable()
[7]:
call()
called with args () and kwargs {}
[8]:
try:
    noncall()
except TypeError as error:
    print(error)
'NotCallable' object is not callable

TypeError: 'NotCallable' object is not callable translates to has no __call__ method

Indexing (iterating)

There are a few methods when it comes down to iteration. However, we won’t go into these details but rather look at the normal indexing. That is controlled via __getitem__ and __setitem__ and invoked with the [] operator.

[9]:
class Storage:
    def __init__(self, name):
        self.name = name
        self.container = [1, 5, 4]  # just for demonstration

    def __getitem__(self, index):
        print(f"getitem of {self.name} invoked with index {index}")
        return self.container[index]

    def __setitem__(self, index, item):
        print(f"setitem of {self.name} invoked with index {index} and item {item}")
        self.container[index] = item
[10]:
storage = Storage('one')
[11]:
storage[2]
getitem of one invoked with index 2
[11]:
4
[12]:
storage[2] = 3
setitem of one invoked with index 2 and item 3

self

What is actually self? Nothing else than the object itself. However, we can rename it however we like.

Read the following well If an instance is create of a class and a method is called on that instance, the first argument to the method is the instance itself. Fullstop

What are the consequences of this?

[13]:
class A:
    def __init__(self, value):
        self.value = value

    def add(self, y):
        return self.value + y.value
[14]:
a = A(4)
b = A(38)
[15]:
a.add(b)
[15]:
42
[16]:
A.add(a, b)
[16]:
42

The latter works as well! Why not? add is a method that we call and we give it two arguments. Forgetting about class dynamics, it makes actually complete sense.

Danger zone

The following is only for fun and should not be used in real live, except you do really know what you’re doing and at least two independent colleagues agree that this is the right way to go

We have seen that basically everything is an operator and it has a dunder method. Everything? Quiz: what did we miss?

Solution: the . the access operator. Yes, you guessed right. Let’s override it

First, where are actually all the attributes stored in a class? Answer: in the __dict__ attribute.

[17]:
a.__dict__
[17]:
{'value': 4}

Next quiz: where are the methods (remark open) stored?

[18]:
a.__class__.__dict__
[18]:
mappingproxy({'__module__': '__main__',
              '__init__': <function __main__.A.__init__(self, value)>,
              'add': <function __main__.A.add(self, y)>,
              '__dict__': <attribute '__dict__' of 'A' objects>,
              '__weakref__': <attribute '__weakref__' of 'A' objects>,
              '__doc__': None})
[19]:
A.__dict__
[19]:
mappingproxy({'__module__': '__main__',
              '__init__': <function __main__.A.__init__(self, value)>,
              'add': <function __main__.A.add(self, y)>,
              '__dict__': <attribute '__dict__' of 'A' objects>,
              '__weakref__': <attribute '__weakref__' of 'A' objects>,
              '__doc__': None})

To be clear, there is nothing special about a value attribute and a method: the value attribute happened to be set on the instance while the method happened to be set on the class. But we can have class attributes as well as (not really occuring in reality though) instance methods.

Disclaimer: the following is EXTREMELY BAD CODING practices and should NEVER be seen in ANY real used code

[20]:
class GetAndSet:
    def __init__(self):
        self.values = [1, 2, 3, 4, 5]

    def add(self, y):
        return self.values[0] + y.values[1]

    def __getattr__(self, name):
        if name in ('add', 'addition'):
            return self.add
        if name == 'hello':
            print('I am 42')

    # we omit the __setattr__, but the game is the same
[21]:
get = GetAndSet()
[22]:
get.add(get)
[22]:
3
[23]:
get.addition(get)
[23]:
3
[24]:
get.hello
I am 42

We can also provoke the same behavior by using the function getattr (or setattr respectively)

[25]:
getattr(get, 'hello')
I am 42
[26]:
get.hi

Quiz: why the above?

Answer: Because the __getattr__ that we called returns None (as any function/method does without an explicit return)

[27]:
import this
The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

And then there is the maybe most important sentence of all in Python:

We’re all adults here.

Behave like one when coding ;)