tds,

Beginning in Python

Luc Luc Follow on Github Aug 22, 2020 · 82 mins read
Beginning in Python
Share this

Table Of Contents

Table Of Contents


1st course will mainly focus on how to approach Python for the first time. For that we will use Jupyter module.

“In Python, everything is an object”

This is a very well-known sentence but I think it is the best, along with some examples, to start getting a good grasp of the language. In Python, everything is an object, according to the creator of Python, Guido van Rossum:

One of my goals for Python was to make it so that all objects were “first class.” By this, I meant that I wanted all objects that could be named in the language (e.g., integers, strings, functions, classes, modules, methods, etc.) to have equal status. That is, they can be assigned to variables, placed in lists, stored in dictionaries, passed as arguments, and so forth”s

A dynamically-typed language

Let’s dive-in a bit, and then experiment from there.

a = 2

Here a is a name, refering to an integer of value 2, which is also (spoil alert) an object.

Note that we didn’t have to declare a memory space holding this data type like in C syntax:

int a; /* creating a memory space allowing only holding data of 
integer type.*/

a = 3 /* storing the value 3; not a string, not a list, but an integer as asked.*/

C is said statically typed: type of a is already known and constrained at compile time

in Python, a = 2 first creates an object of value 2 at a certain memory space, and then links the name a to that object location during assignment. a is then bound to that object by pointing to its memory location.

a variable can then change of type during its lifetime, as its simply a pointer !

a will be simply redirecting to another object of different data type.

The type is then not associated to a but to the run-time values, Python is then dynamically-typed.

Hence i can write:

a = 30

then

a = "Bonjour"

the type is checked only at runtime, hence making Python dynamically-typed. (If the line is not read, the types are not checked).

if False:
    2+"25" # should raise an error, but as the condition is not entered, no types are checked here

same happens during function definition and not execution

An id, a type, a value

In Python, everything is an object, each object has 3 core elements:

  • an id
  • a type
  • a value

Every object has an identity, a type and a value

ìd() is a built-in function that can show us the memory location of the object (at least for CPython implementation), and is certified to be unique to an object, and still during the lifetime of this object

Hence, in Python, everything is an object, hence every object has an identity.

id(a)
4754628528
import sys
sys.getrefcount(a)
2

Note that if an object (example: the object “Bonjour” is not linked anymore by any names (a etc), then it can be garbage-collected.

A counter sys.getrefcount(X) is used to keep track of all the references to a given object “X”.

b = a
id(b)
4754628528

pointing to the same location…

a = 2
a = "Yo"

All objects have a value, a is linked here by the last binding statement. It is now refering to the object, this object has a certain id different from the previous one, and has a value "Yo".

“Yo” embeds a certain datatype, also called a type.
Any Python object has a type.
The type of the object of value “Yo” is a string.

Let’s check the type of both of these 2 objects:

print(type(2)); print(type("Bonjour"))
<class 'int'>
<class 'str'>
type("bonjour")
str

2 is value of an object of type integer.

“Bonjour” is the value of an object of type string.

If you see “class” in the print statement, you can interchangeably say that the class or type of the object of value 2 is integer, as type and class in Python3 has been unified concepts, see also here

You can also say that the object of value 2 is an instance of the integer class. By the way, the integer class itself is an object as everything is object in Python (check out the role of metaclasses if interested !).

Onwards Object-Oriented Programming: attributes of an object

Objects in Python have an id, a type and a value.
Objects may also have attributes related to them, and by “attributes” I refer to both OOP-attributes (variables) and methods in the frame of the object-oriented paradigm, related to an object.
Let’s talk about dir() first, it is a built-in function (we don’t need to import a python package to call this function).
With an argument (here a), it returns a list of valid “attributes” for that object.

print( dir(a) )
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__', '__init__', '__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__', 'as_integer_ratio', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'numerator', 'real', 'to_bytes']
  • a refers to an object, and this object, designed by a, has been passed as argument to the dir function
  • Also, it seems dir() returns a certain number of ‘things’ for this parameter. The object that a refers to, (we will later call object “a” for convenience), has then a number of “attributes” related to it.
    • functions: which in OOP (object-oriented programming) are also known as (attributes) methods,
    • or variables also known as (attributes) variables

We get closer to the definition of an object in OOP !

Let’s check some methods here. It evens seems a contains a __dir__() method we can access doing: a.__dir__()

We used sorted() built-in function to sort alphabetically the content of the list outputed by a.__dir__()

print(sorted(a.__dir__()))
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__', '__init__', '__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__', 'as_integer_ratio', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'numerator', 'real', 'to_bytes']

The outputs are the same !
dir(a) actually call internally a.__dir__() associated to the object a !

We get closer to the initial meaning Rossum has defined by all objects are first-class objects (see related link).

Let’s resume our investigations…

print(dir(2))
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__', '__init__', '__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__', 'as_integer_ratio', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'numerator', 'real', 'to_bytes']

Not the same attributes and methods here…

Although 2 and “Bonjour” are both objects, with different ids and values, they seem to also have different methods associated to them, depending on their datatype.

Let’s try the method lower() in the methods found by doing

a = "Bonjour"
dir(a)

This method is relative to all strings object.

We can access it by using the “dot” notation after the object refered by a, so to access the method that applies on the object refered by a.

a.lower
<function str.lower()>

we can see it is a function, so we need to use the parenthesis.

a.lower()
'yo'

it is a special kind of function though, because it is applied on its corresponding object refered by a, which is an instance of str. This function is also called a method. And this method is already bound to instance a

Back to the first link article i’ve pinned where Rossum describes he wanted all objects to be “first classes”, he highlighted a very interesting conception issue raised with respect to bound and unbound methods; although deprecated in Python3, you should have a look at it anyway.

method_ = a.lower
method_()
'yo'
type(a)
str

We could also do:

unbound_method_ = str.lower 
# <the_class>.<the_method_defined_in_class_body>
unbound_method_(a)
'yo'

Note that str is called a built-in type, in CPython implementation, str is a C struct. Just like int, list, dict, tuple, set, and others

Primitive built-in types.

Strings

Let’s start with strings. A string is a sequence of characters.

You can create it by simply writing it.

"hello-world"

You could have also created it using the str() constructor (more on that on chapter Classes).

str("hello-world")

You can also bind a name/variable to that string ‘first-class’ object:

string1 = "hello-world"

Then later referring to the string by using the variable string1.

string1
Out[1]: "hello-world"

string1 refers to an object, it has a id, type (str) and a value “hello-world”, but more useful, maybe some methods associated to it ? We can check that using dir(string1) and call one of those particular method respective to this object type.

string1.capitalize() # to capitalize the world
'Hello-world'

or another one:

string1.upper()
'HELLO-WORLD'

or another one:

string1.lower()
'hello-world'

or another one:

string1.replace('l', 'a')
'heaao-worad'

or another one:

voiciunstring.count("l")
3

A string being a sequence of characters, i can select one precise character using the indexing notation, that is, with brackets [index]

string1[1]
'e'

Note that Python is 0-indexed, hence the first character in the string is found doing:

string1[0]

You can also select the last index:

string1[-1]
'd'

Or the last minux n index by the more generalized notation:

n = 2 # 1 before last last index
string1[-n]

a string (of characters) having a length, you could have also done, to retrieve the last element:

length = len(string1)
string1[length-1] # recall that python is 0-indexed, hence the number of elements minus 1

You can change a particular character at certain index:

string1[-1] = 'e'
'hello-worle'

We can also select a specific range of characters using slicing notation, that is, using brackets [start_index:stop_index:step_index]:

the stop index is excluded, the step-index is optional, we will talk about it more on the chapter on Lists.

string1[3:5]
'he'
voiciunstring[1:5]
'ello'
voiciunstring[1:10:2]
'el-ol'

You can also use some arithmetic expression such as “+”

string1 + string2

This has the behavvior to concatenate strings. This is the same as the internal call:

string1.__add__(string2)

this is the same method as for integers ! but hte behavior (concatenation) is different from the latter (addition) !

Note: In Jupyter Notebook, after the ‘.’ you can press Tab for showing some autocomplete suggestions.

After writing the entire attribute name, a press on Shift + Tab display information about this attribute, what it is, what it does.

Lists

Lists are the first sequence of objects we cover.

[]
[]

You could have also created it using the list() constructor (more on that on chapter Classes).

list()
[]

You can also bind a name/variable to that list, being a ‘first-class’ object:

uneliste = [] # or liste1 = list()

Arbitrary typed Python objects can be items of a list:

uneliste = [2,3,4,"a"]
uneliste
[2, 3, 4, 'a']

Using some list methods as example

uneliste refers to an object, it has a id, type and value, but more useful, maybe some methods associated to it ? We can check that using dir(uneliste) and call one of those particular method respective to this object type.

I can access to some methods or attributes of list:

uneliste.append(3) ## append the list with object integer 3
uneliste
[2, 3, 4, 'a', 3]

or another one:

uneliste.reverse()
uneliste
[3, 'a', 4, 3, 2]

or another one:

uneliste=[3,2,4,5]
uneliste.sort()
uneliste
[2, 3, 4, 5]

List manipulations

Indexing

You can access to a particular item in the sequence of items that caracterizes the list type:

item_number = 2
uneliste[item_number]
4

Warning: Python is 0-indexed.

Using an index to high (where no such item exist for that index in the list) results in an IndexError:

uneliste[10]
---------------------------------------------------------------------------

IndexError                                Traceback (most recent call last)

<ipython-input-300-b5e08874184b> in <module>
----> 1 uneliste[10]


IndexError: list index out of range

You can also start from the end

uneliste[-1]
3

Or use the len() function, that internally calls method __len__.

len(uneliste)
6
uneliste.__len__()
6

Anything familiar with what said before ?

By the way:

type(uneliste.__len__)
method-wrapper

from Martijn Pieters, in a Stackoverflow thread method-wrapper description: The method-wrapper object is wrapping a C function. It binds together an instance (here a function instance, defined in a C struct) and a C function, so that when you call it, the right instance information is passed to the C function

By the way, the uneliste[index] calls the lower-level __getitem__() method.

We can check it there:

"__getitem__" in dir(list)
True

Hence i can do:

uneliste.__getitem__(2)
47

or even (as we did for str.lower)

list.__getitem__(uneliste, 2)
47

Beautiful, isn’t it ?

You can assign another object to a particular index:

##### assigning a value:
uneliste[2] = 25
uneliste
[2, 3, 25, 'a', 3]

The same way, uneliste[index] = value calls internally setitem() method:

"__setitem__" in dir(list)
True
uneliste.__setitem__
<method-wrapper '__setitem__' of list object at 0x11bbff640>
uneliste
[25, 2, 47, 13, 17, 11, 9, 8]
uneliste.__setitem__(0, 2)
uneliste
[2, 2, 47, 13, 17, 11, 9, 8]

Changing an item object by another one in the list did not recreate a list object, this can be shown looking at the memory address of the list instance object, denoted by id, before and after the change of one of its element.

This is called a mutable object, we will talk about that later on what does it imply.

slicing

Using slicing we can have access to a specified range of elements in the sequence

[start:stop[:step]]

Warning: stop is exclusive !

uneliste=[25,2,47,13,17,11,9,8]
uneliste[0:3] # stopped at index 2 (3 excluded)
[25, 2, 47]

Then notice than using indexing

uneliste[len(uneliste)]
---------------------------------------------------------------------------

IndexError                                Traceback (most recent call last)

<ipython-input-437-c39fd7ff38ae> in <module>
----> 1 uneliste[len(uneliste)]


IndexError: list index out of range

But using slicing:

uneliste[:len(uneliste)]
[25, 2, 47, 13, 17, 11, 9, 8]

More complicated example:

Start from 6th element (using 5 because 0-indexed) and finish at 2 by step -1, element of index 2 is excluded

uneliste[5:2:-1]
[11, 17, 13]

This:

uneliste[::]
[25, 2, 47, 13, 17, 11, 9, 8]

can be written also

uneliste[:]
[25, 2, 47, 13, 17, 11, 9, 8]

but does have a slight difference from:

uneliste
[25, 2, 47, 13, 17, 11, 9, 8]

it makes a copy of the list, returning an other object, at a different memory location

uneliste[2:4:2] # index 4 is excluded, remember...
[47]

You can also do assignement while slicing a list, but the assigned iterable must be of same length of the number of items it is replacing

uneliste[2:4:2] = [1700]
uneliste
[25, 2, 1700, 13, 17, 11, 9, 8]
uneliste[2:5:2] = [12334,13949]

the list on the right hand side of the statement must contain the same number of items as the slice it is replacing

uneliste
[25, 2, 12334, 13, 13949, 11, 9, 8]

You can also use slice object

uneliste[slice(1,4,2)]
[3, 'a']

Slice objects are actually created when using the start:stop:step notation

You can create a list from any iterable sequences (range, tuple, etc.)

list(range(1,8+1))
[1, 2, 3, 4, 5, 6, 7, 8]

More on this on functionnal programming chapter

Tuples

This is the second sequence of objects we cover.

untuple = tuple()
untuple
()
type(untuple)
tuple
untuple = (1,2,3,4,5,6,7,8)
untuple
(1, 2, 3, 4, 5, 6, 7, 8)
untuple = ("a",2)
untuple
('a', 2)
untuple[0]
'a'
try:   
    untuple[1] = 35
except Exception as e:
    print(e)
'tuple' object does not support item assignment

tuple object does not support item assignement and is a member of the immutables family.

Changing a tuple after creation is not possible, only recreating a new tuple is.

So why using tuple if is a kind of “castrated” list ?

one word on mutability / immutability

a = 4
id(a)
4430101024
y = 4
id(y), id(4)
(4430101024, 4430101024)
a+=1
id(a), id(y)
(4430101056, 4430101024)
zeta = 257
id(257), id(zeta)
(4759839312, 4759839728)
b = zeta
id(b)
4759839728

sur une liste

liste = [2,3,'a']
liste
[2, 3, 'a']
id(liste)
4759836992
def change(une_liste_en_param):
    une_liste_en_param+=[13]
change(liste)
liste
[2, 3, 'a', 13]
id(liste)
4759836992

Inplace modifications for a list didn’t change the address location for that list..

A list is then a mutable.

  • mutable objects can be changed after their creation,
  • immutable objects can’t.

  • **Common mutable Objects:** list, set, dict, user-defined class
  • **Common immutable objects:** int, float, bool, string, tuple, frozenset, range

Sets

set([1,2,3])
{1, 2, 3}
try:
    set(3)
except Exception as e:
    print(e)
'int' object is not iterable
set(range(1,3))
{1, 2}
un_set = set([1,2,3,4,5])
un_autre_set = set([3,2,9,1,4])
  • common elements between (intersection)
un_set & un_autre_set
{1, 2, 3, 4}
  • all elements from the 2 sets (union)
un_set | un_autre_set
{1, 2, 3, 4, 5, 9}
  • distincts elements (contrary of commons / one not in the other and vice-versa)
un_set ^ un_autre_set
{5, 9}
  • distincts elements unilateraly (depends on order from the operation)
un_set - un_autre_set
{5}
un_autre_set - un_set
{9}
  • does a set is a subect of another (without being sames)
un_nouveau_set = set([1,2,3])
encore_un = set([1,2,3,4,5])
un_nouveau_set < un_set
True
encore_un < un_set
False

Dictionaries

A dictionary is a collection of key:value pairs

Python docs definition: An associative array, where arbitrary keys are mapped to values.

Operations associated to dictionaries:

- add a new key:val pair
- delete a key:val pair
- modify val for a given key
- look for val from key in dict

An implementation of an hash-table

a = ("bonjour",2)
b = ("bonjour",2)
a is b
False
a == b
True
id(a) == id(b)
False
hash(a) == hash(b)
True

Hash values are based on values, not the id (except for user-defined classes):

They identify a particular value, independently if it is the same object or not

Two objects that compare equal ( == ) must also have the same hash value

Python docs: Numeric values that compare equal have the same hash value (even if they are of different types, as is the case for 1 and 1.0).

hashes for dict look-ups

Hash values are mostly used in dictionnary lookups to quicky compare dictionary keys.

Should you try to find if a value is in the list, a tuple, or a character in a string, a linear search 0(N) would be operated as you need to go through the entire list by creating an iterator out of it to find a specified matching value.

stackoverflow: x in y calls y.contains(x) if y has a contains member function. Otherwise, x in y tries iterating through y.iter() to find x, or calls y.getitem(x) if iter doesn’t exist.

For dictionaries and sets though, data structures using hash-table, the search time is 0(1)

sometimes collisions occur

hash(-1) == hash(-2)
True
-1 == -2
False

mondico = dict()
mondico
{}
mondico = {}
mondico
{}
mondico = { 
    1: "moi",
    2: "toi",
    3: "moi à nouveau",
    4: "nous"
}
mondico
{1: 'moi', 2: 'toi', 3: 'moi à nouveau', 4: 'nous'}
dico_des_contacts = {
    "Marie": "0666102030",
    "René" : "0710212121",
    "Julien": "0820202020"
}

An associative array with defined base operations

the lookup of a value associated with a particular key

look for val from key in dict

dico_des_contacts["Marie"]
'0666102030'
mondico[3]
'moi à nouveau'

the modification of an existing pair)

modify val for a given key

mondico[3] = "finalement non"
mondico
{1: 'moi', 2: 'toi', 3: 'finalement non', 4: 'nous'}

the addition of a pair to the collection

add a new key:val pair

mondico["Jean-Yves"] = "987654"
mondico
{1: 'moi', 2: 'toi', 3: 'finalement non', 4: 'nous', 'Jean-Yves': '987654'}
try:
    dico_des_contacts = {
        uneliste : "123"
    }
except:
    print("ça n'a pas marché")
ça n'a pas marché

the removal of a pair from the collection

delete a key:val pair

del mondico["Jean-Yves"]
mondico
{1: 'moi', 2: 'toi', 3: 'finalement non', 4: 'nous'}

Returning to the question hashability of keys

mondico[ [1, 2] ] = 2
---------------------------------------------------------------------------

TypeError                                 Traceback (most recent call last)

<ipython-input-524-701697dd0a93> in <module>
----> 1 mondico[ [1, 2] ] = 2


TypeError: unhashable type: 'list'

Why is that error ? Can’t we define a key of [1,2]

a dictionary requires its keys to be hashable, as it uses under the hood an hash table.

This is a good explanation why hashable keys are required and what can occur if we try to play a bit around.

Another useful link.

“Most” objects are hashable. By most, we have to cover the case of a tuple, immutable type, where lies a list within:

tuple_ = (1, 2, [3,4] )
id(tuple_)
4757002816

as list is mutable, we can change any value inside of the list within the tuple

tuple_[2][1] = 190
tuple_
(1, 2, [3, 190])
id(tuple_)
4757002816

the id hasn’t change, as no tuple as been created (immutable),

but the values it contains cannot guarantee it reflect the previous tuple_ anymore

hash(tuple_)
---------------------------------------------------------------------------

TypeError                                 Traceback (most recent call last)

<ipython-input-650-7d1b7933af29> in <module>
----> 1 hash(tuple_)


TypeError: unhashable type: 'list'

Hashing is not possible anymore though, as it is not guaranted the object values won’t change over time

All mutable objects, hence that can be modified over time, aren’t hashable

During lookup, the key is hashed and the resulting hash indicates where the corresponding value is stored

Note: A set object is an unordered collection of distinct hashable objects hence set((1.0, 1)) will result in {1} as 1.0 and 1 share the same hash value

Here is a good explanation of how hashing and open adressing works in CPython

hash(2**1000) == hash(16777216)
True
16777216%8
0
my_dict.__get__(0)
---------------------------------------------------------------------------

AttributeError                            Traceback (most recent call last)

<ipython-input-710-d6b1271c2048> in <module>
----> 1 my_dict.__get__(0)


AttributeError: 'dict' object has no attribute '__get__'
my_dict ={}
my_dict[2**1000] = "One"
my_dict[16777216] = "Two"
my_dict
{10715086071862673209484250490600018105614048117055336074437503883703510511249361224931983788156958581275946729175531468251871452856923140435984577574698574803934567774824230985421074605062371141877954182153046474983581941267398767559165543946077062914571196477686542167660429831652624386837205668069376: 'One',
 16777216: 'Two'}
my_dict.
{10715086071862673209484250490600018105614048117055336074437503883703510511249361224931983788156958581275946729175531468251871452856923140435984577574698574803934567774824230985421074605062371141877954182153046474983581941267398767559165543946077062914571196477686542167660429831652624386837205668069376: 'One',
 16777216: 'Two'}
newlist = List([1,2,3])
my_dict[newlist] = "Three"
my_dict
{10715086071862673209484250490600018105614048117055336074437503883703510511249361224931983788156958581275946729175531468251871452856923140435984577574698574803934567774824230985421074605062371141877954182153046474983581941267398767559165543946077062914571196477686542167660429831652624386837205668069376: 'One',
 16777216: 'Two',
 [1, 2, 3]: 'Three'}
newlist = List([2,2,2])
newlist.remove(2); newlist.remove(2); newlist.append(4)
newlist
[2, 4]
my_dict[newlist] = "Five"
my_dict
{10715086071862673209484250490600018105614048117055336074437503883703510511249361224931983788156958581275946729175531468251871452856923140435984577574698574803934567774824230985421074605062371141877954182153046474983581941267398767559165543946077062914571196477686542167660429831652624386837205668069376: 'One',
 16777216: 'Two',
 [1, 2, 3]: 'Three',
 [2, 4]: 'Five'}

An example of subclass of dict: Orderdict

Sometimes it is interesting to play with higher-level data structures, that is, data structures leaning on lower-level ones to add either set of functionalities or behaviors.

dict subclass that remembers the order entries were added

from collections import OrderedDict
OrderedDict.__bases__
(dict,)

OrderedDict CPython implementation!

hash("a")
1052182404982694077

Booleans

e = bool()
e
False
e = True
type(e), e
(bool, True)

tester que e soit égal (pas assignement)

e==True
True
a = 5
b = 6
c = 5
print( a == 5)
print( a == b)
print( a == c)
True
False
True

Boolean inherits from integer !

bool.__bases__
(int,)

Hence,

True *18
18
False *2 + True*18
18
False == 0
True
True == 1
True

by the way hash(False) == hash(0) == 0

my_dict[True] = 25
my_dict[1] = 29
my_dict
{10715086071862673209484250490600018105614048117055336074437503883703510511249361224931983788156958581275946729175531468251871452856923140435984577574698574803934567774824230985421074605062371141877954182153046474983581941267398767559165543946077062914571196477686542167660429831652624386837205668069376: 'One',
 16777216: 'Two',
 [1, 2, 3]: 'Three',
 [2, 4]: 'Five',
 True: 29}

Loops

while (condition-based loop) and for

a=3
while a<10:
    a+=1
    print(a)
4
5
6
7
8
9
10
for a in range(10):
    print(a)
0
1
2
3
4
5
6
7
8
9
for element in ["a", 3, 45]:
    print(element, type(element))
a <class 'str'>
3 <class 'int'>
45 <class 'int'>
for i in range(2,5):
    print(uneliste[i])
3456789
4
5
  • Loop on dict
dico_des_contacts
{'Marie': '0666102030', 'René': '0710212121', 'Julien': '0820202020'}
for element in dico_des_contacts.keys():
    print(element)
Marie
René
Julien
for element in dico_des_contacts.values():
    print(element)
0666102030
0710212121
0820202020
for element in dico_des_contacts:
    print(element)
    print(dico_des_contacts[element])
Marie
0666102030
René
0710212121
Julien
0820202020
for element in enumerate(dico_des_contacts.values()):
    print(element)
(0, '0666102030')
(1, '0710212121')
(2, '0820202020')
for tuple_ in dico_des_contacts.items():
    print(tuple_)
('Marie', '0666102030')
('René', '0710212121')
('Julien', '0820202020')

A nice feature: Iterable unpacking (here on a tuple)

a, b = (1, 2)
a
1
b
2

PEP 3132: extended Iterable unpacking

a, *b = (1, 2, 3, 4)
a
1
b
[2, 3, 4]
a, *b, c = (1, 2, 3, 4)
print(b)
[2, 3]

This can also be done to unpack collections of tuples

an example from the docs directy

for a, *b in [(1, 2, 3), (4, 5, 6, 7)]:
    print(b)
[2, 3]
[5, 6, 7]

Hence, one can loop on a dict from this:

for tuple_ in dico_des_contacts.items():
    print(tuple_)
('Marie', '0666102030')
('René', '0710212121')
('Julien', '0820202020')

To

for key, value in dico_des_contacts.items():
    print(key) 
    print(value)
Marie
0666102030
René
0710212121
Julien
0820202020

Functions

Function definition and function call(s)

  • Defining a function: function may or may not have parameters, can return a value but are not forced too.

Here is an example of a function that has a parameter, and return a value.

def mafonction(a):
    return a**2

and a function with some description (also named a docstring) of what it does, as it is always a good practice to comment your code:

def mafonction(a):
    """This is a doctstring, it is a description to let
    the user know what your function does
    it's a string literal that can be found 
    on top of a function, a module or a class.
    At runtime, it is detected by python Bytecode and assigned to 
    object.__doc__, you can then use Tab keys and Shift in Jupyter
    to see in work, cool isn't it ? check PEP257😉
    You can later find me as the attribute mafonction.__doc__
    """
    return a**2
  • calling a function: you call call it once, twice, or more, passing-in an argument for the corresponding function parameter.
mafonction(9)
81

Terminology alert here: A parameter is a variable in the function definition. Just like in Maths. An argument is the passed-in value at function call for that parameter.

  • for short and simple functions, one call use lambda notation/functions
mafonction = lambda x: x**2
mafonction(11)
121
  • Put default arguments for any parameter in the functions
def mafonction2(a=5):
    return a**2
mafonction2()
25

**Note:** default arguments always follows non-default arguments:

def mafonction3(a,b,c=2, d):
    return a+b+c+d
  File "<ipython-input-806-1a1f779ed717>", line 1
    def mafonction3(a,b,c=2, d):
                    ^
SyntaxError: non-default argument follows default argument

Iterable unpacking in function call

Say we have this function:

def acomplicatedcalculus(a,b,c,d,e,f=23):
    return a + b * c - d*e*f

Let’s say we have a list:

mylistargs = [1,2,3,4,5,6]

What if we want to avoid specifying manually each parameter and simply want to parse the list elements as arguments to the function call ?

Recall iterable unpacking ? in a similar fashion (although the syntax a bit different), we can use it in the function call:

acomplicatedcalculus(*mylistargs)
-113

This equals doing:

acomplicatedcalculus(mylistargs[0], mylistargs[1], ...)

hence this:

acomplicatedcalculus(1, 2, 3, 4, 5, 6)

Note: this also of course overwrote the final argument f which has a default argument value by the same value 6. You can skip last argument manipulating iterable unpacking:

*mylistreduced, rest = mylistargs

and iterable unpacking inside the function call:

acomplicatedcalculus(*mylistreduced)
-453

you can also as of PEP448 (Additional Unpacking Generalizations) you can also unpack multiple iterables in function call i.e.

liste1 = [1,2]
liste2 = [3,4]
liste3 = [5,6]

Then:

acomplicatedcalculus(*liste1, *liste2, *liste3)
-113

Not that this unpacking is based on arguments positions: i.e. this:

mylistargs = [1,2,3,4,5,6]
acomplicatedcalculus(*mylistargs)

is different from that:

mylistargs = [6,5,4,3,2,1]
acomplicatedcalculus(*mylistargs)

We can also provide a smarter unpacking based on keywords and not positions, also known as positional arguments, using the double-star unpacking notation using dictionaries:

dictargs1 = {'b':2, 'a':1, 'c':3}
dictargs2 = {'f':6, 'e': 5, 'd':4}
# (+ with Additional Unpacking Generalizations)
acomplicatedcalculus(**dictargs1, **dictargs2)
-113

Hurra ! the elements in the sequence has been included according to the keys of the dictionnary Position does not matter here. Only the keyword to value association.

Hence, not that

# (+ with Additional Unpacking Generalizations)
acomplicatedcalculus(**dictargs2, **dictargs1)

is same as:

# (+ with Additional Unpacking Generalizations)
acomplicatedcalculus(**dictargs2, **dictargs1)

is same also as keys in a different order, even in the same dictionary.

Though you cannot specify the same keyword argument twice (here defined in another dictionary in the additional unpacking generalization).

dictargs1 = {'b':2, 'a':1, 'c':3}
dictargs2 = {'f':6, 'e': 5, 'd':4}
dictargs2['b'] = 325
acomplicatedcalculus(**dictargs1, **dictargs2)
---------------------------------------------------------------------------

TypeError                                 Traceback (most recent call last)

<ipython-input-827-d48aa343e588> in <module>
----> 1 acomplicatedcalculus(**dictargs1, **dictargs2)


TypeError: acomplicatedcalculus() got multiple values for keyword argument 'b'

Same error if we were to do:

acomplicatedcalculus(**dictargs1, **dictargs2, b=25)

Although this would work, if b was not defined in dictargs1.

**Note2:** ** unpackings follows ** unpackings, not the other way:

This works: acomplicatedcalculus(**{"a":25, "b":17, "e":25, "d":14}, c=25), This does not: acomplicatedcalculus(**{"a":25, "b":17, "e":25, "d":14}, 25)

Function definitions and tuple packing

What about the other way? Instead of unpacking a variable length sequence of elements into positional or keywords arguments at function call, you can also create functions as containing an undefined number of arguments, positional, and/or keywords.

def newfunction(*args):
    somme = 0
    for arg in args:
        somme += arg
    return somme

Now our function is flexible in its number of positional inputs:

newfunction(1,2,3,4,5), newfunction(1,2,3)
(15, 6)

Inside newfunction, args becomes a tuple of provided positional inputs arguments. Its packs those arguments defined in:

newfunction(1,2,3,4,5)

as if you were to do:

*args, = (1,2,3,4,5)

which leads to args being a tuple of all those elements:

args = 
    (1,2,3,4,5)

In the same frame,

def newfunction2(number, **kwargs):
    if kwargs.get("inverse", False):
        number = 1/number
    if kwargs.get("negative", False):
        number = - number
    return number

Inside newfunction2, kwargs becomes a dict of provided keyword inputs arguments. Its packs those arguments defined in:

newfunction2(2, inverse=True, negation=True, gamma=True)
-0.5

as if you were (conceptually, as this would not work running this code below) to do:

**kwargs, =  inverse=True, negation=True, gamma=True

which leads to kwargs being a tuple of all those elements:

kwargs = { 'inverse':True, 'negation':True, 'gamma':True  }

Hence it is free to you to actually manage or not the passed-in keywords arguments just like in `newfunction2.

Type hints

Starting PEP484, Python 3.5 you can add type hints (it is just hints, not forced, but yçou can use a type checking tool for that)

def power(to_be_powered: int, by: int = 2) -> int:
    for i in range(1, by):
        to_be_powered *= to_be_powered
    return to_be_powered
power(3, 4)
6561
pow(base=2, exp=4)
16
def name(arg1, arg2, /,key,*, key1, key2=''):
    """positional_only / pos_or_keyword_args * keywords only"""
    return arg1+arg2+"    "+key1+key2
name("a", "7", "test", key1="2")
'a7    2'

List comprehension

uneliste
[2, 2, 47, 13, 17, 11, 9, 8]
[ x**2 for x in uneliste ]
[4, 4, 2209, 169, 289, 121, 81, 64]
[ element for element in uneliste if element>7]
[47, 13, 17, 11, 9, 8]
[ x**2 for x in uneliste if x>7]
[2209, 169, 289, 121, 81, 64]
[ x**2 if x>7 else x-4 for x in uneliste]
[-2, -2, 2209, 169, 289, 121, 81, 64]
unelistemodifiee = [ x**2 for x in uneliste ]
print(uneliste)
print(unelistemodifiee)
[2, 2, 47, 13, 17, 11, 9, 8]
[4, 4, 2209, 169, 289, 121, 81, 64]

cartesian product

[x+y for x in [2,3,4] for y in [10,100,1000]]
[12, 102, 1002, 13, 103, 1003, 14, 104, 1004]
[x+y for x in [2,3,4] if x>2 for y in [10,100,1000]]
[13, 103, 1003, 14, 104, 1004]
[x+y for x in [2,3,4] if x>2 for y in [10,100,1000] if y>100]
[1003, 1004]

Dict comprehension

dico_des_contacts
{'Marie': '0666102030', 'René': '0710212121', 'Julien': '0820202020'}
{ cle:valeur for cle,valeur in dico_des_contacts.items()}
{'Marie': '0666102030', 'René': '0710212121', 'Julien': '0820202020'}
mondico2 = {"a":1, "b":2, "c":3}
mondico2
{'a': 1, 'b': 2, 'c': 3}
{ cle:valeur*2 for cle, valeur in mondico2.items()}
{'a': 2, 'b': 4, 'c': 6}
{ cle:valeur*2 for cle, valeur in mondico2.items() if cle=='b'}
{'b': 4}
{ cle:valeur*2 for cle, valeur in mondico2.items() if valeur > 1}
{'b': 4, 'c': 6}

Exercice: take phone number only for name starting with ‘R’

dico_des_contacts['Renard'] = "0678899099"
dico_des_contacts
{'Marie': '0666102030',
 'René': '0710212121',
 'Julien': '0820202020',
 'Renard': '0678899099'}
{ cle:valeur for cle, valeur in dico_des_contacts.items() if cle[0] == "R" }
{'René': '0710212121', 'Renard': '0678899099'}
{ cle:valeur for cle, valeur in dico_des_contacts.items() if cle.startswith("R")}
{'René': '0710212121', 'Renard': '0678899099'}
dico_des_contacts['remi'] = "067234099"
{ cle:valeur for cle, valeur in dico_des_contacts.items() if cle.lower().startswith("r")}
{'René': '0710212121', 'Renard': '0678899099', 'remi': '067234099'}

Decorators

Introduction to the concept

**A decorator:**

  • takes a function as argument (remembered? function is an object, all objects are first-class objects/citizens by design, i can then pass a function as argument to another one, and also return a function).
  • returns a function, with an additional functionnality/behavior from the function passed as parameter

Aim is then to return a wrapper function that appends additional features to an existing function.

Let’s take this simple example of a function without any arguments, and without which does not return anything.

def hello():
    print("Hello")

Note that you can inspect the source code of this funciton using inspect module from the Python standard library.

import inspect
print( inspect.getsource(hello) ) 
def hello():
    return 2+"2"

Let’s create another function.
Here this one takes a function as parameter.
We will later call this passed-in function within the body of une_autre.
Then it will not return a function, but rather just a “yes”.

def une_autre(func):
    func()
    return "yes"
une_autre(hello)
Hello
'yes'

This is hence not a decorator, as the decorator should return a function !

We could easily work around by simply returning the paramater func itself as below (with or without calling it).

def une_autre(func):
    return func

This time une_autre actually takes a function as argument, and return a function too.

But, well, it’s not really doing anything worth the attention… We’re here returning the same function passed as arg. It we were to call une_autre with hello as arg this is what we would get:

def hello():
    print("Hello")

def une_autre(func):
    return func

une_autre(hello)
<function __main__.hello()>

As une_autre returns the hello function. Those lines are equivalent.

new_hello = une_autre(hello)
new_hello2 = hello
# This gives True
new_hello is new_hello2 

How one might expect to provide an additional functionality to a function passed-as argument ? We need to define a new function inside of the body of the decorator one. This function will later be the one returned.

def une_autre(func): # decorator function
    def wrapper(): # wrapper function, inside the decorator definition
        func() # calling func !
        return "yes" # + adding another functionnality (here to return "yes")
    return wrapper # we return the wrapper, not func !

If we call une_autre, on hello. This time the wrapper is the function returned, not the hello itself !:

une_autre(unefonction)
<function __main__.une_autre.<locals>.wrapper()>

To call the actual wrapper we can do this:

une_autre(hello)() # this is equivalent to 'wrapper_returned()'

A function in Python is a first-class citizen object, hence we could have done that in 2 steps, just like before:

new_hello = une_autre(hello) # the wrapper function
new_hello2 = hello # just hello, without any added functionality
# This gives False
new_hello is new_hello2 

We could also simply assign this new returned wrapper function back to the variable refering to the fonction passed as argument:

hello = une_autre(hello) # the wrapper function

Now hello, although named the same way as before, is not the same as before, it encapsulates what hello did before, and also return “yes”. Consecutive calls of hello hence does have a new behavior with an added functionnality.

hello()
Hello
yes

Note that instead of writing each time after the definition part def une_autre(func):..., another line hello = une_autre(hello), we could have used this syntactic sugar syntax during definition of the decorated function.

### this is equivalent to do the definition + hello = une_autre(hello) separatly
@une_autre
def hello():
    print("Hello")

Passing arguments to the decorated function

By now, our decorator une_autre didn’t do that much than returning an additional “yes”.
Also it suffers a big flaw. If we change hello to be a little more interactive:

@une_autre
def hello(name):
    print("Hello {}".format(name))

Then running it:

hello("Luc")

will crash:

TypeError: wrapper() takes 0 positional arguments but 1 was given

This makes sense, hello isn’t the former function we defined. It is now the wrapper. And the wrapper didn’t take any arguments so far. (Note that if we added a default argument value for the hello function, and call hello without any argument, the decorator would have still worked).
We hence need to account for this change by adding this parameter to the wrapper.

def une_autre(func):
    def wrapper(name): # adding this parameter
        result = func(name) # of course you still need to 
        # forward it to the func (here hello) so it displays
        # "Hello <name>"
        return "yes" # added functionality does not change
    return wrapper

But what if we apply this same decorator to another function that has 2 parameters this time ?

@une_autre
def hello2(name, age):
    print("Hello {}, {}".format(name, age))

This will crash. The function was wrapped: the function expected 2 parameters, the wrapper just one.

From now, you probably understood we need the decorator to be flexible enough to take any number of arguments.

We can account for this change by adding the function template for any number of positional and keyword arguments.

def une_autre(func):
    #the wrapper now takes any number of args you could pass to it
    def wrapper(*args, **kwargs):
        func(*args, **kwargs) # and forwards them to the function itself
        return "yes"
    return wrapper

Let’s now use again the shortcut syntax @une_autre (equivalent for unefonction = une_autre(unefonction)):

@une_autre
def hello(name, age):
    print("Hello {}, you are {} years old".format(name, age))
    return name

@une_autre
def goodbye(name):
    print("GoodBye {}".format(name))
    return name

And the result by calling hello and goodbye:

Hello Luc, 25
Out[97]: "yes"
GoodBye Luc
Out[97]: "yes"

Note that since the beginning, as “added functionnality” we were returning “yes”; but it is also perfectly fine to return a value computed by the decorated function

def une_autre(func):
    def wrapper(*args, **kwargs):
        # hold the result computed by func (if func returns a value) in a variable
        result = func(*args, **kwargs) 
        # return it
        return result
    return wrapper

@une_autre
def compute_square(number):
    return number**2

Examples of decorators

The timeit decorator is quite famous by now:

def timeit(func):
    import time
    def wrapper(*args, **kwargs):
        start = time.time() # current (starting) time
        result = func(*args, **kwargs) # function called
        end = time.time() # current (ending) time
        # display the elapsed time during function call
        print("The function took {} seconds to run".format(round(end-start, 2)))
        return result # return the result of the function
    return wrapper

@timeit
def compute_square(number):
    return number**2

@timeit
def create_list(length):
    return list(range(length))

output by calling compute_square(10):

The function took 1.6689300537109375e-06 seconds to run
Out[9]: 100

output by calling create_list(100000000):

The function took 3.9860420227 seconds to run

timeit decorator then computes the time elapsed running the decorated function.

Another decorator is to count the number of calls of a function

def nbcalls(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs) # function called
        wrapper.counter += 1 # incrementing a counter specified in the enclosing scope
        print("function called {} time(s)".format(wrapper.counter))
        return result # i return the result of the function
    # i dynamically add an attribute "counter" to the wrapper object
    wrapper.counter = 0 # initializing counter at 0
    return wrapper

@nbcalls
def create_list(length):
    return list(range(length))

output:

function called 7 time(s)

Out[70]:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

Passing arguments to the decorator

We could call this a “higher-higher”-order function 😜 (a decorator is a higher-order function)

def togiveargs(argument):
    def decorator(func):
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            if argument:
                print("Yes")
            else:
                print("No")
            return result
        return wrapper
    return decorator
@togiveargs(False)
def hello(name):
    ## fonction to be decorated
    print("Hello")
    return name 

This notation will equal, conceptually (as we can’t access decorator this way) to:

hello = togiveargs(decorator(function), argument)

Now calling the decorated hello:

hello("Luc")

Output:

Hello
No

Out[70]: 'Luc'

keeping decorated function’s name

When you use a decorator, but would want to print the decorated function’s name in the body of the function.

def une_autre(func):
    def wrapper(*args):
        print("the function called is {}".format(func.__name__))
        result = func(*args)
        return result
    return wrapper

@une_autre
def compute_power2(number):
    print("I'm the function {}".format(compute_power2.__name__))
    return number**2

You have this output:

"the function called is compute_power2"
"I'm the function wrapper"

This makes sense, the compute_power2 is no longer the base one but the returned wrapper. To keep the docstring and name from the decorated function, use @wraps from functools (yes! indeed it is another decorator :D)

from functools import wraps

def une_autre(func):
    @wraps(func)
    def wrapper(*args):
        print("the function called is {}".format(func.__name__))
        result = func(*args)
        return result
    return wrapper

@une_autre
def compute_power2(number):
    print("I'm the function {}".format(compute_power2.__name__))
    return number**2

output:

"the function called is compute_power2"
"I'm the function compute_power2"

Classes

We have seen so far some primitive types (int, float, str, tuple, list, dict).
We have seen we could create a new integer simply writing 1 or using it’s constructor int(1). Same applies to "bonjour" and str("bonjour").
We have seen that types and classes are unified concepts. Moreover, 2 is an integer of type int. By now, we should thus reveal the other appealing face of 2 with another way of saying things, that is:
2 is an instance of the class int!

In this section, we are going to create our own custom classes, also identified as user-defined class, and then leverage the former idea, by inheriting some of those classes from built-in primitive types/classes, so to bring additional functionalities to either list, str, dict and so on !

Class definition and instanciation

This is an example of class definition we will analyse:

class People:
    """This is a docstring, it works for class definitions too!""" 
    number_of_arms = 2
    number_of_legs = 2

    def __init__(self, name, job, age):
        self.name = name
        if job == "NA":
            self.job = "No jobs"
        else:
            self.job = job
        self.age = age
        
    def accident(self):
        self.number_of_arms -= 1

    def celebrates_birthday(self):
        self.age += 1
        
    @classmethod
    def apocalypse(cls):
        cls.number_of_arms -= 1

    @staticmethod
    def power2(number):
        return number ** 2

    def __str__(self):
        output = "age: {}, job: {}, name: {}\n".format(
            self.age, self.job, self.name)
        return output

And this is the corresponding class instantiation, that is, create a new instance of class/type People.

people1 = People("Luc", "Teacher", 25)
# or using keywords, just like for functions calls
people1 = People(name="Luc", job="Teacher", age=25)

class level vs instance level

Of course you’re not reduced to create only an instance, you can create multiple ones, they all share the same type, that is, the People class. Note that, by convention, a Class name is written in CamelCase, while an instance of this class is in lowercase.
Hence, later in the explanation by “people” we mean any instance of People.

Back to the class definition part, it is important to differentiate what belongs to the class and what belongs to any particular instance of a class:

  • instance variables, also called instance attributes, are variables defined on the instance level, their values might change from one people to the other, and are initialized in the initialization method. Here, all peoples are different hence people1 does have a "Teacher job and a specific age. This is initialized in def init(self).
  • instance methods are functions that applies to the instance itself. Instance methods’ first parameter is self (referring to the instance) and calling the method on the instance object (e.g. "bonjour".upper() or people1.accident()) will implicitly pass the instance itself as first argument corresponding to the self parameter (no need to write: "bonjour".upper("bonjour")).
  • class variables are variables are variables defined on the class level. These are shared among all instances of that class. Here, number_of_legs and number_of_arms are class variables, as it is assumed that all peoples do have 2 arms and 2 legs (at basis).
  • class methods are functions that applies to the class itself. For a method to be applyable to the class and not to the instance of that class, you need to add the decorator (oh! a decorator!) @classmethod. The first parameter will be cls and its corresponding argument is implicitly the class, passed on call by either an instance (e.g. people1.apocalypse() will forward the class from which people1 is instantiated), or by the class People.apocalypse() (here we directly have People).

To put this into practice, if we were to create 2 instances of People, then celebrate Sebastien’s birthday:

people1 = People("Luc", "Teacher", 25)
people2 = People("Sebastien", "Boulanger", 47)

people2.celebrates_birthday()
people1.age, people2.age

You will see only the instance people2 (of name “Sebastien”) just incremented his age.

Out[1]: (25, 48)

We could also have added 1 year more to Sebastien performing the code below, as ageis publicly available:

people2.age += 1

Nevertheless, Luc and Sebastien both have 2 arms and 2 legs, we can access in reading those class variables from either of the instance by using the same dot notation:

people1.number_of_arms, people2.number_of_arms
Out[1]: (2, 2)

Instead, if we were to make an accident to happen to Sebastien just after his birthday (what a mean person we are !) we could do either of those:

people2.accident()
## or ##
people2.number_of_arms -= 1

As we try to modify a class variable directly from one instance, a local copy of this attribute is made on the instance level. Hence you can see that only Sebastien lost an arm using the instance method accident(self) (notice the self) or modifying from the instance itself with the dot notation.

If we were now to rexecute all those code blocks except the last one (where we performed an accident), and rather execute class method apocalypse(cls), then we have access to the class variable in writing and every member of People just lost an arm. Note that an instance could also modify the underlying class variable using __class__ attribute:

people2.__class__.number_of_arms -= 1
people1.number_of_arms, people2.number_of_arms

gives the following output:

Out[2]: (1, 1)

One word on static methods:

Finally static methods, described by the decorator @staticmethod does not have any first implicitly passed argument (like self or cls), and then does not interact at basis with either the instance or the class, although it has to be called from either of them (e.g. people1.power2(25) or People.power2(25)). It behaves just like a normal function. Main use case I have personally seen so far is to encapsulate functions with a common “scope”, “purpose” or related context to mainly refactor code. E.g. we can imagine a class Math which does contain a lot of related mathematical functions that should be accessible from a same namespace: Math.power2, Math.sqrt, Math.exp, etc.

I am open to any suggestions if you’ve seen other use cases.

Magic methods

Among some of the defined instance methods you are seeing defined (or redifined) in People, some does have a special notation with 2 leading and trailing underscores (__init__, __str__). Those are Python methods that are not meant at first to be invoked directly by you, but happens internally from the class on a certain action.

When have seen some magic functions in actions when doing ‘+’ (internally calling __add__), ‘+=’ (calling __iadd__), the indexing notation [i] for a list, str or other iterable (internally calling __getitem__), same for the length of the list, string, or other iterable len(liste), (internally calling __len__)

__init__ is most certainly one of the most famous magic method. It enables you to gives a behavior for the constructor (when calling the class, along with __new__ magics) and pass additional arguments to it to initialize instance variables / customize the instance to a specific initial state.

__str__ gives the behavior when str() constructor is called with the instance argument, or when you simply print the instance print(people1), so to give a natural description of it:

Out[3]: age: 25, job: Teacher, name: Luc

rather than this without:

<__main__.People object at 0x1074c7340>

You can reuse dir() built-in as in the beginning of that lesson:

dir(people1)
['__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 '__weakref__',
 'accident',
 'age',
 'apocalypse',
 'celebrates_birthday',
 'job',
 'name',
 'number_of_arms',
 'number_of_legs',
 'power2']

Familiar ?

Class inheritance and mro (method resolution order)

Any student is also a people.
Hence what about creating a new class Student that inherits from People ?

We can do that by passing in the parenthesis the name of the parent class.
Now a relationship is specified between Student data model and People one. Hence Student can actively reuse methods and variables from the parent ! Think about it as a specification of People.

class Student(People):
    pass

Then we can instantiate a student:

student1 = Student("Luc", "Teacher", 25)
print(student1)
student1.number_of_arms

And get:

Out[4] age: 25, job: Teacher, name: Luc
2

We accessed to str for the printing, and all the instance variables while printing, and also the People’s number_of_arms variable.

What if we want to add a functionallity ? Let’s do this:

class Student(People):
    def __init__(self, years_of_studies):
        self.years_of_studies = years_of_studies

and then instantiate again:

student1 = Student("Luc", "Teacher", 25, 5)
print(student1)
student1.number_of_arms

We get an error:

TypeError: __init__() takes 2 positional arguments but 5 were given

It seems rewriting __init__ overwrote the actual parent same method rather than surspecified it to the case of students.
Hence we need a way to first actively call the parent method, then add the child.

We can call super() for this exact purpose (without needed to explicitly write (type(self), self) arguments starting Python3+)

class Student(People):
    def __init__(self, name, job, age, years_of_studies):
        super().__init__(name, job, age) 
        # calling parent __init__ <=> People().__init__(self)
        # and still pass the self first argument required by __init__
        self.years_of_studies = years_of_studies

Now we can do that:

student1 = Student("Luc", "Teacher", 25, 5)
print(student1)
student1.number_of_arms

What about inheriting from multiple classes? One can do so:

class Student(People, Youngers):
    pass

But you should be cautious about the order of the parent classes you state in the parenthesis, specifically when some methods can be found in different, parent, classes like People and Youngers. A good read about order and which prevail over which, in different contexts, can be found there

Inheriting from primitive types

This is just an example to give you some teasing for the exercices.
If everyhing that has been stated earlier seems convincing to you, then what about inheriting from primitive types?

class SpecialList(list):
    pass

Now you have a SpecialList class that embodies all the functionalities brought by list.
You can later incorporate functionalities or override existing ones from the parent methods as we did earlier.

Sync to GitHub

!ls
Mon_Premier_Notebook.ipynb capture_ecran.png
!echo salut
salut
!git init
Reinitialized existing Git repository in /Users/lucbertin/Desktop/TDs_Python_ESILV_5A/.git/
!git add Mon_Premier_Notebook.ipynb
!git commit -m "reformed course"
[master 5267bc0] reformed course
 1 file changed, 4043 insertions(+), 1367 deletions(-)
!git remote add origin https://github.com/Luc-Bertin/TDs_ESILV.git
!git remote -v
origin  https://github.com/Luc-Bertin/TDs_ESILV.git (fetch)
origin  https://github.com/Luc-Bertin/TDs_ESILV.git (push)
!git push origin master
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 8 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 13.62 KiB | 4.54 MiB/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To https://github.com/Luc-Bertin/TDs_ESILV.git
   ea2ff39..5267bc0  master -> master

Join Newsletter
Get the latest news right in your inbox. I never spam!
Luc
Written by Luc
Hi, my name is Luc. To me, code is art and i love coding in Python, hence the website !