Classes and objects#
Education objectives
class, type, objects, attribute, methods
special methods (“dunder”)
OOP and encapsulation
Object-oriented programming: encapsulation#
Python is also an object-oriented language. For some problems, Object-Oriented Programming (OOP) is a very efficient paradigm. Many libraries use it so it is worth understanding what is object oriented programming, when it is useful and how it can be used in Python.
In this notebook, we are just going to consider the OOP notion of encapsulation and won’t study the more complicated concept of inheritance.
Concepts#
Object
An object is an entity that has a state and a behaviour. Objects are the basic elements of object-oriented system.
Class
Classes are “families” of objects. A class is a pattern that describes how objects will be built.
Introduction based on the complex type#
These concepts are so important for Python that we already used many objects and classes.
In particular, str, list and dict are “types”, or “classes”. In Python, these two
names basically means the same. We tend to use “types” for building types and classes for
types defined in libraries or in user code.
We have also used complex to do things like:
complex_number = complex("1j")
Here, we have just instantiated (i.e. create an instance of a class) the builtin type
complex.
We can use the dir function to get its attribute names. We filter out the names
starting by __ since they are special methods.
[name for name in dir(complex_number) if not name.startswith("__")]
['conjugate', 'imag', 'real']
real and imag are simple attributes and conjugate is a method (which can be
called):
complex_number.real
0.0
result = complex_number.conjugate()
result
-1j
We are now going to see how to define our own Complex class.
Attributes and __init__ special method#
You remember that it is better to first define a test function which defines what we want. Let us start with very simple requirements.
def test_complex_attributes(cls):
number = cls("1j")
assert number.imag == 1.0
assert number.real == 0.0
number = cls("1")
assert number.imag == 0.0
assert number.real == 1.0
number = cls(1)
assert number.imag == 0.0
assert number.real == 1.0
We can check if it works with the builtin complex type:
test_complex_attributes(complex)
No assert error indicates that our test is reasonable.
We can start by a too simple implementation
class Complex:
"""Our onw complex class"""
test_complex_attributes(Complex)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[9], line 1
----> 1 test_complex_attributes(Complex)
Cell In[6], line 2, in test_complex_attributes(cls)
1 def test_complex_attributes(cls):
----> 2 number = cls("1j")
3 assert number.imag == 1.0
4 assert number.real == 0.0
TypeError: Complex() takes no arguments
We need to improve our implementation, which can lead to something like:
class Complex:
def __init__(self, obj):
if isinstance(obj, str):
obj = obj.strip()
if obj.endswith("j"):
self.real = 0.0
self.imag = float(obj[:-1])
# warning: early return
return
self.real = float(obj)
self.imag = 0.0
We defined a class with one __init__ method. Note that the methods take as first
argument a variable named self. The name self is just a convention but in practice it
is nearly always used. This first argument is the object used for the call of the method.
Note
We are going to understand that better in few minutes but the __init__ method is really
not adapted to explain this mechanism. So we will first see how this works for a simpler
method and then come back to the __init__ case.
Let us check if this implementation meet our requirements:
test_complex_attributes(Complex)
No assert error mean that this implementation is enough.
Add the conjugate method#
We are new going to focus on the conjugate method with this test:
def test_complex_conjugate(cls):
number = cls("1j").conjugate()
assert number.imag == -1.0
assert number.real == 0.0
test_complex_conjugate(complex)
test_complex_conjugate(Complex)
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In[14], line 1
----> 1 test_complex_conjugate(Complex)
Cell In[12], line 2, in test_complex_conjugate(cls)
1 def test_complex_conjugate(cls):
----> 2 number = cls("1j").conjugate()
3 assert number.imag == -1.0
4 assert number.real == 0.0
AttributeError: 'Complex' object has no attribute 'conjugate'
As expected, we have an exception. Let us modify our Complex class to fix that.
class Complex:
def __init__(self, real=0.0, imag=0.0):
if isinstance(real, str):
real = real.strip()
if real.endswith("j"):
if imag != 0.0:
raise TypeError(
"Complex() can't take second arg if first is a string"
)
self.real = 0.0
self.imag = float(real[:-1])
return
self.real = float(real)
self.imag = imag
def conjugate(self):
"""Return the complex conjugate of its argument."""
return Complex(real=self.real, imag=-self.imag)
Let’s check if it is sufficient:
test_complex_conjugate(Complex)
Note
Numbers in Python are immutable. Complex.conjugate returns a
new object and does not modify the object used for the call.
We can now come back to this weird self argument and note that:
number = Complex(imag=4)
assert Complex.conjugate(number).imag == -number.imag
assert number.conjugate().imag == -number.imag
Important
We now understand the purpose of the first argument of a method (self). It is the
object with whom the method is called.
Special (“dunder”) methods#
Special methods (also known as “dunder methods”) are methods whose name starts with __.
They are used to define how objects behave in specific situations. Python objects have a
lot of dunder methods:
complex_number = complex(imag=2)
[name for name in dir(complex_number) if name.startswith("__")]
['__abs__',
'__add__',
'__bool__',
'__class__',
'__complex__',
'__delattr__',
'__dir__',
'__doc__',
'__eq__',
'__format__',
'__ge__',
'__getattribute__',
'__getnewargs__',
'__getstate__',
'__gt__',
'__hash__',
'__init__',
'__init_subclass__',
'__le__',
'__lt__',
'__mul__',
'__ne__',
'__neg__',
'__new__',
'__pos__',
'__pow__',
'__radd__',
'__reduce__',
'__reduce_ex__',
'__repr__',
'__rmul__',
'__rpow__',
'__rsub__',
'__rtruediv__',
'__setattr__',
'__sizeof__',
'__str__',
'__sub__',
'__subclasshook__',
'__truediv__']
Let’s now print a complex object. In IPython, we can just use its name for the last
instruction of a cell:
complex_number
2j
Note that we can get the string used by IPython by calling the builtin function str:
str(complex_number)
'2j'
Or by directly calling the special method __str__ of the object:
complex_number.__str__()
'2j'
This actually approximately what happens when we just write complex_number. IPython
produces a string with str(complex_number) and the str function calls
complex_number.__str__().
Let us see what happens for our object:
number = Complex(imag=2)
number
<__main__.Complex at 0x7f91f0436450>
Hum, not great. So we should write a test about this behaviour.
def test_complex_str(cls):
number = cls(imag=2)
assert str(number) == "2j"
Does it pass with the builtin complex type?
test_complex_str(complex)
Does it fail with our onw type?
test_complex_str(Complex)
---------------------------------------------------------------------------
AssertionError Traceback (most recent call last)
Cell In[25], line 1
----> 1 test_complex_str(Complex)
Cell In[23], line 3, in test_complex_str(cls)
1 def test_complex_str(cls):
2 number = cls(imag=2)
----> 3 assert str(number) == "2j"
AssertionError:
Good, let’s work on this:
class Complex:
def __init__(self, real=0.0, imag=0.0):
if isinstance(real, str):
real = real.strip()
if real.endswith("j"):
if imag != 0.0:
raise TypeError(
"Complex() can't take second arg if first is a string"
)
self.real = 0.0
self.imag = float(real[:-1])
return
self.real = float(real)
self.imag = imag
def conjugate(self):
"""Return the complex conjugate of its argument."""
return Complex(real=self.real, imag=-self.imag)
def __str__(self):
if self.real == 0.0:
return f"{self.imag}j"
return f"{self.real} + {self.imag}j"
Does it work better now?
test_complex_str(Complex)
Note on test coverage problem
Note that the last line of the class definition is not tested
(return f"{self.real} + {self.imag}j"). This is bad. It could be badly modified and the
tests would still pass. For real life code, one can consider and try to maximize the test
coverage, which is approximately defined as the percentage of lines covered by some tests.
Difference between __str__ and __repr__
We don’t care too much at this point, but these two different special methods exist. In few words:
The goal of
__str__is to be readable.__repr__has to be unambiguous.
Back to the __init__ special method#
number = Complex("1j")
is actually equivalent to:
# create a non-initialized object
# (no need to study and understand this line)
number = Complex.__new__(Complex)
# initialization of the object
number.__init__("1j")
You should now understand that the last line is equivalent to:
Complex.__init__(number, "1j")