Object Usage
Objects everywhere!
Objects are very important in Python since everything is an object!
Technically, everything that can be referred to by a variable is an
instance of the type object.
Integers, booleans, strings, lists are therefore objects:
>>> isinstance(42, object)
True
>>> isinstance(True, object)
True
>>> isinstance("Hello!", object)
True
>>> isinstance([1, 2, 3], object)
True
Although it is less intuitive, functions, types, and even modules are also
objects:
>>> isinstance(print, object)
True
>>> isinstance(int, object)
True
>>> import sys; isinstance(sys, object)
True
Not so complex numbers
The type complex in Python represents the category of complex numbers.
It provides a good example of the kind of interactions
that we can have with objects.
Creation
To create the complex number $z = 1 / 2 + (3 / 2)i$, we can use the literal
syntax for complex numbers:
>>> z = 0.5 + 1.5j
It is good to know this syntax because it is the one Python will use to display
complex numbers:
>>> z
(0.5+1.5j)
However, not all objects have such a literal notation.
But there is an alternative method for all objects:
you can call the type of the object you want to instantiate (as if it were a function)
passing the necessary arguments, here the real and imaginary parts of the number to construct.
The type of the object therefore serves as a constructor.
>>> z = complex(0.5, 1.5)
>>> z == 0.5+1.5j
True
Attributes
An object is a data structure. The data it contains can be made accessible as
attributes. All complex numbers have the attributes real and imag:
>>> z.real
0.5
>>> z.imag
1.5
The syntax to change the value of the attribute real of the number z
should be z.real = .... However, this assignment fails here because complex
numbers were designed as immutable.
>>> z.real = -0.5
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: readonly attribute
Methods
A complex number has an attribute conjugate
>>> z.conjugate # doctest: +ELLIPSIS
<built-in method conjugate of complex object at 0x...>
This attribute is a method: it behaves like a function. Technically,
it is callable:
>>> callable(z.conjugate)
True
This method is bound to the complex number z: it can use z and the data it contains
to produce a result, without needing to explicitly pass z as an argument.
Here, z.conjugate() returns the complex conjugate of z.
>>> z.conjugate()
(0.5-1.5j)
The method conjugate is also available as an attribute of the type complex.
>>> complex.conjugate
<method 'conjugate' of 'complex' objects>
It is then not bound to a particular complex number instance;
we must therefore explicitly provide the complex number to conjugate as an argument.
>>> complex.conjugate(z)
(0.5-1.5j)
Magic methods
Magic methods are methods of an object whose name starts and ends
with a double underscore "__". These magic methods are rarely
called directly by the programmer, but most often indirectly by the Python
interpreter itself.
The magic methods of type complex allow, for example, calculations with complex
numbers using concise syntax: if it is possible to compute
>>> 1j + 1j * 1j
(-1+1j)
It’s because the type complex includes the magic methods __add__ and __mul__,
called in case of addition and multiplication respectively. The calculation above is therefore
equivalent to:
>>> complex.__add__(1j, complex.__mul__(1j, 1j))
(-1+1j)
Or, using the bound version of these methods:
>>> 1j.__add__(1j.__mul__(1j))
(-1+1j)
In any case, the initial notation — where Python itself calls the magic methods
— is considerably more readable!
Designing Types
Our aim in this section is to create a type Complex which is a
simplified version of the built-in type complex.
To do this, we define a new class of objects;
to create a minimal class, without specific functionality,
the following code is sufficient:
class Complex:
pass
At this stage, it is certainly possible to instantiate a “complex number”
>>> z = Complex()
with the correct type
>>> type(z) is Complex
True
>>> isinstance(z, Complex)
True
But it has no attributes or methods characteristic of complex numbers
>>> z.real
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Complex' object has no attribute 'real'
>>> z.conjugate()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Complex' object has no attribute 'conjugate'
>>> z + z
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'Complex' and 'Complex'
Constructor and Attributes
To manage the addition of attributes real and imag, we could define a function
def Complex_init(z, real, imag):
z.real = real
z.imag = imag
which would us to add the real and imag attributes to an empty complex object
>>> z = Complex()
>>> Complex_init(z, 0.5, 1.5)
>>> z.real
0.5
>>> z.imag
1.5
Possible yes, but not practical! By defining the previous function directly in the
class Complex, and naming it __init__, we define a magic method that is the
constructor associated with the class Complex and avoid this tedious construction.
So, adopting the following definition of Complex
class Complex:
def __init__(z, real, imag):
z.real = real
z.imag = imag
we can save ourselves the creation of an object without attributes, automatically
handled when we call the complex number constructor
>>> z = Complex(0.5, 1.5)
>>> z.real
0.5
>>> z.imag
1.5
The convention when such a method is defined is to call the first argument
of the method self. The variable self shall always refer to
an instance of the class at hand
class Complex:
def __init__(self, real, imag):
self.real = real
self.imag = imag
It’s only a convention, which changes nothing in the behavior of the class
we had defined.
Methods
Adding methods to a class follows the same scheme as the constructor.
Thus, to have a method conjugate that returns the conjugate of a complex
number instance, we can do
class Complex:
def __init__(self, real, imag):
self.real = real
self.imag = imag
def conjugate(self):
return Complex(self.real, -self.imag)
With
>>> z = Complex(0.5, 1.5)
we then have
>>> w = Complex.conjugate(z)
>>> w.real
0.5
>>> w.imag
1.5
Or, since Python automatically handles the creation of methods bound to instances
>>> w = z.conjugate()
>>> w.real
0.5
>>> w.imag
1.5
Magic methods
It is a bit frustrating not to see complex numbers display properly in the terminal at this stage:
>>> Complex(0.5, 1.5) # doctest: +ELLIPSIS
<__main__.Complex object at 0x...>
This is a problem we can solve by defining a magic method __repr__, responsible for
building a suitable representation of instances as strings.
class Complex:
def __init__(self, real, imag):
self.real = real
self.imag = imag
def conjugate(self):
return Complex(self.real, -self.imag)
def __repr__(self):
# ⚠️ weird output when self.imag < 0
return f"({self.real}+{self.imag}j)"
Our representation is now compatible with the literal notation of built-in complex numbers
>>> Complex(0.5, 1.5)
(0.5+1.5j)
Support for arithmetic operations is similar. To have addition, for example, we can do:
class Complex:
def __init__(self, real, imag):
self.real = real
self.imag = imag
def conjugate(self):
return Complex(self.real, -self.imag)
def __repr__(self):
# ⚠️ weird output when self.imag < 0
return f"({self.real}+{self.imag}j)"
def __add__(w, z):
return Complex(w.real+z.real, w.imag+z.imag)
And then
>>> z = Complex(0.5, 1.5)
>>> z + z.conjugate()
(1+0j)
Note that for this type of methods, which accept two instances of the class as arguments,
the convention is to use the names self and other, so prefer the following
(equivalent) definition:
class Complex:
def __init__(self, real, imag):
self.real = real
self.imag = imag
def conjugate(self):
return Complex(self.real, -self.imag)
def __repr__(self):
# ⚠️ weird output when self.imag < 0
return f"({self.real}+{self.imag}j)"
def __add__(self, other):
return Complex(
self.real + other.real,
self.imag + other.imag
)
Private Attributes
Not all attributes of an object are necessarily meant to be public;
we may want private data, for internal use, only usable internally,
by the methods of the object.
The convention in Python is to prefix the name of such attributes
with a single underscore.
It is then possible to control on a case-by-case basis how we allow the outside world to
interact with this data. For example, we can make our complex number ensure that its
real and imaginary parts are floating-point numbers. At this stage there is no such type safety;
it is therefore very easy (including by accident) to create “invalid complex numbers”
that will no doubt be the source of future bugs…
>>> Complex("Hello", "world!")
(Hello+world!j)
But we can fortunately replace the public attributes real and imag with private
attributes _real and _imag and expose these values in a controlled way for reading
and/or writing through dedicated methods: accessors (getters and/or setters).
For example, we can ensure that when we want to set the value of the real or imaginary
part, we first make sure we are dealing with a floating-point number, and when that’s not
the case, we immediately generate an appropriate error. We can even adapt the
constructor to benefit from this additional safety.
Of course, since we have made the real and imaginary parts private,
we will need to provide read access functions so that external
users of complex numbers can use them.
Internally, we need to adapt methods to use private
attributes or accessors, rather than the public attributes that have been removed.
class Complex:
def __init__(self, real, imag):
self.set_real(real)
self.set_imag(imag)
def get_real(self):
return self._real
def set_real(self, real):
if isinstance(real, float):
self._real = real
else:
raise TypeError(f"{real!r} is not a float")
def get_imag(self):
return self._imag
def set_imag(self, imag):
if isinstance(imag, float):
self._imag = imag
else:
raise TypeError(f"{imag!r} is not a float")
def conjugate(self):
return Complex(self._real, -self._imag)
def __repr__(self):
# ⚠️ weird output when self.imag < 0
return f"({self._real}+{self._imag}j)"
def __add__(self, other):
return Complex(
self._real + other._real,
self._imag + other._imag
)
Complex numbers then behave as we expect.
>>> z = Complex(0.5, 1.5)
>>> z
(0.5+1.5j)
>>> z.get_real()
0.5
>>> z.set_real(-0.5)
>>> z
(-0.5+1.5j)
>>> z.set_real("Hello")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 11, in set_real
TypeError: 'Hello' is not a float
Properties
We may regret the syntactic verbosity of accessors compared to access to public
attributes. Fortunately, there is a mechanism that offers the same syntactic
interface as attribute access, but the same security as going through accessors:
properties. These are “virtual” attributes defined by their getter and/or setter.
Thus, if we add the properties real and imag to our implementation of the class Complex
class Complex:
def __init__(self, real, imag):
self.set_real(real)
self.set_imag(imag)
def get_real(self):
return self._real
def set_real(self, real):
if isinstance(real, float):
self._real = real
else:
raise TypeError(f"{real!r} is not a float")
real = property(get_real, set_real)
def get_imag(self):
return self._imag
def set_imag(self, imag):
if isinstance(imag, float):
self._imag = imag
else:
raise TypeError(f"{imag!r} is not a float")
imag = property(get_imag, set_imag)
def conjugate(self):
return Complex(self._real, -self._imag)
def __repr__(self):
# ⚠️ weird output when self.imag < 0
return f"({self._real}+{self._imag}j)"
def __add__(self, other):
return Complex(
self._real + other._real,
self._imag + other._imag
)
We retrieve the simplified usage of access to the real and imaginary parts,
but without having lost the security of type checking for the attributes real and imag.
>>> z = Complex(0.5, 1.5)
>>> z
(0.5+1.5j)
>>> z.real
0.5
>>> z.real = -0.5
>>> z
(-0.5+1.5j)
>>> z.real = "Hello"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 11, in set_real
TypeError: 'Hello' is not a float