$$ \newcommand{\tp}{\thinspace .} $$

 

 

 

This chapter is taken from the book A Primer on Scientific Programming with Python by H. P. Langtangen, 5th edition, Springer, 2016.

Example: Class for complex numbers

Imagine that Python did not already have complex numbers. We could then make a class for such numbers and support the standard mathematical operations. This exercise turns out to be a very good pedagogical example of programming with classes and special methods, so we shall make our own class for complex numbers and go through all the details of the implementation.

The class must contain two data attributes: the real and imaginary part of the complex number. In addition, we would like to add, subtract, multiply, and divide complex numbers. We would also like to write out a complex number in some suitable format. A session involving our own complex numbers may take the form

>>> u = Complex(2,-1)
>>> v = Complex(1)     # zero imaginary part
>>> w = u + v
>>> print w
(3, -1)
>>> w != u
True
>>> u*v
Complex(2, -1)
>>> u < v
illegal operation "<" for complex numbers
>>> print w + 4
(7, -1)
>>> print 4 - w
(1, 1)

We do not manage to use exactly the same syntax with j as imaginary unit as in Python's built-in complex numbers so to specify a complex number we must create a Complex instance.

Implementation

Here is the complete implementation of our class for complex numbers:

class Complex(object):
    def __init__(self, real, imag=0.0):
        self.real = real
        self.imag = imag

    def __add__(self, other):
        return Complex(self.real + other.real,
                       self.imag + other.imag)

    def __sub__(self, other):
        return Complex(self.real - other.real,
                       self.imag - other.imag)

    def __mul__(self, other):
        return Complex(self.real*other.real - self.imag*other.imag,
                       self.imag*other.real + self.real*other.imag)

    def __div__(self, other):
        sr, si, or, oi = self.real, self.imag, \ 
                         other.real, other.imag # short forms
        r = float(or**2 + oi**2)
        return Complex((sr*or+si*oi)/r, (si*or-sr*oi)/r)

    def __abs__(self):
        return sqrt(self.real**2 + self.imag**2)

    def __neg__(self):   # defines -c (c is Complex)
        return Complex(-self.real, -self.imag)

    def __eq__(self, other):
        return self.real == other.real and self.imag == other.imag

    def __ne__(self, other):
        return not self.__eq__(other)

    def __str__(self):
        return '(%g, %g)' % (self.real, self.imag)

    def __repr__(self):
        return 'Complex' + str(self)

    def __pow__(self, power):
        raise NotImplementedError\ 
              ('self**power is not yet impl. for Complex')

The special methods for addition, subtraction, multiplication, division, and the absolute value follow easily from the mathematical definitions of these operations for complex numbers. What -c means when c is of type Complex, is also easy to define and implement. The __eq__ method needs a word of caution: the method is mathematically correct, but comparison of real numbers on a computer should always employ a tolerance. The version of __eq__ shown above is about compact code and equivalence to the mathematics. Any real-world numerical computations should employ a test that abs(self.real - other.real) < eps and abs(self.imag - other.imag) < eps, where eps is some small tolerance, say eps = 1E-14.

The final __pow__ method exemplifies a way to introduce a method in a class, while we postpone its implementation. The simplest way to do this is by inserting an empty function body using the pass ("do nothing") statement:

class Polynomial(object):
    ...
    def __pow__(self, power):
        # Postpone implementation of self**power
        pass

However, the preferred method is to raise a NotImplementedError exception so that users writing power expressions are notified that this operation is not available. The simple pass will just silently bypass this serious fact!

Illegal operations

Some mathematical operations, like the comparison operators >, >=, etc., do not have a meaning for complex numbers. By default, Python allows us to use these comparison operators for our Complex instances, but the boolean result will be mathematical nonsense. Therefore, we should implement the corresponding special methods and give a sensible error message that the operations are not available for complex numbers. Since the messages are quite similar, we make a separate method to gather common operations:

    def _illegal(self, op):
        print 'illegal operation "%s" for complex numbers' % op

Note the underscore prefix: this is a Python convention telling that the _illegal method is local to the class in the sense that it is not supposed to be used outside the class, just by other class methods. In computer science terms, we say that names starting with an underscore are not part of the application programming interface, known as the API. Other programming languages, such as Java, C++, and C#, have special keywords, like private and protected that can be used to technically hide both data and methods from users of the class. Python will never restrict anybody who tries to access data or methods that are considered private to the class, but the leading underscore in the name reminds any user of the class that she now touches parts of the class that are not meant to be used "from the outside".

Various special methods for comparison operators can now call up _illegal to issue the error message:

    def __gt__(self, other):  self._illegal('>')
    def __ge__(self, other):  self._illegal('>=')
    def __lt__(self, other):  self._illegal('<')
    def __le__(self, other):  self._illegal('<=')

Mixing complex and real numbers

The implementation of class Complex is far from perfect. Suppose we add a complex number and a real number, which is a mathematically perfectly valid operation:

w = u + 4.5

This statement leads to an exception,

AttributeError: 'float' object has no attribute 'real'

In this case, Python sees u + 4.5 and tries to use u.__add__(4.5), which causes trouble because the other argument in the __add__ method is 4.5, i.e., a float object, and float objects do not contain an attribute with the name real (other.real is used in our __add__ method, and accessing other.real is what causes the error).

One idea for a remedy could be to set

other = Complex(other)

since this construction turns a real number other into a Complex object. However, when we add two Complex instances, other is of type Complex, and the constructor simply stores this Complex instance as self.real (look at the method __init__). This is not what we want!

A better idea is to test for the type of other and perform the right conversion to Complex:

    def __add__(self, other):
        if isinstance(other, (float,int)):
            other = Complex(other)
        return Complex(self.real + other.real,
                       self.imag + other.imag)

We could alternatively drop the conversion of other and instead implement two addition rules, depending on the type of other:

    def __add__(self, other):
        if isinstance(other, (float,int)):
            return Complex(self.real + other, self.imag)
        else:
            return Complex(self.real + other.real,
                           self.imag + other.imag)

A third way is to look for what we require from the other object, and check that this demand is fulfilled. Mathematically, we require other to be a complex or real number, but from a programming point of view, all we demand (in the original __add__ implementation) is that other has real and imag attributes. To check if an object a has an attribute with name stored in the string attr, one can use the function

hasattr(a, attr)

In our context, we need to perform the test

if hasattr(other, 'real') and hasattr(other, 'imag'):

Our third implementation of the __add__ method therefore becomes

    def __add__(self, other):
        if isinstance(other, (float,int)):
            other = Complex(other)
        elif not (hasattr(other, 'real') and \ 
                  hasattr(other, 'imag')):
            raise TypeError('other must have real and imag attr.')
        return Complex(self.real + other.real,
                       self.imag + other.imag)

The advantage with this third alternative is that we may add instances of class Complex and Python's own complex class (complex), since all we need is an object with real and imag attributes.

Dynamic, static, strong, weak, and duck typing

The presentations of alternative implementations of the __add__ actually touch some very important computer science topics. In Python, function arguments can refer to objects of any type, and the type of an argument can change during program execution. This feature is known as dynamic typing and supported by languages such as Python, Perl, Ruby, and Tcl. Many other languages, C, C++, Java, and C# for instance, restrict a function argument to be of one type, which must be known when we write the program. Any attempt to call the function with an argument of another type is flagged as an error. One says that the language employs static typing, since the type cannot change as in languages having dynamic typing. The code snippet

a = 6    # a is integer
a = 'b'  # a is string

is valid in a language with dynamic typing, but not in a language with static typing.

Our next point is easiest illustrated through an example. Consider the code

a = 6
b = '9'
c = a + b

The expression a + b adds an integer and a string, which is illegal in Python. However, since b is the string '9', it is natural to interpret a + b as 6 + 9. That is, if the string b is converted to an integer, we may calculate a + b. Languages performing this conversion automatically are said to employ weak typing, while languages that require the programmer to explicit perform the conversion, as in

c = a + float(b)

are known to have strong typing. Python, Java, C, and C# are examples of languages with strong typing, while Perl and C++ allow weak typing. However, in our third implementation of the __add__ method, certain types - int and float – are automatically converted to the right type Complex. The programmer has therefore imposed a kind of weak typing in the behavior of the addition operation for complex numbers.

There is also something called duck typing where the code only imposes a requirement of some data or methods in the object, rather than demanding the object to be of a particular type. The explanation of the term duck typing is the principle: if it walks like a duck, and quacks like a duck, it's a duck. An operation a + b may be valid if a and b have certain properties that make it possible to add the objects, regardless of the type of a or b. To enable a + b in our third implementation of the __add__ method, it is sufficient that b has real and imag attributes. That is, objects with real and imag look like Complex objects. Whether they really are of type Complex is not considered important in this context.

There is a continuously ongoing debate in computer science which kind of typing that is preferable: dynamic versus static, and weak versus strong. Static and strong typing, as found in Java and C#, support coding safety and reliability at the expense of long and sometimes repetitive code, while dynamic and weak typing support programming flexibility and short code. Many will argue that short code is more readable and reliable than long code, so there is no simple conclusion.

Special methods for "right" operands

What happens if we add a float and a Complex in that order?

w = 4.5 + u

This statement causes the exception

TypeError: unsupported operand type(s) for +: 'float' and 'instance'

This time Python cannot find any definition of what the plus operation means with a float on the left-hand side and a Complex object on the right-hand side of the plus sign. The float class was created many years ago without any knowledge of our Complex objects, and we are not allowed to extend the __add__ method in the float class to handle Complex instances. Nevertheless, Python has a special method __radd__ for the case where the class instance (self) is on the right-hand side of the operator and the other object is on the left-hand side. That is, we may implement a possible float or int plus a Complex by

    def __radd__(self, other):        # defines other + self
        return self.__add__(other)    # other + self = self + other

Similar special methods exist for subtraction, multiplication, and division. For the subtraction operator, observe that other - self, which is the operation assumed to implemented in __rsub__, can be realized by other.__sub__(self). A possible implementation is

    def __sub__(self, other):
        print 'in sub, self=%s, other=%s' % (self, other)
        if isinstance(other, (float,int)):
            other = Complex(other)
        return Complex(self.real - other.real,
                       self.imag - other.imag)

    def __rsub__(self, other):
        print 'in rsub, self=%s, other=%s' % (self, other)
        if isinstance(other, (float,int)):
            other = Complex(other)
        return other.__sub__(self)

The print statements are inserted to better understand how these methods are visited. A quick test demonstrates what happens:

>>> w = u - 4.5
in sub, self=(2, -1), other=4.5
>>> print w
(-2.5, -1)
>>> w = 4.5 - u
in rsub, self=(2, -1), other=4.5
in sub, self=(4.5, 0), other=(2, -1)
>>> print w
(2.5, 1)

Remark

As you probably realize, there is quite some code to be implemented and lots of considerations to be resolved before we have a class Complex for professional use in the real world. Fortunately, Python provides its complex class, which offers everything we need for computing with complex numbers. This fact reminds us that it is important to know what others already have implemented, so that we avoid "reinventing the wheel". In a learning process, however, it is a probably a very good idea to look into the details of a class Complex as we did above.

Inspecting instances

The purpose of this section is to explain how we can easily look at the contents of a class instance, i.e., the data attributes and the methods. As usual, we look at an example - this time involving a very simple class:

class A(object):
    """A class for demo purposes."""
    def __init__(self, value):
        self.v = value

    def dump(self):
        print self.__dict__

The self.__dict__ attribute is briefly mentioned in the section Making classes without the class construct. Every instance is automatically equipped with this attribute, which is a dictionary that stores all the ordinary attributes of the instance (the variable names are keys, and the object references are values). In class A there is only one data attribute, so the self.__dict__ dictionary contains one key, 'v':

>>> a = A([1,2])
>>> a.dump()
{'v': [1, 2]}

Another way of inspecting what an instance a contains is to call dir(a). This Python function writes out the names of all methods and variables (and more) of an object:

>>> dir(a)
'__doc__', '__init__', '__module__', 'dump', 'v']

The __doc__ variable is a docstring, similar to docstrings in functions, i.e., a description of the class appearing as a first string right after the class headline:

>>> a.__doc__
'A class for demo purposes.'

The __module__ variable holds the name of the module in which the class is defined. If the class is defined in the program itself and not in an imported module, __module__ equals '__main__'.

The rest of the entries in the list returned from dir(a) correspond to attribute names defined by the programmer of the class, in this example the method attributes __init__ and dump, and the data attribute v.

Now, let us try to add new variables to an existing instance:

>>> a.myvar = 10
>>> a.dump()
{'myvar': 10, 'v': [1, 2]}
>>> dir(a)
['__doc__', '__init__', '__module__', 'dump', 'myvar', 'v']

The output of a.dump() and dir(a) show that we were successful in adding a new variable to this instance on the fly. If we make a new instance, it contains only the variables and methods that we find in the definition of class A:

>>> b = A(-1)
>>> b.dump()
{'v': -1}
>>> dir(b)
['__doc__', '__init__', '__module__', 'dump', 'v']

We may also add new methods to an instance, but this will not be shown here.

Adding or removing attributes may sound scary and highly illegal to C, C++, and Java programmers, but more dynamic classes is natural and legal in many other languages - and often useful.

Python classes are dynamic and their contents can be inspected. As seen by the examples above,
  1. a class instance is dynamic and allows attributes to be added or removed while the program is running,
  2. the contents of an instance can be inspected by the dir function, and the data attributes are available through the __dict__ dictionary.
There is a special module, inspect, doing more detailed inspection of Python objects. One can, for example, get the arguments of functions or methods and even inspect the code of the object.