Apprendre Python et s'initier à la programmation

Partie 2 : Programmation avancée

Sébastien Combéfis

Cette seconde partie vous introduit à plusieurs concepts avancés de la programmation. Le septième chapitre présente les dictionnaires, structure de données qui stocke des paires associant des valeurs à des clés. Les deux chapitres suivants vous font découvrir les objets et les classes, notions de base de la programmation orientée objet. Le dixième chapitre présente comment gérer proprement les erreurs avec le mécanisme d'exception. Le onzième chapitre concerne la lecture et l'écriture de fichiers au format texte et binaire. Le douzième chapitre présente comment construire des programmes avec une interface graphique, vous emmenant dans le monde de la programmation évènementielle. Enfin, le dernier chapitre résume tout le livre en présentant comment produire du code de qualité, en respectant les conventions Python.

Commentez

- Titre : Apprendre Python et s'initier à la programmation

- Auteur : Sébastien Combéfis

- Parution : 1 août 2019

- Licence : Copyright ® 2019 Sébastien Combéfis. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Dictionnaire

- I-A. Ensemble

- I-A-1. Définition d'un ensemble

- I-A-1-a. Modification d'un ensemble

- I-A-1-b. Opération ensembliste

- I-A-1-c. Ensemble non modifiable

- I-B. Dictionnaire

- I-B-1. Définition d'un dictionnaire

- I-B-1-a. Modification d'un dictionnaire

- I-B-1-b. Parcours d'un dictionnaire

- I-B-1-c. Dictionnaire et tuple nommé

- I-C. Imbrication de données

- I-C-1. Liste à deux dimensions

- I-C-1-a. Liste multidimensionnelle

- I-C-1-b. Structure imbriquée

- I-C-1-c. Copie

- I-C-1-c-i. Module copy

- I-D. Base de données

- I-D-1. Format JSON

- I-D-1-a. Base de données orientée document

- I-D-1-b. Sérialisation et désérialisation

- II. Objet

- II-A. Objet et état

- II-A-1. Tuple nommé

- II-A-1-a. Objet

- II-A-1-b. Objet en Python

- II-B. Construction et utilisation

- II-B-1. Constructeur

- II-B-1-a. Paramètre de type objet

- II-B-1-b. Valeur de retour de type objet

- II-C. Méthode

- II-C-1. Appel de méthode

- II-C-1-a. Objet cible

- II-D. Programmation orientée objet

- II-D-1. Identité et état d'un objet

- II-D-1-a. Alias

- II-D-1-b. Différence tuple nommé/objet

- II-D-1-c. Tout est objet

- III. Classe

- III-A. Objet et classe

- III-A-1. Attribut et fonctionnalité

- III-A-1-a. Définition de classe

- III-B. Constructeur et variable d'instance

- III-B-1. Définition de constructeur

- III-B-1-a. Référence

- III-B-1-b. Variable d'instance

- III-B-1-c. Plusieurs constructeurs

- III-C. Méthode

- III-C-1. Définition de méthode

- III-C-1-a. Appel de méthode

- III-C-1-b. Plusieurs méthodes de même nom

- III-D. Programmation orientée objet

- III-D-1. Représentation d'un objet

- III-D-1-a. Surcharge d'opérateur

- III-D-1-b. Égalité

- III-D-1-c. Encapsulation

- III-D-1-c-i. Accesseur

- III-D-1-c-ii. Mutateur

- III-D-1-c-iii. Variable privée

- III-D-1-c-iv. Interface publique

- III-D-1-d. Composition

- IV. Gestion d'erreurs

- IV-A. Gestion d'erreurs

- IV-A-1. Type d'erreur

- IV-A-1-a. Erreur de syntaxe

- IV-A-1-a-i. Erreur d'exécution

- IV-A-1-a-ii. Erreur logique

- IV-A-1-b. Documentation

- IV-A-1-b-i. Génération de la documentation

- IV-B. Assertion

- IV-B-1. Instruction assert

- IV-B-1-a. Programmation défensive

- IV-C. Mécanisme d'exception

- IV-C-1. Instruction try-except

- IV-C-1-a. Le type Exception

- IV-C-1-b. Capturer une erreur spécifique

- IV-C-1-b-i. Gestionnaire d'erreur partagé

- IV-C-1-c. Propagation d'erreur

- IV-C-1-d. Le bloc finally

- IV-D. Définir une exception

- IV-D-1. Génération d'erreur

- IV-D-1-a. Créer un type d'exception

- IV-D-1-b. Exception paramétrée

- V. Manipulation de fichiers

- V-A. Fichier

- V-A-1. Créer et modifier un fichier

- V-A-1-a. Chemin

- V-B. Fichier texte

- V-B-1. Ouverture et fermeture

- V-B-1-a. Mode d'ouverture

- V-B-1-b. Lecture

- V-B-1-b-i. Lecture ligne par ligne

- V-B-1-b-ii. Itérateur de lignes

- V-B-1-b-iii. Écriture

- V-B-1-b-iv. Encodage

- V-B-1-b-v. Unicode

- V-B-1-b-vi. Séquence d'échappement

- V-B-1-b-vii. Choix de l'encodage

- V-C. Gestionnaire de contexte

- V-C-1. Fermeture du fichier

- V-C-1-a. Instruction with

- V-D. Fichier binaire

- V-D-1. Lecture et écriture d'objets avec pickle

- V-D-1-a. Écriture

- V-D-1-a-i. Lecture

- V-D-1-b. Lecture et écriture de données primitives avec struct

- V-D-1-b-i. Encodage/décodage de chaines de caractères

- V-D-1-b-ii. Écriture

- V-D-1-b-iii. Lecture

- VI. Interface graphique

- VI-A. bibliothèque Kivy

- VI-A-1. Hello World!

- VI-A-1-a. Fenêtre

- VI-A-1-b. Composant graphique

- VI-A-1-b-i. Widget

- VI-A-1-b-ii. Gestionnaire de mise en page

- VI-A-1-c. Fichier KV

- VI-B. Programmation évènementielle

- VI-B-1. Boucle de gestion d'évènements

- VI-B-1-a. Gestionnaire d'évènements

- VI-B-1-a-i. Gestionnaire d'évènements dans le fichier KV

- VI-B-1-b. Composant personnalisé

- VI-B-1-c. État des widgets

- VI-C. Dessin

- VI-C-1. Composant canvas

- VI-C-1-a. Transformation

- VII. Qualité de code

- VII-A. Qualité de code

- VII-A-1. Outil Pylint

- VII-A-1-a. Règles de bonne pratique

- VII-A-1-a-i. Variable

- VII-A-1-a-ii. Expression booléenne

- VII-A-1-a-iii. Variable indexée

- VII-A-1-a-iv. Instruction inutile

- VII-A-1-a-v. Duplication de code

- VII-A-1-a-vi. Découpe en fonctions

- VII-A-1-a-vii. Mise en page

- VII-A-1-a-viii. Commentaire

- VII-B. Convention de codage

- VII-B-1. PEP 0008

- VII-B-1-a. Code pythonique

I - Dictionnaire

On a vu les séquences dans la première partie du livre, et on va découvrir d'autres structures de données dans ce chapitre. On commence avec les ensembles qui sont étendus par l'ajout d'une valeur sur chacun de leurs éléments pour obtenir des dictionnaires. On voit également comment imbriquer des structures de données pour en créer des complexes. Enfin, on termine avec une introduction aux bases de données.

I-A - Ensemble

Un ensemble est une collection d'éléments distincts. Contrairement aux séquences que l'on a vues précédemment, les éléments d'un ensemble ne sont pas ordonnés et il ne contient pas d'éléments dupliqués.

I-A-1 - Définition d'un ensemble

Python propose le type de données set pour représenter les ensembles. On peut créer un ensemble en listant tous ses éléments, séparés par des virgules, et en délimitant le tout avec des accolades. L'exemple suivant crée un ensemble avec cinq nombres entiers :

numbers = {42, -2, 0, 7, 11}

Plusieurs opérations et fonctions applicables aux séquences le sont également aux ensembles. On peut notamment connaitre la taille d'un ensemble avec la fonction len et tester si un élément en fait partie ou non avec l'opérateur in. On ne peut par contre pas accéder aux éléments d'un ensemble à partir d'un indice, comme avec les séquences, puisqu'il s'agit d'une collection non ordonnée d'éléments. On ne peut donc parcourir les éléments d'un ensemble qu'avec la boucle for. L'exemple suivant illustre ces différentes opérations :

1. print(numbers)

2. print(len(numbers))

3. print(60 in numbers)

4. print(type(numbers))

6. for element in numbers:

7. print(element)

Comme on peut le constater sur le résultat de l'exécution de ces instructions, le type de la variable numbers est bien set :

1. {0, 42, 11, -2, 7}

2. 5

3. False

4. <class 'set'>

6. 0

7. 42

8. 11

9. -2

10. 7

Notez que l'affichage des éléments de l'ensemble peut tout à fait changer d'une exécution à l'autre. En effet, puisque les éléments d'un ensemble ne sont pas ordonnés, il n'y a pas d'unique ordre de parcours. La boucle for garantit juste que chaque élément de l'ensemble sera visité une fois.

Il y a deux autres manières courantes de créer un ensemble. Tout d'abord, on peut en créer un nouveau par compréhension, tout comme on l'a déjà fait avec les séquences. Par exemple, pour définir l'ensemble des entiers inférieurs à qui sont à la fois divisibles par et , on écrit :

S = {n for n in range(100) if n % 3 == 0 and n % 7 == 0}

Cette notation est très similaire à celle utilisée en mathématiques pour définir des ensembles où on aurait écrit :

Ensuite, on peut également créer un ensemble à partir d'une séquence. On utilise pour cela la fonction set en lui passant comme paramètre la séquence sur base de laquelle il faut créer l'ensemble. Voici deux exemples créant un ensemble, à partir d'une liste et d'une chaine de caractères :

1. A = set([12, 42, 0, 12, 0, -1, 0])

2. B = set('Cocorico!')

4. print(A)

5. print(B)

Puisqu'un ensemble ne peut contenir de doublons, ils seront automatiquement supprimés comme on le voit sur le résultat de l'exécution :

{0, 42, 12, -1}
{'r', '!', 'o', 'c', 'C', 'i'}

Si on veut définir un ensemble vide, il faut utiliser la fonction set :

emptyset = set()

En effet, comme on le verra à la section suivante, la notation {} permet de définir un dictionnaire vide.

I-A-1-a - Modification d'un ensemble

Une fois un ensemble créé, il est possible d'en obtenir des informations ou de le modifier en utilisant des fonctions dédiées aux ensembles. On a déjà vu les fonctions len et in, dans la section précédente, qui permettent respectivement de connaitre la taille de l'ensemble et de tester l'appartenance d'un élément à un ensemble.

Une fois un ensemble créé, on peut le modifier, c'est-à-dire lui ajouter ou retirer des valeurs. On utilise pour cela les fonctions add et remove, qu'il faut appliquer sur la variable contenant l'ensemble. L'exemple suivant crée un ensemble, lui retire deux valeurs et en ajoute une :

1. S = {1, 2, 3, 4}

2. S.remove(1)

3. S.remove(2)

4. S.add(5)

6. print(S)

Comme on peut le constater sur le résultat de l'exécution de ces instructions, l'ensemble qui contenait cinq éléments au départ ne contient plus que les trois éléments , et  :

{3, 4, 5}

Si on tente de supprimer un élément qui ne fait pas partie de l'ensemble, la fonction remove provoquera une erreur lors de l'exécution. Il existe également une fonction discard qui, elle, ne produira pas d'erreur. Enfin, il existe également une fonction pop qui permet d'obtenir l'un des éléments de l'ensemble, le retirant de ce dernier.

I-A-1-b - Opération ensembliste

Comme on le verra en détail plus loin, Python est un langage de programmation très riche qui permet notamment de définir des opérations spécifiques à chaque nouveau type de données.

En l'occurrence, il est possible d'effectuer des opérations ensemblistes sur les données de type set. La figure 1 illustre le résultat de ces différentes opérations, étant donné deux ensembles et  :

- la différence entre et , notée , contient tous les éléments de sauf ceux appartenant également à  ;

- l'union des ensembles et , notée , contient tous les éléments se trouvant soit dans , soit dans (ou dans les deux) ;

- l'intersection des ensembles et , notée , contient tous les éléments se trouvant à la fois dans et dans .

Figure 1. Les trois principales opérations ensemblistes sont la différence, l'union et l'intersection d'ensembles.

Comme on le verra plus loin dans ce livre, le langage Python permet de redéfinir des opérateurs pour tout nouveau type de données. En l'occurrence, les opérations ensemblistes de différence, d'union et d'intersection sont respectivement calculées par les opérateurs -, | et &. Voici un exemple utilisant ces trois opérateurs :

1. A = {1, 2, 3, 4}

2. B = {3, 4, 5}

4. print(A - B)

5. print(A & B)

6. print(A | B)

Voici le diagramme de Venn reprenant les deux ensembles et , ainsi que leurs différents éléments :

Le résultat de l'exécution de ces instructions correspond bien aux résultats attendus des opérations ensemblistes, et est conforme à ce que l'on peut observer sur le diagramme de Venn :

{1, 2}
{3, 4}
{1, 2, 3, 4, 5}

Tout comme c'est le cas avec les opérateurs arithmétiques, on peut également construire des affectations composées à partir des opérateurs ensemblistes. Cela nous offre donc une autre possibilité pour modifier un ensemble. Étant donné un ensemble A, les deux séquences d'instructions suivantes sont (presque) équivalentes :

1. # Appel de fonction

2. A.add(6)

3. A.remove(4)

5. # Opération ensembliste

6. A |= {6}

7. A -= {4}

Pourquoi ces opérations ne sont-elles pas complètement équivalentes ? Pour vraiment le comprendre, il faudra attendre le prochain chapitre sur les objets, mais on peut néanmoins déjà comprendre l'intuition grâce à la figure 2 qui montre la situation en mémoire.

Sur la gauche, on peut voir la situation en mémoire avant et après exécution de l'instruction A.add(6). L'ensemble contenu dans la variable A a été modifié et contient un élément supplémentaire.

Sur la droite, on peut voir la situation en mémoire avant et après exécution de l'instruction A |= {6}. La valeur de la variable A a été modifiée et contient un nouvel ensemble, résultat de l'union de l'ensemble A original avec l'ensemble {6}. L'ensemble qui était originellement référencé par la variable A reste en mémoire, mais n'est plus accessible.

Figure 2. Ajouter un élément à un ensemble en passant par la fonction add n'est pas équivalent à utiliser une affectation composée avec l'opérateur d'union, même si le résultat observé est le même.

I-A-1-c - Ensemble non modifiable

Terminons cette section en nous penchant sur l'aspect modifiable des éléments d'un ensemble, mais également de l'ensemble en tant que tel.

La première contrainte importante concernant les éléments d'un ensemble est qu'ils doivent être non modifiables. Voyons cela avec l'exemple suivant qui tente de créer un ensemble contenant des listes :

A = {[0], [1, 2, 3]}

Les listes étant modifiables, on ne peut en utiliser comme éléments d'un ensemble et une erreur d'exécution est produite par l'interpréteur :

1. Traceback (most recent call last):

2. File "program.py", line 1, in <module>

3. A = {[0], [1, 2, 3]}

4. TypeError: unhashable type: 'list'

Et qu'en est-il des ensembles ? Comme on l'a vu précédemment, un ensemble est modifiable puisqu'on peut lui ajouter ou retirer des éléments une fois créé, notamment avec add et remove.

Il est possible de définir un ensemble non modifiable, c'est-à-dire que l'on ne pourra pas le modifier une fois créé. Pour cela, on utilise simplement le type frozenset au lieu du type set. Tout ce que l'on a vu précédemment pour les ensembles, à l'exception des opérations de modification, est également applicable aux frozenset.

L'exemple suivant crée un ensemble non modifiable et tente de lui ajouter un élément :

A = frozenset([1, 2, 3])
A.add(6)

Puisque la variable A contient un ensemble non modifiable, une erreur se produit lors de l'exécution :

1. Traceback (most recent call last):

2. File "program.py", line 2, in <module>

3. A.add(6)

4. AttributeError: 'frozenset' object has no attribute 'add'

Néanmoins, notez que l'instruction A |= {6} s'exécute sans produire d'erreur. Après son exécution, la variable A contiendra un nouvel ensemble, à savoir frozenset({1, 2, 3, 6}). On n'a donc pas modifié l'ensemble originellement présent dans A, mais on lui a affecté un nouvel ensemble non modifiable.

I-B - Dictionnaire

Un autre type de données très utilisé est le dictionnaire. Il s'agit d'un ensemble de paires associant une clé et une valeur. Ce type est également appelé tableau associatif dans d'autres langages de programmation.

Dans un même dictionnaire, chaque clé est unique. On peut en fait voir un dictionnaire comme une extension des séquences, où chaque élément est identifié de manière unique, non plus par un indice, mais par une clé. Une clé peut être une donnée de n'importe quel type, pour autant qu'elle soit non modifiable.

I-B-1 - Définition d'un dictionnaire

On peut créer un dictionnaire en listant toutes ses paires clé-valeur, séparées par des virgules, et en délimitant le tout avec des accolades. L'exemple ci-dessous crée un dictionnaire contenant trois paires stockant les numéros de téléphone internes des employés d'une entreprise :

phone = {'Quentin': 8723, 'Cédric': 2837, 'Jonathan': 4872}

Un dictionnaire vide se déclare avec des accolades vides ({}). L'accès aux éléments d'un dictionnaire se fait également avec des crochets, comme pour les séquences, si ce n'est que l'on utilise la clé de l'élément qui nous intéresse comme indice. La plupart des opérations applicables aux séquences le sont également aux dictionnaires, comme le montre l'exemple suivant :

1. print(phone)

2. print(len(phone))

3. print(phone['Cédric'])

4. print('Jonathan' in phone)

5. print(type(phone))

7. del(phone['Cédric'])

8. for name in phone:

9. print(name, phone[name], sep=' : ')

Comme on peut le constater sur le résultat de l'exécution, la variable phone est bien un dictionnaire puisque son type est dict :

1. {'Jonathan': 4872, 'Quentin': 8723, 'Cédric': 2837}

2. 3

3. 2837

4. True

5. <class 'dict'>

7. Jonathan : 4872

8. Quentin : 8723

Contrairement aux séquences, où l'opérateur in agit sur ses éléments, il permet, dans un dictionnaire, de tester si une valeur est une clé de ce dernier. La boucle for agit également sur les clés, permettant de les parcourir. On se rend également compte qu'un dictionnaire est modifiable puisqu'on peut notamment appliquer l'opérateur del.

Enfin, un dictionnaire est bel et bien un ensemble, et non pas une séquence. Du coup, ses éléments, qui sont des paires clé-valeur, ne sont pas ordonnés. Il n'y a dès lors aucune garantie que deux parcours de ses clés se fassent toujours dans le même ordre.

On peut également créer un dictionnaire par compréhension, tout comme on peut le faire pour les séquences et les ensembles. Créons, par exemple, un dictionnaire associant à chaque entier impair compris entre et (les clés), son carré (les valeurs) :

squares = {n : n ** 2 for n in range(1,10) if n % 2 != 0}
print(squares)

On constate bien que les clés du dictionnaire squares sont les entiers impairs voulus, et que les valeurs associées sont à chaque fois leur carré :

{1: 1, 3: 9, 9: 81, 5: 25, 7: 49}

Enfin, on peut également créer un dictionnaire à partir d'une liste de paires clé-valeur, représentées par des tuples. Pour cela, on passe par la fonction dict :

price = dict([('pear', 1.10), ('lemon', 0.85), ('pear', 1.00)])
print(price)

Puisqu'un dictionnaire ne peut pas contenir plusieurs fois la même clé, en cas de doublons, seule la dernière présente dans la liste sera retenue. Comme on a deux fois la clé pear dans la liste passée en paramètre à la fonction dict, seule la valeur associée à la dernière sera retenue, à savoir . C'est bien ce que l'on observe sur le résultat de l'exécution :

{'lemon': 0.85, 'pear': 1.0}

I-B-1-a - Modification d'un dictionnaire

Le dictionnaire est un type modifiable, c'est-à-dire que l'on va pouvoir modifier les valeurs des paires clé-valeur qu'il contient, mais également lui ajouter ou retirer de telles paires. Modifier la valeur d'une paire se fait comme avec les listes, c'est-à-dire avec les crochets en précisant la clé de la paire que l'on veut modifier. Ainsi, pour changer le prix des citrons, on écrit l'instruction suivante :

price['lemon'] = 0.90

Si la clé que l'on avait spécifiée dans l'instruction précédente n'existait pas dans le dictionnaire, une nouvelle paire clé-valeur aurait été ajoutée. C'est donc ainsi que l'on ajoute un élément à un dictionnaire :

price['apple'] = 1.00
print(price)

Cette instruction a donc ajouté une nouvelle paire dont la clé est 'apple' et la valeur est , comme on peut le constater sur le résultat de l'exécution :

{'pear': 1.0, 'lemon': 0.9, 'apple': 1.0}

Pour supprimer un élément du dictionnaire, comme on l'a précédemment vu, il suffit d'utiliser la fonction del. Par exemple, pour supprimer la paire correspondant aux poires, on écrit :

del(price['pear'])

I-B-1-b - Parcours d'un dictionnaire

Il existe plusieurs manières de parcourir un dictionnaire. Comme on a vu en début de section, on peut utiliser la boucle for qui va parcourir ses différentes clés. On accède ensuite aux valeurs associées aux clés avec les crochets :

for fruit in price:
print(fruit, price[fruit], sep=' : ')

L'exécution de cette boucle affiche le résultat suivant :

apple : 1.0
lemon : 0.9

Rappelez-vous bien que l'ordre n'est pas déterminé et peut donc être différent entre deux exécutions, puisqu'un dictionnaire est un ensemble.

On peut récupérer toutes les clés d'un dictionnaire grâce à la fonction keys et tous ses éléments grâce à la fonction items, toutes deux à appliquer sur le dictionnaire duquel on veut extraire les informations. Ces deux fonctions renvoient respectivement des données de type dict_keys et dict_items. Ce sont des séquences que l'on peut transformer en listes avec la fonction list. L'exemple suivant et son résultat illustrent cela :

1. print(price.keys())

2. print(price.items())

4. print(list(price.keys()))

5. print(list(price.items()))

1. dict_keys(['apple', 'lemon'])

2. dict_items([('apple', 1.0), ('lemon', 0.9)])

4. ['apple', 'lemon']

5. [('apple', 1.0), ('lemon', 0.9)]

Enfin, on peut également directement parcourir les paires d'un dictionnaire en utilisant la fonction items :

for key, value in price.items():
print(key, value, sep=' : ')

I-B-1-c - Dictionnaire et tuple nommé

Un dictionnaire ressemble à un tuple nommé pour certains points, mais il s'agit bel et bien de deux types de données différents. Voici les différences entre ces deux types :

- un tuple nommé est une séquence et ses éléments sont donc ordonnés, ce qui n'est pas le cas d'un dictionnaire qui est un ensemble de paires ;

- un tuple nommé est non modifiable alors que le dictionnaire l'est ;

- les attributs d'un tuple nommé doivent être des chaines de caractères alors que les clés d'un dictionnaire peuvent être n'importe quelle donnée non modifiable.

I-C - Imbrication de données

Les éléments que l'on peut stocker dans les séquences, les ensembles et les dictionnaires peuvent être de n'importe quel type, sauf pour les ensembles et pour les clés des dictionnaires qui doivent être non modifiables. On peut imbriquer une structure de données dans une autre, pour construire des structures plus complexes. On a déjà vu quelques exemples dans les sections précédentes, et on va maintenant analyser cette possibilité dans le détail.

I-C-1 - Liste à deux dimensions

Commençons avec les listes imbriquées, c'est-à-dire une liste dont les éléments sont eux-mêmes des listes. On va commencer par créer deux listes de nombres entiers, puis utiliser ces deux listes comme éléments d'une troisième liste :

1. A = [1, 2]

2. B = [3, 4, 5]

3. L = [A, B]

5. print(L)

Lorsqu'on exécute ces instructions, on obtient le résultat suivant qui montre bien que les éléments de la liste L sont des listes :

[[1, 2], [3, 4, 5]]

On n'est pas obligé de préalablement créer les listes qui seront imbriquées, et on peut directement écrire :

L = [[1, 2], [3, 4, 5]]

La figure 3 montre tout ce qui est créé en mémoire lors de l'exécution de cette instruction. On voit d'abord que la variable L est une référence vers une liste à deux éléments. Le premier élément de cette liste est lui-même une référence vers une autre liste, qui contient trois nombres entiers tandis que le second élément référence une liste à deux éléments.

Figure 3. On peut imbriquer des listes dans une liste, c'est-à-dire que les éléments de cette dernière sont eux-mêmes des listes.

Si on parcourt les éléments de la liste L, et que l'on affiche leur type, on se rend compte directement qu'il s'agit bien de listes comme le montrent l'exemple suivant et le résultat de son exécution :

for elem in L:
print(type(elem))

<class 'list'>
<class 'list'>

On peut donc accéder aux deux listes imbriquées avec les notations L[0] et L[1]. Comment ensuite accéder aux éléments de ces listes imbriquées ? Il suffit en fait de faire un accès multiple en rajoutant encore des crochets. Ainsi, la notation L[1][2] permettra d'accéder au troisième élément (celui d'indice ) de la deuxième liste imbriquée (celle d'indice ), à savoir la valeur .

Lorsque toutes les listes imbriquées ont la même taille, on dit que l'on a créé une liste à deux dimensions. On peut comparer une telle structure aux matrices utilisées en mathématiques. Une telle structure est caractérisée par un nombre de lignes et de colonnes. Analysons l'exemple suivant :

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

La variable M contient une référence vers une liste à deux éléments, ces derniers étant tous deux des listes de nombres entiers à trois éléments. On représente en fait la matrice suivante :

qui possède deux lignes et trois colonnes. On peut obtenir le nombre de lignes avec len(M) et le nombre de colonnes avec len(M[0]), pour autant que la matrice ne soit pas vide. Chaque élément de la liste à deux dimensions est accessible avec la notation M[i][j] où l'indice représente celui de la ligne et l'indice celui de la colonne.

Pour parcourir tous les éléments d'une liste à deux dimensions, il faudra utiliser deux boucles imbriquées, que ce soit une while ou une for. Voici comment on peut parcourir la liste M avec les deux types de boucles :

1. # Avec une boucle while

2. i = 0

3. while i < len(M):

4. j = 0

5. while j < len(M[0]):

6. print(M[i][j])

7. j += 1

8. i += 1

10. # Avec une boucle for

11. for line in M:

12. for elem in line:

13. print(elem)

Ces boucles vont simplement afficher tous les éléments de la liste à deux dimensions, l'un en dessous de l'autre, en la parcourant ligne par ligne.

I-C-1-a - Liste multidimensionnelle

On peut aisément aller plus loin et augmenter la dimension d'une liste. Par exemple, une liste à trois dimensions est simplement une liste de listes de listes. Pour accéder aux valeurs de la liste tridimensionnelle, il faudra dès lors spécifier trois indices. Par exemple, pour la liste suivante :

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

On accède à la valeur avec data[0][0][0] et à la valeur avec data[1][0][2]. De manière générale, on évite toutefois de monter en dimensions, car cela rend les listes plus complexes, notamment pour accéder à leurs éléments et les manipuler. Autant les listes à deux dimensions sont encore assez répandues, celles à trois dimensions sont déjà plus rares et ne parlons même pas des dimensions supérieures.

I-C-1-b - Structure imbriquée

Ce que l'on vient de voir avec les listes peut s'appliquer aux autres structures de données que l'on a vues jusqu'à présent, en respectant certaines contraintes.

Tout d'abord, tout ce que l'on peut imbriquer dans une liste, on peut également le faire avec des tuples. On a déjà vu que l'on pouvait imbriquer des listes dans une liste, et on peut également y imbriquer des tuples. On pourrait, par exemple, stocker une liste de coordonnées dans le plan comme suit :

coords = [(0,0), (7,-2), (4,5), (-3,-9)]

On peut également imbriquer des ensembles dans une liste. Par exemple, on pourrait vouloir stocker une liste de lunches, par ordre de préférence :

1. lunches = [

2. {'apple', 'banana', 'grape'},

3. {'yogurt', 'cereals'},

4. {'bread', 'cheese', 'ham'},

5. {'sausage'}

6. ]

Remarquez que la liste a été déclarée sur plusieurs lignes, afin de la rendre plus lisible. Une telle notation est autorisée et même recommandée lorsque vous déclarez des structures complexes.

Enfin, il n'y a aucun problème à stocker des dictionnaires dans une liste. L'exemple suivant montre comment on pourrait stocker ses contacts par ordre alphabétique des prénoms :

1. contacts = [

2. {'firstname': 'Alexis', 'lastname': 'King'},

3. {'firstname': 'Brice', 'lastname': 'Monster'},

4. {'firstname': 'Sébastien', 'lastname': 'Adams'}

5. ]

Concernant les ensembles, on a vu que l'on ne pouvait y stocker que des valeurs non modifiables. On ne va donc pas pouvoir y imbriquer des listes, des ensembles (on peut néanmoins imbriquer des ensembles non modifiables, obtenus avec frozenset, dans des ensembles) ou des dictionnaires. On peut, par exemple, imbriquer des tuples dans un ensemble. Voici, par exemple, un ensemble reprenant, pour différents athlètes, les résultats réalisés en mètres pour trois lancers de javelot consécutifs :

1. results = {

2. (90.73, 89.23, 90.12),

3. (87.23, 90.11, 88.12),

4. (90.88, 88.61, 89.91)

5. }

La même restriction s'applique aux clés d'un dictionnaire qui doivent donc être des valeurs non modifiables. On peut, par exemple, utiliser un tuple comme clé d'un dictionnaire. L'exemple suivant associe une personne à une série de coordonnées dans le plan :

1. config = {

2. (0, 0): 'Arnaud',

3. (2, 1): 'Louis',

4. (-1, 3): 'Marie',

5. (3, -1): 'Dan'

6. }

Si on désire pouvoir placer plusieurs personnes à une même coordonnée, et se rappelant que les clés d'un dictionnaire doivent être uniques, on va devoir imbriquer une liste dans le dictionnaire :

1. config = {

2. (0, 0): ['Arnaud', 'Pierre'],

3. (2, 1): ['Louis'],

4. (-1, 3): ['Marie', 'Éric', 'Tom'],

5. (3, -1): ['Dan']

6. }

Comme on le verra à la section suivante, on va aussi pouvoir imbriquer des dictionnaires ensemble afin de construire des structures complexes permettant de représenter de véritables bases de données.

I-C-1-c - Copie

Terminons cette section en s'intéressant à la copie de structures de données, qui peut parfois se révéler délicate sachant qu'une variable ne contient pas toute la structure de données, mais uniquement une référence vers l'emplacement en mémoire où elle est stockée. En effet, comme on l'a vu à la section 5.1, lorsqu'on affecte à une variable une autre variable qui contient une référence vers une liste, on crée un alias. Les deux variables contiennent une référence vers la même zone mémoire, contenant la liste, et permettent donc d'agir sur la même liste. Revoyons cela avec l'exemple suivant :

1. L = [1, 2, 3]

2. A = L

4. A[0] = 42

5. print(L)

Alors que l'on pourrait croire, de prime abord, que la variable A contient une copie de la liste référencée par la variable L, ce n'est effectivement pas le cas. Les variables A et L sont des alias vers la même liste, qui n'existe donc qu'une seule fois en mémoire. On peut le constater avec le résultat de l'exécution :

[42, 2, 3]

Pour faire une réelle copie, il faut passer par la fonction prédéfinie list qui va, elle, créer une nouvelle liste en mémoire, avec le même contenu que celle passée en paramètre. Pour faire une véritable copie, l'exemple précédent doit donc se réécrire comme suit :

1. L = [1, 2, 3]

2. A = list(L)

4. A[0] = 42

5. print(L)

Cette fois-ci, le code affiche bien ce que l'on attend :

[1, 2, 3]

Ce comportement est exactement le même pour toutes les autres structures de données que l'on a vues jusqu'à présent. Il ne pose évidemment aucun souci pour les structures de données non modifiables comme les chaines de caractères, les tuples ou les intervalles.

Pour les autres structures, il faudra à chaque fois passer par la fonction prédéfinie correspondant à la structure de données pour en faire une copie, à savoir list, set et dict.

I-C-1-c-i - Module copy

La méthode de copie que l'on vient de voir permet en réalité de faire une copie en surface. Pour comprendre cette notion, attardons-nous un moment sur l'exemple suivant :

1. L = [[1, 2], [3, 4, 5]]

2. A = list(L)

4. A[1][0] = 42

5. print(L)

Bien que l'on ait fait une copie de la liste référencée par la variable L dans la variable A, après avoir modifié cette copie à partir de la variable A, on observe le résultat suivant après exécution :

[[1, 2], [42, 4, 5]]

Pour comprendre ce qui s'est passé, il faut examiner la situation en mémoire, après exécution des deux premières instructions, illustrée à la figure 4. On y voit donc que la liste directement référencée par la variable L a bel et bien été copiée, cette copie étant référencée par la variable A. La subtilité, c'est que les listes qui sont référencées par ces deux listes sont, elles, uniques et c'est dans ces listes que se trouvent les valeurs de la liste à deux dimensions.

Figure 4. La copie d'une liste avec la fonction list ne se fait qu'en surface, c'est-à-dire que seule la liste de première dimension est copiée.

Si on le souhaite, il est également possible de faire une copie en profondeur, c'est-à-dire que tous les niveaux de la structure de données seront copiés, même les imbriquées.

Pour cela, on va faire appel au module copy qui propose deux fonctions :

- copy permet de faire une copie en surface ;

- deepcopy permet de faire une copie en profondeur.

L'exemple précédent avec une copie en profondeur se réécrit simplement comme suit, sans oublier d'importer le module copy évidemment :

1. import copy

3. L = [[1, 2], [3, 4, 5]]

4. A = copy.deepcopy(L)

6. A[1][0] = 42

7. print(L)

Le résultat de l'exécution montre que l'on a bel et bien une copie intégrale de la liste L :

[[1, 2], [3, 4, 5]]

La copie en profondeur copie donc intégralement toutes les structures imbriquées. Cela a deux conséquences immédiates : la copie prendra plus de temps et la consommation mémoire globale du programme sera plus grande. Lorsqu'on fait une copie, il est dès lors important de se poser la question de savoir ce que l'on veut en faire. Si c'est uniquement pour la consulter, un simple alias suffira, et si c'est pour la modifier, selon la profondeur des modifications envisagées, le choix se portera vers une copie en surface ou en profondeur.

I-D - Base de données

On peut utiliser des dictionnaires pour construire des simples bases de données. Une base de données est une collection de données organisées selon un format choisi. La gestion de ces données est confiée à un système de gestion de bases de données (SGBD), un ensemble de logiciels permettant notamment d'interroger la base de données pour en extraire des informations et de gérer le stockage physique de ces données.

I-D-1 - Format JSON

Le format JSON (acronyme de JavaScript Object Notation, ce format provient du monde JavaScript) est utilisé pour représenter des objets. C'est un format qui dérive de la notation des objets du langage JavaScript. Il est utilisé pour représenter de l'information structurée et, comme vous pourrez le constater, ressemble fort aux dictionnaires Python.

Un document JSON est un ensemble de paires constituées d'une étiquette et d'une valeur ou d'une liste de valeurs. On sépare les paires par des virgules, le tout entre accolades. Pour les listes de valeurs, on sépare les valeurs par des virgules, le tout entre crochets. Enfin, une valeur peut elle-même être un document JSON et les étiquettes doivent être écrites entre guillemets doubles (vous pouvez vérifier la validité d'un document JSON en ligne, à l'adresse suivante : http://jsonlint.com/).

Voici un exemple de document JSON qui représente un carnet de contacts, reprenant leurs nom, prénom et numéro de téléphone :

1. {

2. "name": "Carnet de contacts de Sébastien Combéfis",

3. "contacts": [

4. {

5. "firstname": "Cédric",

6. "lastname": "Marchand",

7. "phone": 2837

8. },

9. {

10. "firstname": "Jonathan",

11. "lastname": "Verlant-Chenet",

12. "phone": 4872

13. },

14. {

15. "firstname": "Quentin",

16. "lastname": "Lurkin",

17. "phone": 8723

18. }

19. ]

20. }

Ce document JSON contient, à la base, deux paires dont les étiquettes sont name et contacts. La valeur de la première paire est une chaine de caractères et celle de la seconde paire est une liste de documents JSON. Elle en contient trois identiques contenant chacun trois étiquettes (firstname, lastname et phone) dont les valeurs sont des chaines de caractères pour les deux premières et un nombre entier pour la troisième.

I-D-1-a - Base de données orientée document

Les documents JSON sont utilisés pour construire des bases de données orientées document. Dans de telles bases de données, les éléments stockés peuvent être des documents JSON. Un tel type de base de données supporte essentiellement quatre opérations que l'on résume avec l'acronyme CRUD (Create, Read, Update, and Delete) :

- création et ajout d'un nouveau document ;

- lecture et récupération d'un document par une recherche ;

- mise à jour d'un document existant par modification, ajout ou suppression de paires ;

- suppression d'un document existant.

Ces quatre opérations sont nécessaires pour avoir un système complet. Si on considère l'implémentation d'une base de données orientée documents à l'aide de Python, en utilisant les dictionnaires pour stocker les documents JSON, il est assez simple d'implémenter ces quatre opérations à l'aide de celles utilisables sur les dictionnaires.

I-D-1-b - Sérialisation et désérialisation

Un document JSON peut toujours être transformé en un dictionnaire Python ; cette opération s'appelle la désérialisation. L'opération inverse, appelée sérialisation, tente de convertir un dictionnaire Python en un document JSON. Cette seconde opération n'est pas toujours possible, les dictionnaires Python étant plus riches que ce qui est permis en JSON. On relève notamment les différences majeures suivantes :

- les étiquettes des paires d'un document JSON doivent être des chaines de caractères alors qu'elles peuvent être n'importe quelle donnée non modifiable dans un dictionnaire Python ;

- les types de données autorisés pour les valeurs des paires d'un document JSON sont limités aux chaines de caractères, booléens, nombres entiers et flottants, listes et documents JSON imbriqués alors qu'il y a beaucoup plus de types autorisés dans un dictionnaire Python.

Les deux opérations de sérialisation et de désérialisation peuvent être faites à partir de fonctions disponibles dans le module json. La fonction dumps correspond à la sérialisation et la fonction loads désérialise un document JSON.

Le premier exemple construit un dictionnaire Python et le sérialise en un document JSON qui est ensuite affiché. Le résultat renvoyé par la fonction dumps est de type chaine de caractères :

1. import json

3. bb = {'seasons': 5, 'genre': ['crime drama', 'thriller']}

4. skins = {'seasons': 7, 'genre': ['teen drama', 'comedy drama']}

5. tvshows = {'Breaking Bad': bb, 'Skins': skins}

7. document = json.dumps(tvshows, indent=4)

8. print(type(document))

9. print(document)

Le paramètre nommé indent de la fonction dumps permet d'obtenir un joli formatage du document JSON, comme on le voit sur le résultat de l'exécution :

1. <class 'str'>

2. {

3. "Breaking Bad": {

4. "genre": [

5. "crime drama",

6. "thriller"

7. ],

8. "seasons": 5

9. },

10. "Skins": {

11. "genre": [

12. "teen drama",

13. "comedy drama"

14. ],

15. "seasons": 7

16. }

17. }

Voyons maintenant l'opération inverse, à savoir la création d'un dictionnaire Python à partir d'une chaine de caractères contenant un document JSON. On utilise donc pour cela la fonction loads comme le montre l'exemple suivant :

1. import json

3. document = '{"Belgium":{"capital":"Brussels","languages":["french","dutch","german"]},"China":{"capital":"Beijing","languages":["mandarin chinese"]}}'

5. countries = json.loads(document)

6. print(type(countries))

7. print(countries['China'])

La variable countries, qui stocke le résultat renvoyé par la fonction loads, contient bien un dictionnaire comme on peut le constater sur le résultat de l'exécution :

<class 'dict'>
{'languages': ['mandarin chinese'], 'capital': 'Beijing'}

Les deux fonctions dumps et loads provoquent une erreur lors de l'exécution lorsqu'un problème de format de données est présent. La sérialisation échouera si le dictionnaire Python passé en paramètre comporte des éléments interdits par le format JSON. La désérialisation échouera si la chaîne de caractères passée en paramètre ne représente pas un document JSON valide.

II - Objet

Tous les programmes que l'on a écrits jusqu'à présent sont des programmes procéduraux, dont l'un des éléments constitutifs est une fonction. L'exécution de tels programmes consiste en l'exécution successive de leurs instructions, modifiant l'état de la mémoire (les variables). Dans ce chapitre, on va voir la programmation orientée objet, dont l'un des éléments constitutifs est un objet.

II-A - Objet et état

Jusqu'à présent, on a déjà pu voir et utiliser plusieurs types de données différents, partant de simples nombres et booléens, jusqu'à des types permettant de stocker des collections d'éléments (listes, chaines de caractères, tuples, intervalles, ensembles et dictionnaires).

II-A-1 - Tuple nommé

On a également vu les tuples nommés qui permettent de rassembler plusieurs variables en leur associant à chacune un nom, il s'agit des champs du tuple nommé. Voici, par exemple, une définition d'un nouveau tuple nommé permettant de représenter un vecteur dans le plan :

from collections import namedtuple

Vector = namedtuple('Vector', ['x', 'y'])

La seconde instruction définit un nouveau type de tuple nommé, dont le nom est Vector, constitué de deux champs nommés x et y. À partir de cette définition, on peut créer des tuples nommés représentant des vecteurs dans le plan :

u = Vector(1, -1)
v = Vector(2, 1)

Le tuple nommé permet donc de stocker des données de manière organisée, avec des champs qui possèdent un nom. On peut, par exemple, afficher les coordonnées du vecteur en accédant aux champs. Afin d'avoir un joli affichage, on va construire une chaine de caractères par concaténation, en n'oubliant pas la conversion en str :

print('(' + str(u.x) + ', ' + str(u.y) + ')')

Si on veut également afficher l'autre vecteur, on pourrait simplement dupliquer cette instruction, en remplaçant les u par des v. Néanmoins, comme on l'a déjà vu, ce n'est pas une bonne pratique. Lorsqu'on doit répéter du code qui varie légèrement, une bonne pratique consiste à définir une fonction. Voici une fonction vector2string qui renvoie une chaine de caractères à partir d'un vecteur :

def vector2string(v):
return '(' + str(v.x) + ', ' + str(v.y) + ')'

Maintenant que la fonction est définie, on va pouvoir l'appeler autant de fois que l'on souhaite, pour afficher joliment nos vecteurs. Voici deux appels à cette fonction, avec le résultat produit lors de l'exécution :

print(vector2string(u))
print(vector2string(v))

(1, -1)
(2, 1)

Dans l'exemple que l'on vient de voir, on a donc associé des données sous forme d'un tuple nommé, et on a défini des fonctions permettant d'effectuer des opérations sur ces dernières. C'est exactement cette association qui est à la base de la programmation orientée objet, concept sur lequel le langage Python a été construit.

II-A-1-a - Objet

Qu'est-ce qu'un objet ? Un objet est une entité qui possède des caractéristiques et qu'on va pouvoir manipuler. On peut faire un parallèle immédiat avec les objets du monde réel.

Prenons, par exemple, l'objet GSM (pour être précis, on devrait dire téléphone mobile puisque GSM désigne une norme, mais ce dernier terme est abusivement utilisé pour désigner le téléphone), qui possède une certaine marque, un modèle, un numéro de série, une couleur, un niveau de charge de la batterie, un niveau de luminosité de l'écran, etc. Ces différentes caractéristiques sont appelées attributs de l'objet. Pour un objet donné, chaque attribut possède une valeur, dont l'ensemble définit l'état de l'objet. La figure 1 montre deux objets de type GSM, avec leurs cinq attributs et valeurs associées.

Figure 1. Un objet possède un type (GSM dans cet exemple) et est caractérisé par un état propre, défini par les valeurs de ses attributs.

Alors que les attributs sont communs aux deux GSM, les valeurs de ces derniers peuvent être différentes. Mais comme on peut le constater sur cet exemple, plusieurs attributs peuvent également avoir les mêmes valeurs (la marque et le modèle, dans cet exemple).

En plus des attributs, un objet offre toute une série de fonctionnalités. On peut, par exemple, effectuer et recevoir un appel, envoyer un SMS, écouter un fichier MP3, consulter le niveau de charge de la batterie, prendre une photo, jouer à un jeu, etc. Il s'agit d'opérations qui vont être appliquées à un objet.

Pour résumer, un objet est une entité qui possède des attributs et propose des fonctionnalités. Chaque objet est unique et possède son état propre, qui est défini par la valeur de ses attributs.

II-A-1-b - Objet en Python

Python est, entre autres, un langage de programmation orienté objet. On manipule dès lors déjà des objets depuis le début, sans vraiment le savoir. Par exemple, les listes, les chaines de caractères et les ensembles sont des objets. Lorsqu'on crée un objet, les trois éléments illustrés sur la figure 2 existent :

- l'objet, c'est-à-dire les valeurs de ses attributs, est en mémoire ;

- une variable, du même type que l'objet, est déclarée et initialisée ;

- une référence vers l'objet est stockée dans la variable.

L'instruction suivante crée un nouvel ensemble contenant six nombres entiers représentant les valeurs des faces d'un dé :

dice = {1, 2, 3, 4, 5, 6}

Un ensemble est un objet de type set, qui stocke les valeurs des six faces du dé. Une référence vers cet objet est stockée dans la variable dice, dont le type est le même que celui de l'objet, à savoir set.

Figure 2. Un objet se trouve en mémoire et on peut le manipuler à l'aide d'une variable qui contient une référence vers celui-ci.

En réalité, tout est objet en Python. D'autres types d'objets qu'on a déjà vus sont donc les nombres entiers et flottants et les booléens.

II-B - Construction et utilisation

Pour pouvoir utiliser un objet dans un programme, il faut avant tout le créer, c'est-à-dire préparer de la place en mémoire pour stocker la valeur de ses attributs, et initialiser ces valeurs. Une fois cela fait, on va pouvoir utiliser l'objet, c'est-à-dire accéder à ses attributs (les lire et les modifier) et exploiter les fonctionnalités offertes par l'objet. L'utilisation d'un objet se fait grâce à la variable stockant une référence vers celui-ci.

Dans cette section, on va utiliser des objets prédéfinis de type time. Ces derniers permettent de représenter un instant temporel identifié par des heures, minutes et secondes. Pour pouvoir utiliser des objets time, il faut avant tout importer le module datetime.

II-B-1 - Constructeur

La création d'un nouvel objet passe par l'appel d'un constructeur. Il s'agit d'une fonction particulière dont le nom est le même que celui du type de l'objet à créer.

On a déjà vu plusieurs fois l'utilisation d'un constructeur, notamment lorsqu'on voulait créer des copies de structures de données avec les fonctions list, set et dict, par exemple, ou lorsqu'on effectuait des conversions de données avec les fonctions int, float et str, par exemple.

L'exemple suivant crée deux objets de type time :

1. from datetime import time

3. start = time(14, 45, 21)

4. end = time(16, 15, 56)

Pour créer un nouvel objet time, il faut donc fournir trois paramètres correspondants aux heures, minutes et secondes de l'objet à créer. Les deux objets créés, représentés sur la figure 3, représentent respectivement les instants 14:45:21 et 16:15:56.

Figure 3. Deux objets de type time sont distincts en mémoire et possèdent chacun leur état propre, caractérisé par la valeur de ses attributs.

Une fois un objet créé, on va pouvoir l'utiliser, et en particulier accéder à ses attributs. Pour cela, on passe par la variable contenant la référence vers l'objet et on utilise l'opérateur d'accès (.).

Un objet de type time possède plusieurs attributs dont hour, minute et second. L'exemple suivant calcule l'équivalent en secondes d'un instant temporel, afin de faire la différence entre deux instants. Pour cela, il suffit de multiplier les heures par , les minutes par et de sommer le tout, pour les deux objets :

1. from datetime import time

3. start = time(14, 45, 21)

4. end = time(16, 15, 56)

6. startsec = 3600 * start.hour + 60 * start.minute + start.second

7. endsec = 3600 * end.hour + 60 * end.minute + end.second

9. diff = endsec - startsec

10. print("La différence est de", diff, "secondes.")

La différence est de 5435 secondes.

L'accès aux attributs peut être fait en lecture et/ou en écriture. Par exemple, pour les objets de type time, les trois attributs qu'on vient de voir ne sont accessibles qu'en lecture. Si on tente de modifier la valeur d'un de ces attributs, une erreur se produit lors de l'exécution :

1. from datetime import time

3. start = time(14, 45, 21)

4. start.minute = 15

Comme on le constate ci-dessous, l'attribut minute des objets de type time n'est pas modifiable :

1. Traceback (most recent call last):

2. File "program.py", line 4, in <module>

3. start.minute = 15

4. AttributeError: attribute 'minute' of 'datetime.time' objects is not writable

II-B-1-a - Paramètre de type objet

Si on revient sur l'exemple précédent, qui permet de calculer la différence en secondes entre deux instants, on se rend compte qu'il y a de la duplication de code.

Ce n'est pas une bonne pratique, comme on l'a déjà précédemment vu, et on va donc améliorer ce code. Une solution consiste à définir une fonction qui reçoit un objet de type time en paramètre et qui renvoie l'équivalent en secondes de cet instant :

def toseconds(t):
return 3600 * t.hour + 60 * t.minute + t.second

Le paramètre t reçu en paramètre est donc un objet de type time, ce qui permet d'accéder à ses attributs dans le corps de la fonction. Sur base de cette fonction, on peut simplifier le calcul de la différence entre deux instants. Le précédent exemple devient :

1. from datetime import time

3. start = time(14, 45, 21)

4. end = time(16, 15, 56)

6. diff = toseconds(end) - toseconds(start)

7. print("La différence est de", diff, "secondes.")

II-B-1-b - Valeur de retour de type objet

En plus de pouvoir recevoir un paramètre de type objet, une fonction peut également renvoyer un objet, ou plus précisément une référence vers un objet. Définissons une fonction after qui reçoit un premier paramètre de type time et un second de type int. Le premier représente un instant dans le temps et le second une durée en minutes. Le but de la fonction est de calculer l'instant qu'il sera une fois la durée écoulée (en supposant que l'on ne dépasse pas 23:59:59).

Pour résoudre ce problème, on va utiliser les opérateurs de division entière et de reste de la division entière. Comme vous le constatez ci-dessous, la fonction renvoie bel et un bien un nouvel objet :

1. def after(start, duration):

2. minute = start.minute + (duration % 60)

3. hour = start.hour + (duration // 60) + (minute // 60)

4. return time(hour, minute % 60, start.second)

Étant donné duration minutes, duration // 60 représente le nombre d'heures et duration % 60 le nombre de minutes correspondants. Par exemple, un duration de correspond à une heure et trente minutes. Pour calculer l'instant final, il suffit d'ajouter ces deux quantités aux heures et minutes initiales, et si le nombre de minutes dépasse , il faut augmenter les heures d'une unité et diminuer les minutes de unités.

Une fois la fonction définie, on peut l'utiliser pour construire des objets de type time, comme on peut le constater sur base de l'exemple suivant :

1. start = time(14, 45, 21)

2. end = after(start, 90)

4. print('Après 90 minutes, il sera', end)

5. print(type(end))

On voit bien sur le résultat de l'exécution que la valeur renvoyée par l'appel de la fonction after est un objet de type time :

Après 90 minutes, il sera 16:15:21

II-C - Méthode

Une fois un objet construit, on vient de voir qu'on peut accéder à ses attributs, en lecture et/ou et écriture. On peut également utiliser les fonctionnalités qu'il offre. Ces dernières sont représentées par des fonctions, appelées méthodes, qu'il sera possible d'appeler, une fois l'objet créé, sur ce dernier.

II-C-1 - Appel de méthode

Une fonction associée à un objet est appelée méthode. Pour appeler une méthode, on utilise l'opérateur d'appel (.) sur la variable contenant une référence vers l'objet dont on veut utiliser une fonctionnalité.

Pour illustrer cela, regardons du côté des objets de type TextCalendar, qui représentent des calendriers. Pour créer un objet TextCalendar, il faut importer le module calendar, puis on peut directement créer un nouvel objet comme suit, le constructeur n'admettant aucun paramètre :

1. from calendar import TextCalendar

3. cal = TextCalendar()

4. print(type(cal))

<class 'calendar.TextCalendar'>

Les objets de type TextCalendar offrent une méthode prmonth qui permet d'afficher le calendrier de n'importe quel mois de n'importe quelle année. Pour cela, il faut fournir deux paramètres lors de l'appel à la méthode, respectivement l'année et le mois désiré. Par exemple, si on veut afficher le calendrier d'octobre 2015, il suffit d'écrire :

cal.prmonth(2015, 10)

Dans cette instruction, on a donc appelé la méthode prmonth sur l'objet qui est référencé par la variable cal, en lui fournissant deux paramètres, à savoir les nombres entiers et . Cet appel de méthode a pour résultat d'afficher le calendrier demandé à l'écran :

1. October 2015

2. Mo Tu We Th Fr Sa Su

3. 1  2  3  4

4. 5  6  7  8  9 10 11

5. 12 13 14 15 16 17 18

6. 19 20 21 22 23 24 25

7. 26 27 28 29 30 31

La méthode appelée affiche donc directement le calendrier demandé. Il s'agit d'une méthode sans valeur de retour. Tout comme pour les fonctions (une méthode étant simplement une fonction associée à un objet), une méthode peut être avec ou sans valeur de retour.

Au lieu d'afficher le calendrier directement, on pourrait vouloir le récupérer dans une variable. Pour cela, les objets de type TextCalendar proposent une méthode formatmonth qui fonctionne exactement de la même manière que la méthode prmonth, sauf qu'elle renvoie le calendrier sous forme d'une chaine de caractères au lieu de l'afficher :

result = cal.formatmonth(2015, 10)
print(result)

II-C-1-a - Objet cible

Une méthode est donc toujours appelée sur un objet. Ce dernier, appelé objet cible, est celui sur lequel la méthode appelée pourra opérer. Déclarons, par exemple, deux objets de type set :

numbers = {8, 3, 1, -2, 0}
letters = {'A', 'P', 'Q'}

Ces deux instructions créent deux objets en mémoire, ceux-ci étant référencés par les variables numbers et letters, comme le montre la figure 4. Le premier ensemble contient cinq éléments et le second trois.

Figure 4. Les deux variables numbers et letters référencent deux objets différents, tous deux de type set.

Les objets de type set possèdent une méthode pop qui permet de récupérer un élément arbitraire dans l'ensemble. On peut évidemment appeler cette méthode sur chacun des deux objets, en utilisant numbers ou letters comme objet cible. La méthode pop agira à chaque fois sur l'objet cible, en en récupérant un élément de manière arbitraire, comme le montre l'exemple suivant, avec un résultat d'exécution possible :

print(numbers.pop())
print(letters.pop())

8
Q

II-D - Programmation orientée objet

L'objet est au cœur de la programmation orientée objet, par opposition à la programmation procédurale où un programme se construit sur base de fonctions. Ici, on commence par créer un objet, pour ensuite l'utiliser par le biais de ses méthodes.

II-D-1 - Identité et état d'un objet

Un objet, une fois créé, possède un état constitué des valeurs de ses attributs. Juste après sa construction, l'objet est dit initialisé, se trouvant dans son état initial. De plus, un objet possède une identité propre, notamment caractérisée par la position où il se trouve en mémoire.

Alors que deux objets différents peuvent tout à fait avoir le même état, ils n'auront jamais la même identité. En Python, on peut utiliser la fonction prédéfinie id pour obtenir l'identité d'un objet. L'exemple ci-dessous crée deux objets différents de type set et affiche leur identité :

1. numbers = {8, 3, 1, -2, 0}

2. letters = {'A', 'P', 'Q'}

4. print(id(numbers))

5. print(id(letters))

Les deux valeurs affichées après exécution sont bien différentes, témoignant des identités distinctes des deux objets créés :

4302577224
4329799752

Dans l'exemple suivant, on crée de nouveau deux objets différents, de type time cette fois-ci, mais qui représentent la même heure. Ces deux objets ont donc le même état, mais gardent des identités différentes :

1. from datetime import time

3. t1 = time(12, 45, 32)

4. t2 = time(12, 45, 32)

Deux opérateurs permettent de comparer des objets entre eux. L'opérateur d'égalité (==) permet de tester si deux objets ont le même état. L'opérateur d'identité (is) permet de tester si deux objets ont la même identité. On peut donc, par exemple, écrire :

print(t1 == t2)
print(t1 is t2)

La première instruction affiche True, car les deux objets possèdent exactement le même état, puisqu'ils représentent tous les deux l'instant 12:45:32. Par contre, la seconde instruction affiche False, car les deux objets n'ont pas la même identité. Il y a en effet bel et bien deux objets qui ont été créés en mémoire, même s'ils ont le même état :

True
False

II-D-1-a - Alias

On peut stocker des références vers le même objet dans plusieurs variables différentes, c'est-à-dire créer des alias. On a déjà vu cette notion précédemment, mais revoyons un nouvel exemple :

s = {9, 12, -5}
a = s

Dans cet exemple, un objet est créé par la première instruction et une référence vers ce dernier est stockée dans la variable s. La deuxième instruction copie juste le contenu de la variable s dans la variable a, c'est-à-dire la référence vers l'objet comme le montre la figure 5. On dit que la variable a est un alias de la variable s.

Figure 5. La variable a est un alias de la variable s, c'est-à-dire qu'elles contiennent toutes les deux une référence vers le même objet.

Les deux variables permettent d'agir sur le même objet. Par exemple, si on utilise la variable a pour ajouter un élément dans l'ensemble, puis qu'on affiche le contenu de l'ensemble avec la variable s, on verra que le changement a bien eu lieu sur l'unique objet qui existe en mémoire, référencé par les deux variables :

a.add('NEW')
print(s)

{'NEW', 9, -5, 12}

II-D-1-b - Différence tuple nommé/objet

Les objets ressemblent beaucoup aux tuples nommés, ces derniers étant en quelque sorte les précurseurs des objets. Tous les deux permettent de stocker des données, appelées champs pour les tuples nommés et attributs pour les objets, accessibles avec l'opérateur d'accès. Alors qu'un tuple nommé est toujours non modifiable, les attributs d'un objet peuvent être modifiés s'ils ne sont pas en lecture seule.

Si on veut effectuer des opérations sur un tuple nommé, il faut définir une fonction qui reçoit le tuple nommé en paramètre. Pour effectuer des opérations sur un objet, on l'utilise comme objet cible lors de l'appel d'une méthode. La figure 6 résume ces deux types d'appels.

Figure 6. L'appel d'une fonction qui opère sur un tuple nommé est similaire à l'appel d'une méthode sur un objet. Alors qu'on doit passer le tuple nommé en paramètre de la fonction dans le premier cas, la méthode s'applique sur l'objet cible dans le second cas.

Enfin, une dernière différence entre les tuples nommés et les objets est qu'avec ces derniers, l'accès aux attributs peut être restreint aux méthodes de l'objet. Ils sont en effet parfois simplement cachés, et dès lors accessibles ni en lecture ni en écriture. Pour un tuple nommé, ils sont par contre toujours accessibles en lecture.

II-D-1-c - Tout est objet

En Python, toutes les données sont de type objet, même les nombres entiers, par exemple. La figure 7 montre un objet de type int créé en mémoire et dont une référence est stockée dans la variable temperature. On l'obtient avec l'instruction suivante :

temperature = 19

Tout ce qu'on a vu sur les objets s'applique donc aussi sur les types de données qu'on a vus depuis le début du livre. Par exemple, l'instruction x = temperature crée un alias.

Figure 7. Un nombre entier est un objet en Python, et temperature = 19 contient donc une référence vers un objet de type int représentant le nombre 19.

Quelles sont les méthodes disponibles pour les objets de type int ? On peut le savoir à l'aide de la fonction prédéfinie dir, qui renvoie une liste comme le montre l'exemple suivant :

temperature = 19
print(dir(temperature))

['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__', '__init__', '__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__', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'numerator', 'real', 'to_bytes']

On voit, par exemple, une méthode real qui permet d'obtenir la partie réelle du nombre et une méthode imag qui permet d'obtenir la partie imaginaire. Dans le cas d'un nombre entier, cette dernière renverra évidemment toujours .

Les méthodes dont les noms sont entourés de __ sont privées et ne devraient pas être utilisées directement, mais indirectement. Par exemple, la méthode __add__ correspond à l'opérateur d'addition (+). Les deux instructions suivantes font exactement la même opération, et affichent toutes les deux  :

print(temperature + 11)
print(temperature.__add__(11))

Comme expliqué dans le chapitre suivant, Python permet de définir des notations simplifiées pour appeler des méthodes, remplaçant l'appel par l'utilisation d'un opérateur.

III - Classe

Dans le chapitre précédent, on a vu le principe de la programmation orientée objet et comment il est possible de créer et utiliser des objets dans un programme Python. Ce chapitre présente les classes, construction permettant de définir des nouveaux objets.

III-A - Objet et classe

Pour pouvoir créer des objets dans un programme, il faut avant tout avoir défini une classe. Il s'agit en quelque sorte d'un plan, qui décrit les caractéristiques de l'objet. En particulier, une classe définit les deux éléments constitutifs des objets que sont ses attributs et fonctionnalités.

Un objet est donc construit à partir d'une classe et on dit qu'il en est une instance. La classe est le modèle à partir duquel il est possible de créer autant d'objets que l'on souhaite. La figure 1 illustre ce concept en montrant quatre instances qu'il est possible d'obtenir à partir d'une classe GSM (représentée au centre).

Figure 1. Un objet est une instance d'une classe, qui possède son propre état, défini par la valeur de ses différents attributs.

III-A-1 - Attribut et fonctionnalité

Pour rappel, un objet possède des attributs et offre des fonctionnalités. Un attribut est une donnée stockée dans l'objet et qui permet de le caractériser. L'ensemble des valeurs de tous les attributs d'un objet donné définit son état. Une fonctionnalité permet d'effectuer une opération grâce à l'objet. On peut, par exemple, l'interroger pour obtenir une information le concernant, ou alors effectuer une action sur cet objet.

La figure 2 montre une variable g de type GSM qui contient une référence vers un objet, lien indiqué par une flèche simple (). L'objet ainsi créé est une instance d'une classe, représentée à droite sur l'image, lien indiqué par la flèche inversée (). Dans la classe, on peut voir deux parties séparées par un trait horizontal :

- la partie supérieure liste tous les attributs qu'auront tous les objets instanciés à partir de cette classe ;

- la partie inférieure liste toutes les fonctionnalités applicables aux objets qui seront construits à partir de cette classe.

Enfin, dans l'objet, on retrouve les valeurs de tous les attributs. Ainsi, cette instance en particulier a « Sony Ericsson » comme valeur pour l'attribut Marque, « w200i » pour l'attribut Modèle et enfin « Orange-Blanc » comme Couleur.

Figure 2. Un objet est une instance d'une classe, possède une valeur pour chacun de ses attributs et offre plusieurs fonctionnalités qui y sont décrites.

III-A-1-a - Définition de classe

On définit une nouvelle classe à l'aide du mot réservé class suivi d'un nom, d'un deux-points (:) et on termine avec le corps de la classe indenté d'un niveau. L'exemple suivant définit une nouvelle classe appelée Contact qui permet de représenter un élément d'un carnet de contacts :

class Contact:
pass

Le mot réservé pass représente l'instruction vide, c'est-à-dire que son exécution ne fait rien. On vient donc de définir une classe Contact dont le corps est vide, c'est-à-dire qu'elle ne possède aucun attribut ni fonctionnalité, propres à elle.

On peut dorénavant créer des instances à partir de cette classe. L'exemple suivant crée deux objets à partir de la classe :

a = Contact()
b = Contact()

La figure 3 montre la situation en mémoire après exécution de ces deux instructions. On y voit clairement qu'il y a une seule classe Contact à partir de laquelle deux objets ont été créés. De plus, des références vers ces objets se trouvent respectivement dans les variables a et b, qui sont dès lors de type Contact.

Figure 3. Un objet est une instance d'une classe, possède une valeur pour chacun de ses attributs et offre plusieurs fonctionnalités qui y sont décrites.

III-B - Constructeur et variable d'instance

On a vu, dans le chapitre précédent, que le constructeur est appelé lors de la création d'un nouvel objet. Il s'agit en fait d'une méthode particulière dont le code est exécuté lorsqu'une classe est instanciée.

III-B-1 - Définition de constructeur

Le constructeur se définit dans une classe comme une fonction avec deux particularités :

- le nom de la fonction doit être __init__ ;

- la fonction doit accepter au moins un paramètre, dont le nom doit être self, et qui doit être le premier paramètre.

Le paramètre self représente en fait l'objet cible, c'est-à-dire que c'est une variable qui contient une référence vers l'objet qui est en cours de création. Grâce à ce dernier, on va pouvoir accéder aux attributs et fonctionnalités de l'objet cible.

Modifions la classe Contact en lui ajoutant un constructeur qui permet de créer un nouveau contact en renseignant son prénom, son nom et son numéro de téléphone :

1. class Contact:

2. def __init__(self, firstname, lastname, phonenumber):

3. print(firstname)

4. print(lastname)

5. print(phonenumber)

7. brice = Contact('Brice', 'Thomas', 6942)

Le constructeur de la classe Contact reçoit donc trois paramètres, en plus du paramètre spécial self, et se contente de les afficher. Lorsqu'on crée une instance de cette classe, il faut donc spécifier des valeurs pour les trois paramètres comme on peut le voir sur la dernière instruction qui crée un objet représentant Brice Thomas.

On peut voir ce qui suit affiché à l'écran après exécution du programme, ce qui témoigne bien du fait que le constructeur a été exécuté :

Brice
Thomas
6942

III-B-1-a - Référence

Qu'en est-il de la variable brice ? Cette dernière contient tout simplement une référence vers l'objet qui a été créé, c'est-à-dire une indication sur son emplacement en mémoire. On peut observer cela en affichant à l'écran la variable brice, ce qui révèle qu'elle contient une référence vers un objet Contact se trouvant en mémoire à la position 0x10f805c88 :

print(brice)

<__main__.Contact object at 0x10f805c88>

III-B-1-b - Variable d'instance

La classe Contact, telle qu'elle est pour le moment, n'est pas encore des plus utiles. En effet, il serait souhaitable de pouvoir stocker les informations du contact dans l'objet qui a été créé. On voudrait avoir une situation en mémoire telle que celle présentée à la figure 4, c'est-à-dire avoir des attributs stockant les informations du contact.

Figure 4. Un objet de type Contact possède trois attributs représentant respectivement le prénom, le nom et le numéro de téléphone.

Un attribut est représenté par une variable particulière, appelée variable d'instance, accessible à l'aide du paramètre self. L'idée est que le constructeur initialise ces variables d'instances, qui seront alors stockées dans l'objet et en mémoire pour toute la durée de vie de l'objet. On modifie donc le constructeur de la classe Contact en :

1. class Contact:

2. def __init__(self, firstname, lastname, phonenumber):

3. self.firstname = firstname

4. self.lastname = lastname

5. self.phonenumber = phonenumber

Il est important de faire la différence entre les deux types de variables qui se trouvent dans le code de ce constructeur :

- la variable self.firstname représente la variable d'instance, c'est-à-dire celle associée à l'objet, qui existe à partir de la création de l'objet jusque sa destruction ;

- la variable firstname représente le paramètre reçu par le constructeur et n'existe que dans le corps de ce dernier.

Le paramètre self permet donc d'accéder aux variables d'instance, c'est-à-dire aux attributs de l'objet cible, depuis le constructeur.

Revenons maintenant à la création d'un objet de type Contact. Puisqu'on a initialisé des variables d'instance dans le constructeur, on va pouvoir y accéder pour n'importe quel objet créé. Si on reprend brice, on va pouvoir afficher la valeur des trois variables d'instance à l'aide de l'opérateur d'accès (.) :

1. brice = Contact('Brice', 'Thomas', 6942)

3. print(brice.firstname)

4. print(brice.lastname)

5. print(brice.phonenumber)

Comme on peut le constater sur le résultat de l'exécution, les valeurs passées en paramètres au constructeur lors de la création de l'objet ont bien été enregistrées dans les variables d'instance de l'objet :

Brice
Thomas
6942

Les variables d'instance sont spécifiques à chaque objet et ce sont d'ailleurs elles qui définissent l'état de l'objet. Pour illustrer cela, voyons un dernier exemple où on crée deux instances de la classe Contact :

barack = Contact('Barack', 'Obama', 9381)
lurkin = Contact('Quentin', 'Lurkin', 0)

La figure 5 montre l'état de la mémoire après exécution de ces deux instructions. On y voit clairement la classe Contact et les deux instances de cette dernière qui ont été créées. Chaque instance possède bel et bien son espace propre en mémoire, où sont stockées les valeurs de ses variables d'instance.

Figure 5. Deux instances d'une même classe occupent deux zones mémoire distinctes déterminant l'identité des objets et dont le contenu définit leur état.

III-B-1-c - Plusieurs constructeurs

Comme on peut le constater, on a spécifié la valeur lorsqu'on a créé l'objet représentant Quentin Lurkin. Cela peut, par exemple, signifier que le numéro de téléphone de ce contact n'est pas connu. Il serait dès lors pratique de pouvoir avoir un deuxième constructeur à qui on ne donnerait que le prénom et le nom.

On ne peut pas avoir plusieurs constructeurs dans une classe, mais on peut utiliser la valeur par défaut des paramètres pour arriver à la même fin. Il suffit donc de modifier le constructeur de la classe Contact comme indiqué ci-dessous :

1. class Contact:

2. def __init__(self, firstname, lastname, phonenumber=0):

3. self.firstname = firstname

4. self.lastname = lastname

5. self.phonenumber = phonenumber

On peut maintenant créer Quentin Lurkin sans spécifier de numéro de téléphone et réécrire l'exemple précédent comme suit :

barack = Contact('Barack', 'Obama', 9381)
lurkin = Contact('Quentin', 'Lurkin')

Pour résumer cette section, le constructeur est donc une méthode particulière qui est exécutée au moment de la création d'un nouvel objet. Son but principal consiste à initialiser l'objet, c'est-à-dire au moins initialiser ses différentes variables d'instance.

III-C - Méthode

Maintenant qu'on est capables d'initialiser un objet et ses attributs, on va pouvoir s'intéresser aux fonctionnalités qui sont, pour rappel, représentées par des méthodes. Une méthode n'est rien de plus qu'une fonction qui s'applique sur un objet cible, spécifié lors de son appel.

III-C-1 - Définition de méthode

Une méthode se définit dans une classe comme une fonction, avec comme particularité qu'elle doit accepter au moins un paramètre, dont le nom doit être self, et qui doit être le premier paramètre. Ce paramètre représente l'objet cible sur lequel la méthode est appelée. Il permet notamment d'avoir accès aux variables d'instance de l'objet.

Ajoutons, par exemple, une méthode setphonenumber à la classe Contact, qui permet de modifier le numéro de téléphone d'un contact :

1. class Contact:

2. def __init__(self, firstname, lastname, phonenumber=0):

3. self.firstname = firstname

4. self.lastname = lastname

5. self.phonenumber = phonenumber

7. def setphonenumber(self, phonenumber):

8. self.phonenumber = phonenumber

La méthode setphonenumber reçoit simplement un numéro de téléphone en paramètre et écrase la valeur de la variable d'instance phonenumber avec cette nouvelle valeur reçue.

III-C-1-a - Appel de méthode

Pour appeler une méthode, il faut indiquer l'objet cible suivi de la méthode à appeler en utilisant l'opérateur d'appel (.). L'exemple suivant crée un objet de la classe Contact, sans préciser le numéro de téléphone, puis modifie ce dernier en appelant la méthode setphonenumber :

1. lurkin = Contact('Quentin', 'Lurkin')

2. print(lurkin.phonenumber)

4. lurkin.setphonenumber(8293)

5. print(lurkin.phonenumber)

La première instruction print affiche , c'est-à-dire la valeur à laquelle est initialisée la variable d'instance phonenumber par défaut. La seconde instruction print affiche , témoignant du fait que l'appel à la méthode setphonenumber a bien fait son travail :

0
8293

III-C-1-b - Plusieurs méthodes de même nom

Comme pour les constructeurs, on peut avoir plusieurs méthodes qui ont le même nom en utilisant les valeurs par défaut des paramètres. Définissons, par exemple, une méthode changename qui permet de changer le prénom et/ou le nom d'un contact :

1. def changename(self, firstname=None, lastname=None):

2. if firstname is not None:

3. self.firstname = firstname

4. if lastname is not None:

5. self.lastname = lastname

On utilise la valeur spéciale None comme valeur par défaut pour les deux paramètres. Il s'agit d'une valeur spéciale dont la signification est justement l'absence de valeur. Dans le corps de la méthode, on modifie les variables d'instance pour le prénom et/ou le nom, si la valeur des paramètres reçus est différente de None. Voici différents appels que l'on peut faire sur un contact :

- lurkin.changename('John', 'Doe') change le prénom et le nom du contact ;

- lurkin.changename('John') change uniquement le prénom du contact ;

- lurkin.changename(lastname='Doe') change uniquement le nom du contact.

III-D - Programmation orientée objet

Comme on l'a déjà vu dans le chapitre précédent, en programmation orientée objet, on modélise les objets réels par des objets informatiques. Ces derniers sont l'élément constitutif de ce type de programmation. Cette section présente quelques aspects clés de la programmation orientée objet et la manière de les implémenter en Python.

Voyons avant tout deux nouveaux exemples de classe, présentés au listing de la figure 6. La classe Vector permet de représenter un vecteur dans le plan, composé de deux coordonnées et . Elle propose une méthode norm permettant de calculer la norme du vecteur, qui vaut, pour rappel, . La classe Music représente une musique décrite par un titre, une liste d'artistes et une durée en secondes. Elle propose une méthode hasAuthor qui teste si un artiste spécifié fait partie des artistes de la musique en question.

Figure 6. Le fichier classexamples.py contient deux définitions de classe : Vector représente un vecteur dans le plan et Music représente une musique.

1. from math import sqrt

3. class Vector:

4. def __init__(self, x, y):

5. self.x = x

6. self.y = y

8. def norm(self):

9. return sqrt(self.x ** 2 + self.y ** 2)

12. class Music:

13. def __init__(self, title, artists, duration):

14. self.title = title

15. self.artists = artists

16. self.duration = duration

18. def hasAuthor(self, name):

19. return name in self.artists

Une fois définies, on peut utiliser ces classes pour en créer des instances. On peut, par exemple, écrire les instructions suivantes :

1. from classexamples import Vector, Music

3. u = Vector(1, -1)

4. print(u.norm())

6. m = Music('Easy Come Easy Go', ['Alice on the roof'], 213)

7. print(m.hasAuthor('Stromae'))

On suppose que ces instructions sont placées dans un fichier se trouvant dans le même dossier que le fichier classexamples.py, permettant ainsi l'importation des deux classes depuis le module classexamples.

L'exécution de ces instructions affiche donc d'abord la norme du vecteur qui vaut et teste ensuite si Stromae est un artiste de la musique Easy Come Easy Go ou non, et comme ce n'est pas le cas, affiche False :

1.4142135623730951
False

III-D-1 - Représentation d'un objet

Comme on l'a vu précédemment, si on tente d'afficher un objet à l'aide de la fonction print, le résultat montrera le type d'objet ainsi que l'adresse mémoire où il se situe, comme on le voit sur le résultat de l'exécution des instructions suivantes :

u = Vector(1, -1)
print(u)

<classexamples.Vector object at 0x102898b00>

Ce comportement par défaut n'est pas forcément des plus utiles. Ce qui serait plus intéressant serait de pouvoir obtenir une représentation de l'objet sous forme d'une chaine de caractères. Cette dernière pourrait contenir l'état ou une partie de l'état de l'objet.

En Python, il suffit d'ajouter une méthode nommée __str__ sans paramètre (à part l'obligatoire self puisque c'est une méthode) qui renvoie une chaine de caractères. Voici, par exemple, ce que pourrait être cette méthode pour la classe Vector :

def __str__(self):
return "(" + str(self.x) + ", " + str(self.y) + ")"

On construit donc une chaine de caractères par concaténation, qui contient les valeurs des coordonnées et séparées par une virgule et le tout entouré de parenthèses. Si on exécute de nouveau l'exemple précédent, on obtient maintenant :

(1, -1)

L'idée derrière la méthode __str__ est donc de proposer une représentation textuelle de l'objet. De manière brute, on pourrait se contenter d'inclure les valeurs de toutes les variables d'instance, mais il est évidemment préférable de construire une représentation qui fait sens, par rapport à l'objet représenté.

Par exemple, pour un objet Music, on peut se limiter à inclure le titre de la musique et la liste des artistes, sans utiliser la durée. On obtiendrait ainsi la méthode suivante :

1. def __str__(self):

2. result = '"' + self.title + '" par ' + self.artists[0]

3. for i in range(1, len(self.artists)):

4. result += ', ' + self.artists[i]

5. return result

L'exemple suivant et le résultat de son exécution montrent ce qu'on obtient avec cette méthode __str__ :

m = Music('Si demain', ['Bonnie Tyler', 'Kareen Antonn'], 230)
print(m)

"Si demain" par Bonnie Tyler, Kareen Antonn

III-D-1-a - Surcharge d'opérateur

Revenons maintenant sur la classe Vector. Une opération que l'on doit pouvoir faire consiste à additionner deux vecteurs entre eux. Le résultat de l'opération est un nouveau vecteur dont les composantes sont les sommes des composantes des vecteurs additionnés. Ajoutons pour cela une méthode add dans la classe Vector. Elle prend un vecteur en paramètre, l'additionne avec celui représenté par l'objet cible et renvoie un nouveau vecteur correspondant au résultat de cette somme :

def add(self, other):
return Vector(self.x + other.x, self.y + other.y)

La méthode renvoie donc un nouvel objet de type Vector dont les composantes et s'obtiennent en faisant la somme des composantes de l'objet représenté par self (le vecteur de l'objet cible) avec celles de l'objet représenté par other (le vecteur reçu en paramètre).

Une fois cette méthode ajoutée à la classe, on peut l'utiliser pour additionner deux vecteurs et, par exemple, écrire :

u = Vector(1, -1)
v = Vector(2, 2)
print(u.add(v))

L'exécution de ces trois instructions affiche :

(3, 1)

Un nouvel objet Vector a donc bien été créé, correspondant à la somme des vecteurs et . Comme l'opération que l'on a définie représente une addition, ce serait bien de pouvoir utiliser l'opérateur d'addition (+). En Python, il suffit de définir une méthode __add__ qui accepte un paramètre qui est le vecteur à additionner. On remplace donc simplement la méthode add précédemment définie par :

def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)

Grâce à cela, on peut maintenant utiliser directement l'opérateur d'addition pour sommer deux vecteurs :

u = Vector(1, -1)
v = Vector(2, 2)
print(u + v)

Cette capacité du langage Python s'appelle la surcharge d'opérateur. La figure 7 reprend les différents opérateurs qu'il est possible de surcharger, avec le nom de la méthode à utiliser.

Figure 7. On peut utiliser plusieurs opérateurs sur des objets nouvellement définis en les surchargeant dans la classe qui définit ces objets, c'est-à-dire en définissant une méthode avec un nom particulier.

Opérateur

Notation

Méthode à définir

Signe positif

+

__pos__

Signe négatif

-

__neg__

Addition

+

__add__

Soustraction

-

__sub__

Multiplication

*

__mul__

Division

/

__truediv__

Exponentiation

**

__pow__

Division entière

//

__floordiv__

Reste de la division entière

%

__mod__

Égal

==

__eq__

Différent

!=

__ne__

Strictement plus petit

<

__lt__

Plus petit ou égal

<=

__le__

Strictement plus grand

>

__gt__

Plus grand ou égal

>=

__ge__

« non » logique

not

__not__

« et » logique

and

__and__

« ou » logique

or

__or__

III-D-1-b - Égalité

On a précédemment vu qu'on pouvait vouloir comparer les états ou les identités de deux objets. La comparaison des états se fait avec l'opérateur d'égalité (==) et celle des identités avec l'opérateur d'identité (is).

L'exemple suivant crée deux objets Vector distincts en mémoire, c'est-à-dire qu'ils ont des identités différentes. L'utilisation de is doit donc renvoyer False. Néanmoins, les deux objets représentent exactement le même vecteur, à savoir . Ils ont donc le même état et l'utilisation de == doit renvoyer True.

Le code suivant et le résultat de son exécution montrent un souci par rapport aux valeurs attendues pour l'utilisation de is et == :

1. u = Vector(1, 1)

2. v = Vector(1, 1)

4. print(u == v)

5. print(u is v)

False
False

Que s'est-il passé ? On a défini un nouveau type d'objet, représentant des vecteurs, mais on n'a jamais décrit ce que ça signifie pour deux objets Vector d'être égaux. Par défaut, l'opérateur == se comporte comme l'opérateur is et compare les identités. Pour définir ce que signifie l'égalité de deux objets, il faut redéfinir l'opérateur == en définissant une méthode __eq__. Dans notre cas, deux vecteurs sont égaux s'ils ont les mêmes valeurs pour leurs composantes respectives. On ajoute donc la méthode suivante dans la classe Vector :

def __eq__(self, other):
return self.x == other.x and self.y == other.y

Si on exécute de nouveau l'exemple précédent, on obtient cette fois-ci le résultat attendu :

True
False

III-D-1-c - Encapsulation

L'implémentation de la classe Vector qu'on a faite précédemment utilise deux variables d'instance pour stocker les composantes et . Ce n'est évidemment pas la seule possibilité pour implémenter cette classe. On pourrait par exemple utiliser un tuple de deux éléments :

1. from math import sqrt

3. class Vector:

4. def __init__(self, x, y):

5. self.coords = (x, y)

7. def norm(self):

8. return sqrt(self.coords[0] ** 2 + self.coords[1] ** 2)

10. # ...suite de la classe

La méthode norm a dû être changée, par rapport à la précédente version, mais elle calcule toujours la même valeur et est toujours appelée de la même manière.

De plus, on constate qu'il y a une différence à faire entre les attributs d'un objet « vus de l'extérieur » (deux coordonnées et ) et les variables d'instance qu'il contient (une seule dans ce cas-ci).

Les détails d'implémentation, à savoir utiliser deux nombres ou un tuple en variables d'instance dans cet exemple, sont cachés de « l'extérieur ». Le code qui utilise la classe Vector ne doit donc pas être changé si on décide de changer la manière avec laquelle on stocke les données concernant le vecteur dans l'objet représentant ce dernier. Ce principe fondamental de la programmation orientée objet est appelé encapsulation.

L'encapsulation consiste à cacher les détails d'implémentation d'une classe, et ne pas les dévoiler en dehors de cette dernière. Le code qui utilise une classe doit être le plus indépendant possible de la manière avec laquelle la classe est implémentée. Suivant ce principe, il faut donc éviter, dans la mesure du possible, d'accéder directement aux variables d'instance d'un objet, en dehors du code de la classe. Prenons, par exemple, les instructions suivantes :

u = Vector(1, 1)
v = Vector(u.x + 1, u.y)

On souhaite translater le vecteur d'une unité selon l'axe et stocker le vecteur résultant dans une variable v. Exécuter ce code avec la première version de la classe Vector ne posera aucun problème, mais avec la deuxième version, on fera face à une erreur d'exécution :

1. Traceback (most recent call last):

2. File "program.py", line 2, in

3. v = Vector(u.x + 1, u.y + 1)

4. AttributeError: 'Vector' object has no attribute 'x'

En effet, dans la deuxième version de la classe Vector, on n'a plus de variable d'instance associée à l'attribut « extérieur »x. Il aurait fallu écrire l'instruction suivante :

u = Vector(1, 1)
v = Vector(u.coords[0] + 1, u.coords[1])

Les deux versions de la classe Vector violent donc le principe d'encapsulation, car un code utilisant des objets Vector dépend fortement des choix d'implémentation de cette classe et est sensible à des modifications de cette dernière.

III-D-1-c-i - Accesseur

Il est recommandé de ne pas accéder directement aux variables d'instance d'un objet et de limiter les interactions avec ce dernier à des appels de méthodes. Une méthode qui renvoie la valeur d'un attribut d'un objet est un accesseur. Le nom donné à la méthode est celui que l'on souhaite pour l'attribut et cette dernière doit être définie avec la décoration property. Une décoration est une information que l'on attache à une méthode et qui se déclare avant sa définition avec une arobase (@).

On peut, par exemple, définir deux accesseurs permettant d'obtenir les coordonnées et d'un vecteur. Pour la deuxième implémentation de la classe Vector, on y ajoute les définitions suivantes :

1. @property

2. def x(self):

3. return self.coords[0]

5. @property

6. def y(self):

7. return self.coords[1]

L'attribut x est donc relié à la valeur self.coords[0] et l'attribut y à la valeur self.coords[1]. Une fois ces attributs définis, ils peuvent être utilisés en dehors de la classe, pour accéder à leur valeur :

u = Vector(1, 2)
print(u.x, u.y, sep=', ')

On appelle donc un accesseur comme s'il s'agissait d'une variable d'instance, c'est-à-dire à l'aide de son nom. L'exécution du code affiche bien les valeurs des deux coordonnées du vecteur :

1, 2

Si on décide de modifier la manière avec laquelle on stocke les coordonnées d'un vecteur, il suffira de changer la définition des deux accesseurs. Le code qui utilise des objets Vector en passant par les accesseurs ne devra pas être modifié, l'encapsulation est respectée.

Un accesseur donne donc accès en lecture à un attribut, comme s'il s'agissait d'une variable d'instance. Il n'est par contre pas possible de modifier la valeur d'un attribut, le compilateur provoquant une erreur d'exécution si vous essayez :

u.x = 42

1. Traceback (most recent call last):

2. File "program.py", line 3, in

3. u.x = 42

4. AttributeError: can't set attribute

III-D-1-c-ii - Mutateur

Pour pouvoir modifier la valeur d'un attribut, il va falloir définir un mutateur. Il s'agit de nouveau d'une méthode qui porte le même nom que l'attribut désiré et qu'il faut décorer avec le nom de l'attribut suivi de .setter. La méthode accepte un paramètre qui est la nouvelle valeur que l'on désire pour l'attribut.

Pour autoriser la modification des coordonnées d'un vecteur, on ajoute les deux définitions suivantes à la classe Vector :

1. @x.setter

2. def x(self, value):

3. self.coords = (value, self.coords[1])

5. @y.setter

6. def y(self, value):

7. self.coords = (self.coords[0], y)

Comme les tuples sont non modifiables, on affecte un nouveau tuple à la variable d'instance coords, en modifiant la nouvelle coordonnée ou et en reprenant l'ancienne valeur pour l'autre composante.

Une fois les mutateurs définis, on va pouvoir modifier un attribut, de nouveau comme si c'était une variable d'instance. On peut donc, par exemple, écrire :

u = Vector(1, 2)
u.x = 42
print(u)

Le résultat de l'exécution de ces trois instructions montre que l'attribut x a bien été modifié :

(42, 2)

Dans une classe, on peut soit uniquement définir un accesseur, soit définir un accesseur et un mutateur, dans lequel cas ce dernier doit être défini après l'accesseur.

III-D-1-c-iii - Variable privée

À partir du moment où on a défini des accesseurs et des mutateurs pour les attributs, et pour respecter l'encapsulation, on ne devrait plus accéder directement aux variables d'instance en dehors du code de la classe même. Dans la version actuelle de la classe Vector, c'est toujours possible d'écrire les instructions suivantes :

u = Vector(1, 2)
u.coords = (42, u.coords[1])
print(u)

Ce code n'est évidemment pas recommandé puisqu'il viole l'encapsulation. Pour bien faire, il faudrait pouvoir empêcher l'accès direct aux variables d'instance. Une variable d'instance privée ne peut être accédée que depuis le corps de la classe la contenant, à partir de self. Tout autre accès provoque une erreur lors de l'exécution.

Pour rendre une variable d'instance privée, il suffit de préfixer son nom avec __. Modifions la classe Vector en rendant la variable d'instance coords privée :

1. from math import sqrt

3. class Vector:

4. def __init__(self, x, y):

5. self.__coords = (x, y)

7. @property

8. def x(self):

9. return self.__coords[0]

11. @property

12. def y(self):

13. return self.__coords[1]

15. # ...suite de la classe

Si on tente d'accéder à la variable d'instance en dehors de la classe, on aura cette fois-ci une erreur lors de l'exécution, que ce soit un accès en lecture ou en écriture :

u = Vector(1, 2)
u.__coords = (42, u.__coords[1])
print(u)

1. Traceback (most recent call last):

2. File "program.py", line 4, in

3. u.__coords = (42, u.__coords[1])

4. AttributeError: 'Vector' object has no attribute '__coords'

III-D-1-c-iv - Interface publique

On appelle interface publique d'un objet, l'ensemble des fonctionnalités qu'elle expose au public, c'est-à-dire qui sont accessibles à partir d'une variable contenant une référence vers l'objet. Lorsqu'on définit une nouvelle classe, il est important de bien penser cette interface publique. C'est en effet via elle que ses instances seront utilisées et, pour bien faire, elle ne devrait pas être trop souvent modifiée.

La figure 8 résume l'interface exposée par la classe Vector. On y voit tout d'abord la variable d'instance coords qui est privée, ce qu'on note avec le . Viennent ensuite les deux propriétés x et y, publiques comme indiqué par le . Enfin, on a la méthode publique norm() qui permet d'obtenir la norme du vecteur.

Figure 8. La classe Vector expose deux propriétés et une méthode publique et possède une variable d'instance privée.

III-D-1-d - Composition

Terminons ce chapitre sur la définition de classes avec la notion de composition. L'idée derrière cette notion clé de la programmation orientée objet consiste à construire des objets à partir d'autres objets. Partons d'un exemple pour comprendre cette notion. On va définir une classe qui représente un carré dessiné dans le plan :

1. class Square:

2. def __init__(self, lowerleft, side, angle=0):

3. self.__lowerleft = lowerleft

4. self.__side = side

5. self.__angle = angle

7. @property

8. def lowerleft(self):

9. return self.__lowerleft

11. # ...suite de la classe

Un carré est caractérisé par la coordonnée dans le plan de son coin inférieur gauche, par la longueur de ses côtés et par l'angle qu'il forme avec l'horizontale comme on peut le voir avec le dessin d'une instance de la classe sur la figure 9.

Figure 9. Un carré dans le plan est caractérisé par les coordonnées de son coin inférieur gauche, la longueur de ses côtés et l'angle qu'il forme avec l'horizontale.

Au lieu de représenter les coordonnées de son coin inférieur gauche avec deux valeurs et , on va plutôt utiliser un objet Vector puisqu'on a déjà défini la classe correspondante. Une instance de la classe Square est donc composée à partir d'une instance de Vector. Voici comment on procède pour construire un objet Square :

s = Square(Vector(1, 2), 5)

On voit donc bien que le premier paramètre passé au constructeur de la classe Square est un objet Vector, le deuxième est la longueur des côtés et on a laissé la valeur par défaut pour l'angle.

Cette façon de travailler permet donc de réutiliser du code existant et évite de la duplication de code. De plus, on va pouvoir utiliser automatiquement tout ce qui est défini pour les objets Vector, comme la méthode norm, par exemple. Ainsi, on pourrait sans problème écrire le code suivant :

print(s.lowerleft.norm())

On part donc de la variable s qui contient une référence vers un objet Square. Via l'un de ses accesseurs, on obtient une référence vers l'objet Vector qui représente les coordonnées du coin inférieur gauche. Comme c'est une instance de la classe Vector, on peut appeler la méthode norm pour obtenir la norme de ce vecteur et l'afficher pour obtenir :

2.23606797749979

IV - Gestion d'erreurs

Lors de l'exécution d'un programme, des erreurs peuvent se produire. Plusieurs causes sont possibles : le code source est mal écrit, ne respectant pas la syntaxe du Python, ou alors des opérations interdites ont été faites, comme une division par zéro. Le pire, ce sont les erreurs de logique, où tout semble bien se passer si ce n'est que le résultat produit finalement n'est pas celui attendu. Ce chapitre présente comment gérer les erreurs dans un programme, notamment avec le mécanisme d'exception utilisé en programmation orientée objet.

IV-A - Gestion d'erreurs

On a déjà pu voir de nombreuses situations où l'exécution d'un programme provoque une erreur. Lorsque c'est le cas, l'exécution s'arrête immédiatement et l'interpréteur Python affiche une trace d'erreur.

Cette dernière fournit des informations quant au chemin d'exécution qui a mené jusqu'à l'erreur et sur la cause de cette dernière. Prenons, par exemple, le programme suivant :

1. def percentage(score, total):

2. return score / total * 100

4. print('Alexis a obtenu', percentage(18, 20), '%')

5. print('Sébastien a obtenu', percentage(6, 0), '%')

Si on l'exécute, on obtient le résultat suivant :

1. Alexis a obtenu 90.0 %

2. Traceback (most recent call last):

3. File "program.py", line 5, in <module>

4. print('Sébastien a obtenu', percentage(6, 0), '%')

5. File "program.py", line 2, in percentage

6. return score / total * 100

7. ZeroDivisionError: division by zero

On voit donc, sur la première ligne, qu'Alexis a obtenu %. On ne voit par contre pas la note de Sébastien, mais un message d'erreur à la place. En décortiquant cette trace d'erreur, on peut y identifier plusieurs informations par rapport à l'erreur qui s'est produite :

- la première ligne, qui commence par Traceback, est le début de la trace d'erreur. Après cette ligne, on retrouve l'ordre des appels qui ont causé l'erreur ;

- le premier élément de la trace d'erreur indique son origine. Dans ce cas-ci, l'erreur provient de l'exécution de l'instruction de la ligne~5 du fichier program.py :

File "program.py", line 5, in <module>
print('Sébastien a obtenu', percentage(6, 0), '%')

- comme l'erreur est apparue suite à un appel de fonction, la trace d'erreur fournit plus d'informations quant à l'instruction précise dans la fonction qui a provoqué l'erreur. Dans ce cas-ci, l'erreur provient de l'exécution de l'instruction de la ligne~2, dans la fonction percentage :

File "program.py", line 2, in percentage
return score / total * 100

- enfin, la dernière ligne de la trace d'erreur explique ce qui a provoqué l'erreur. Dans ce cas-ci, il s'agit d'une division par zéro :

ZeroDivisionError: division by zero

La trace d'erreur permet donc de retracer l'erreur depuis son origine jusqu'à l'instruction précise qui l'a provoquée. Elle essaie également de fournir une explication utile quant à sa cause.

Comment gérer cette erreur ? Une première manière de faire consiste à rendre la fonction percentage la plus robuste possible, c'est-à-dire qu'elle ne doit jamais produire d'erreur. Pour cet exemple, on pourrait donc s'assurer qu'elle se comporte bien, peu importe la valeur de total, en renvoyant une valeur spéciale lorsque total vaut  :

1. def percentage(score, total):

2. if total != 0

3. return score / total * 100

4. return None

Cette solution n'est pas encore idéale, car il faudrait vérifier que les deux paramètres soient des nombres positifs et que score soit inférieur à total. On continue avec cet exemple en présentant d'autres solutions pour gérer ses erreurs, plus loin dans ce chapitre.

IV-A-1 - Type d'erreur

On peut distinguer trois types d'erreurs.

- Une erreur de syntaxe survient lorsque le code source du programme est mal formé. Une telle erreur se produit, par exemple, lorsqu'on oublie la condition d'un if, lorsqu'on a un else sans if associé, lorsqu'on a mal écrit un mot réservé, etc.

- Une erreur d'exécution survient lorsqu'un programme, syntaxiquement correct, effectue une opération interdite. Une telle erreur se produit, par exemple, lorsqu'on tente de faire une division par zéro, lorsqu'on ajoute une liste dans un ensemble, lorsqu'on tente d'accéder à une variable d'instance privée hors de la classe, etc.

- Une erreur logique survient lorsqu'un programme, sans erreur de syntaxe ni d'exécution, ne produit pas le résultat correct attendu. Par exemple, si on a écrit length + width au lieu de length * width pour calculer la surface d'un rectangle, le programme se terminera sans erreur, mais pas avec la bonne réponse.

IV-A-1-a - Erreur de syntaxe

Lorsqu'un programme Python comporte une erreur de syntaxe, elle ne sera détectée que lors de l'exécution, étant donné qu'il s'agit d'un langage interprété et que l'interpréteur ne passe pas en revue tout le code source avant exécution. Il se peut donc très bien qu'un programme comportant des erreurs de syntaxe se soit toujours exécuté sans erreurs. C'est, par exemple, le cas lorsqu'on a un if-else dont le bloc else n'a jamais été exécuté, alors qu'il comporte une erreur de syntaxe. Examinons le code d'exemple suivant, qui comporte une erreur de syntaxe :

score = 12
if score > 10
print('Vous avez réussi !')

Si on tente d'exécuter ce programme, on se retrouve face à l'erreur suivante :

1. File "program.py", line 2

2. if score > 10

3. ^

4. SyntaxError: invalid syntax

On voit directement qu'il s'agit d'une erreur de syntaxe grâce à la dernière ligne qui commence par SyntaxError. L'interpréteur tente de décrire et situer l'erreur le plus précisément possible. Il indique qu'il y a un souci à la ligne 2 et plus précisément tout à la fin de l'instruction comme pointé par le caractère ^ de la troisième ligne. On se rend en fait compte qu'il manque le : après la condition du if.

Il faut aussi savoir qu'il y a deux cas particuliers d'erreurs de syntaxe pour lesquels le type d'erreur sera différent :

- l'indentation du code n'est pas correcte (IndentationError) ;

- il y a une inconsistance entre l'utilisation d'espaces et de tabulations pour l'indentation d'un même bloc (TabError).

IV-A-1-a-i - Erreur d'exécution

Une erreur d'exécution se produit lorsqu'une opération interdite a été effectuée. Voici plusieurs situations qui peuvent se produire, étant donné ce qu'on a déjà vu.

- Une opération arithmétique ne peut pas être effectuée : division par zéro (ZeroDivisionError), racine carrée d'un nombre négatif (ValueError).

- Un opérateur ou une fonction est utilisé avec une donnée ou variable du mauvais type (TypeError).

- Un package n'a pas su être importé (ImportError).

- Une variable ou une fonction avec le nom précisé n'a pas su être trouvée (NameError).

- Un accès à un élément d'une séquence ne peut pas être effectué : mauvais indice dans une liste (IndexError), clé inexistante dans un dictionnaire (KeyError).

- Le nombre maximal d'appels récursifs a été atteint (RecursionError).

L'exemple suivant provoque une erreur d'exécution, car on dépasse les bornes d'une liste dont on souhaite afficher les éléments :

1. data = [1, 2, 3]

3. i = 0

4. while i <= len(data):

5. print(data[i])

6. i += 1

On commence bien avec comme premier indice, mais on va trop loin pendant la boucle puisqu'on s'arrête à la taille de la liste, alors que le plus grand indice vaut un de moins que cette dernière. Les trois valeurs de la liste sont donc bien affichées, mais s'ensuit une erreur d'exécution :

1. 1

2. 2

3. 3

4. Traceback (most recent call last):

5. File "program.py", line 5, in <module>

6. print(data[i])

7. IndexError: list index out of range

IV-A-1-a-ii - Erreur logique

Enfin, le dernier type d'erreur est le plus difficile à déceler. Lorsqu'un programme comporte une erreur logique, il s'exécute en effet sans erreur, si ce n'est que le résultat produit n'est pas celui attendu. Supposons, par exemple, que l'on écrive une fonction dont le but est de calculer le périmètre d'un rectangle :

1. def perimeter(length, width):

2. return length + width * 2

4. print(perimeter(2, 1))

On n'a malheureusement pas été attentif à la priorité des opérateurs et on a bêtement écrit la formule « longueur plus largeur fois deux » comme appris en primaires. Le problème est que le calcul fait est ; l'exécution du programme affiche donc au lieu de . Pour s'en rendre compte, il faudrait pouvoir dire à Python le résultat attendu. Mais d'un autre côté, si on écrit un programme c'est aussi pour que Python calcule ce résultat à notre place !

Heureusement, il existe des techniques, dont une basée sur des tests unitaires, qui permettent de traquer ce type d'erreur. Ces dernières ne sont pas abordées dans ce livre introductif, mais font l'objet d'autres ouvrages plus avancés.

IV-A-1-b - Documentation

Une première façon de gérer les erreurs passe par la documentation. On a déjà abordé rapidement ce sujet au chapitre 4, mais revenons maintenant plus en détail dessus. On va se concentrer uniquement sur la documentation des fonctions.

Le but de la documentation est de permettre à l'utilisateur d'une fonction de savoir comment l'appeler correctement et comment interpréter sa valeur de retour. Elle donne des conditions à respecter sur les valeurs des paramètres et explique quelles seront les différentes valeurs de retour possibles. Voici ce que ça pourrait donner pour la fonction percentage vue au début de ce chapitre :

1. # Renvoie le pourcentage d'une note étant donné :

2. # - "score" contient la note obtenue (flottant)

3. # - "total" est la note maximale atteignable (flottant)

4. #

5. # Si total <= 0, score < 0 ou score > total, alors renvoie None

6. def percentage(score, total):

7. if total > 0 and (0 <= score <= total):

8. return score / total * 100

9. return None

Ce qu'on vient d'écrire est une documentation informelle. Il n'y a pas vraiment de règles à suivre et on peut se contenter d'un texte en langue naturelle. L'important est de n'oublier aucune information, afin que l'on puisse appeler et utiliser la fonction, et interpréter sa valeur de retour, sans ambigüité.

L'autre façon de décrire proprement une fonction consiste à établir ses spécifications comme présenté au chapitre 4. Pour rappel, les deux éléments suivants sont à définir :

- les préconditions sont les conditions qui doivent être satisfaites sur les paramètres et l'état global du programme, avant l'appel de la fonction ;

- les postconditions sont les conditions qui seront satisfaites sur la valeur de retour et sur l'état global du programme, après l'appel de la fonction, si les préconditions ont été respectées.

Une spécification est donc un contrat entre celui qui implémente une fonction et celui qui l'utilise. L'utilisateur s'engage à respecter les préconditions avant d'appeler la fonction et le programmeur lui garantit que les postconditions seront satisfaites en retour. Voici une nouvelle version de la fonction percentage, avec sa spécification :

1. # Calcule le pourcentage correspondant à une note.

2. #

3. # Pre:  0 <= score <= total, la note obtenue

4. #       total > 0, la note maximale atteignable

5. # Post: La valeur renvoyée contient le pourcentage

6. #       correspondant à la note obtenue.

7. def percentage(score, total):

8. return score / total * 100

Étant donné qu'il y a un contrat, le programmeur ne doit plus se soucier du cas où les paramètres sont négatifs, ou de celui où total est nul. Il ne doit garantir le bon comportement de la fonction que pour les cas où les préconditions sont respectées, ce qui simplifie la fonction.

IV-A-1-b-i - Génération de la documentation

Comme on l'a déjà vu au chapitre 4, il est de bon usage, en Python, d'insérer la documentation d'une fonction sous forme d'un Docstring. Il s'agit simplement de placer une chaine de caractères, souvent délimitée par des guillemets triples pour pouvoir l'écrire sur plusieurs lignes, comme première instruction du corps de la fonction. L'exemple précédent se réécrit donc comme suit :

1. def percentage(score, total):

2. """# Calcule le pourcentage correspondant à une note.

3. #

4. # Pre:  0 <= score <= total, la note obtenue

5. #       total > 0, la note maximale atteignable

6. # Post: La valeur renvoyée contient le pourcentage

7. #       correspondant à la note obtenue.

8. """

9. return score / total * 100

Si on génère la documentation de cette fonction, à l'aide de l'outil pydoc, on obtient le résultat présenté à la figure 1, qui reprend la documentation de toutes les fonctions présentes dans un module. L'outil pydoc permet également d'exporter cette documentation en HTML.

Figure 1. La documentation insérée sous la forme de Docstring peut être visualisée à l'aide de l'outil pydoc.

IV-B - Assertion

Lorsqu'on définit une nouvelle fonction, et qu'on la spécifie, il faut minimiser le nombre de préconditions si on veut la rendre robuste, c'est-à-dire résistante à des mauvaises utilisations. Par contre, si on écrit une fonction à usage interne, c'est moins critique, surtout si le nombre de personnes qui vont l'utiliser n'est pas trop élevé et qu'elles sont de confiance. Dans ce dernier cas, le code de la fonction sera plus simple.

IV-B-1 - Instruction assert

On peut vouloir vérifier que des conditions qui sont censées être satisfaites le sont effectivement, à l'aide du mécanisme d'assertion proposé par Python. Voyons comment l'utiliser pour vérifier les préconditions de la fonction percentage :

1. def percentage(score, total):

2. assert total > 0, 'total doit être strictement positif'

3. assert 0 <= score, 'score doit être positif'

4. assert score <= total, 'score doit être inférieur à total'

5. return score / total * 100

Trois instructions assert ont été utilisées pour vérifier les préconditions. Une telle instruction se compose d'une condition (une expression booléenne) éventuellement suivie d'une virgule et d'une phrase en langue naturelle, sous forme d'une chaine de caractères. L'instruction assert teste si sa condition est satisfaite. Si c'est le cas, elle ne fait rien et sinon elle arrête immédiatement l'exécution du programme en affichant éventuellement la phrase qui lui est associée.

Dans le programme d'exemple suivant, le premier appel s'exécutera sans erreur tandis que le second provoquera une erreur d'exécution due à une assertion non satisfaite :

print(percentage(15, 20), '%')
print(percentage(22, 20), '%')

1. 75.0 %

2. Traceback (most recent call last):

3. File "program.py", line 8, in <module>

4. print(percentage(22, 20), '%')

5. File "program.py", line 4, in percentage

6. assert score <= total, 'score doit être inférieur à total'

7. AssertionError: score doit être inférieur à total

Le mécanisme d'assertion est là pour empêcher des erreurs qui ne devraient pas se produire, en arrêtant prématurément le programme, avant d'exécuter le code qui aurait produit une erreur. Si une telle erreur survient, c'est que le programme doit être modifié pour qu'elle n'arrive plus. Dans cet exemple, on se rend immédiatement compte qu'un appel ne respectant pas les préconditions a été fait, et qu'il faut donc le changer. C'était peut-être 2/20 au lieu de 22/20…

Enfin, il faut savoir que le mécanisme d'assertion est une aide au développeur, et ne doit en aucun cas faire partie du code fonctionnel d'un programme. En supprimant toutes les instructions assert, le programme doit continuer à fonctionner normalement.

IV-B-1-a - Programmation défensive

Ce mode de programmation, qui exploite les assertions pour vérifier les préconditions, est appelé programmation défensive. Dans ce type de programmation, on suppose que les fonctions sont appelées comme il faut, dans le respect de leurs préconditions. On prévoit néanmoins un garde-fou avec des instructions assert pour vérifier que les préconditions sont effectivement bien satisfaites.

En pratique, on va utiliser ce type de programmation pour du code sur lequel on a le contrôle. Par exemple, lorsqu'on écrit un module, on peut programmer défensivement pour les fonctions qui ne sont destinées qu'à être appelées au sein de ce dernier. Lorsqu'on écrit des fonctions destinées à être utilisées par d'autres personnes, on va plutôt faire une gestion active des erreurs, notamment avec l'instruction if-else et en prévoyant des valeurs de retour spéciales.

Dans les deux cas, on doit spécifier les fonctions que l'on définit. En programmation défensive, on peut se permettre d'avoir des préconditions et faire l'hypothèse qu'elles seront toujours respectées. Par contre, lorsque les fonctions sont destinées à être utilisées par d'autres, on va limiter au maximum le nombre de préconditions.

Reprenons l'exemple du test de la sous-chaine présenté au chapitre 6. On va le réécrire sans utiliser le slicing ni l'égalité de listes, mais en faisant tout avec des opérations de base. Voici le code de la fonction issubsequence, avec sa documentation :

1. def issubsequence(subseq, seq):

2. """Teste si une séquence est une sous-séquence d'une autre.

4. Pre : -

5. Post: La valeur renvoyée contient True si 'subseq' est une

6. sous-séquence de 'seq' et False sinon.

7. """

8. # Vérification des paramètres

9. if type(subseq) != str or type(seq) != str:

10. return False

11. if len(subseq) > len(seq):

12. return False

13. # Teste la sous-séquence à toutes les positions possibles

14. for i in range(0, len(seq) - len(subseq) + 1):

15. if _issubsequenceat(subseq, seq, i):

16. return True

17. return False

On peut faire plusieurs observations sur la définition de cette fonction qui est, pour rappel, destinée à être appelée par d'autres programmeurs :

- on a limité drastiquement le nombre de préconditions, les ramenant même au nombre de zéro ;

- on a pratiqué la gestion active des erreurs, en vérifiant que les paramètres reçus par la fonction sont corrects, et en renvoyant False le cas échéant ;

- on a fait appel à une fonction _issubsequenceat au sein de la boucle, qui permet de tester si subseq est une sous-chaine de seq en commençant à son indice i.

Voyons maintenant le code de cette dernière fonction appelée par issubsequence. Comme elle n'est pas destinée à être utilisée en dehors du module, mais uniquement par les autres fonctions du même module, on va pouvoir pratiquer la programmation défensive :

1. def _issubsequenceat(subseq, seq, pos):

2. """Teste si une séquence est une sous-séquence d'une autre

3. en commençant à un indice donné.

5. Pre : len(subseq) <= len(seq)

6. 0 <= pos <= len(seq) - len(subseq)

7. Post: La valeur renvoyée contient True si 'subseq' est une

8. sous-séquence de 'seq' commençant à l'indice 'pos'

9. et False sinon.

10. """

11. # Vérification des préconditions

12. assert type(subseq) == str and type(seq) == str

13. assert type(pos) == int

14. assert len(subseq) <= len(seq)

15. assert 0 <= pos <= len(seq) - len(subseq)

16. # Teste la sous-séquence à la position 'pos'

17. for i in range(len(subseq)):

18. if seq[pos + i] != subseq[i]:

19. return False

20. return True

On peut faire plusieurs observations sur la définition de cette fonction qui est, pour rappel, destinée à n'être appelée que depuis les autres fonctions du même module, en l'occurrence par issubsequence :

- on a ajouté _ au début du nom de la fonction, suivant la convention Python, signalant ainsi qu'elle est privée au module ;

- on n'a pas hésité à définir plusieurs préconditions qui décrivent les paramètres valides ;

- on a pratiqué la programmation défensive, en vérifiant les préconditions à l'aide d'assertions.

IV-C - Mécanisme d'exception

On a vu que l'on peut donc gérer les erreurs à l'aide de l'instruction if-else et en prévoyant des valeurs de retour spéciales. Cette technique n'est malheureusement pas toujours utilisable, notamment lorsque la fonction définie ne renvoie rien.

Voyons maintenant le mécanisme d'exception, présent dans les langages de programmation orientés objet, qui permet de gérer des exécutions exceptionnelles qui ne se produisent qu'en cas d'erreur.

IV-C-1 - Instruction try-except

La construction de base à utiliser est l'instruction try-except qui se compose de deux blocs de code. On place le code « risqué » dans le bloc try et le code à exécuter en cas d'erreur dans le bloc except. Partons d'un exemple pour comprendre son utilisation :

1. birthyear = input('Année de naissance ? ')

3. try:

4. print('Tu as', 2016 - int(birthyear), 'ans.')

5. except:

6. print('Erreur, veuillez entrer un nombre.')

8. print('Fin du programme.')

On demande donc à l'utilisateur son année de naissance, grâce à la fonction prédéfinie input qui renvoie, pour rappel, une chaine de caractères. On souhaite ensuite calculer son âge en soustrayant son année de naissance à . Pour cela, il faut convertir la valeur de la variable birthyear en un int. Cette conversion peut échouer si la chaine de caractères entrée par l'utilisateur n'est pas un nombre. Voyons deux scénarios possibles d'exécution.

- Si l'utilisateur entre un nombre entier, l'exécution se passe sans erreur et son âge est calculé et affiché :

Année de naissance ? 1994
Tu as 22 ans.
Fin du programme.

- Si l'utilisateur entre une chaine de caractères quelconque, qui ne représente pas un nombre entier, un message d'erreur est affiché :

Année de naissance ? deux
Erreur, veuillez entrer un nombre.
Fin du programme.

Dans le premier cas, la conversion s'est passée sans souci, et le bloc try a donc pu s'exécuter intégralement sans erreur. L'exécution du programme se poursuit donc après l'instruction try-except. Dans le second cas, une erreur se produit dans le bloc try, lors de la conversion. L'exécution de ce bloc s'arrête donc immédiatement et passe au bloc except, avant de continuer également après l'instruction try-except.

On place donc uniquement le code qui est susceptible de générer une erreur dans le bloc try, avec le code qui en dépend. Il est important de ne pas placer trop de code dans ce dernier. Améliorons l'exemple précédent pour demander à l'utilisateur son année de naissance, en boucle jusqu'à ce qu'il fournisse une valeur correcte :

1. valid = False

2. while not valid:

3. birthyear = input('Année de naissance ? ')

4. try:

5. birthyear = int(birthyear)

6. if 0 <= birthyear <= 2016:

7. valid = True

8. else:

9. print("L'année doit être comprise entre 0 et 2016.")

10. except:

11. print('Veuillez entrer un nombre naturel.')

13. print('Tu as', 2016 - birthyear, 'ans.')

On initialise une variable valid à la valeur False. On rentre ensuite dans une boucle while qui va se répéter tant que la variable valid ne passe pas à True. Le corps de la boucle commence par demander à l'utilisateur son année de naissance, à l'aide de la fonction prédéfinie input. On entre ensuite dans une zone critique, placée donc dans un bloc try. On commence par tenter de convertir la variable birthyear en un entier de type int. Si la conversion réussit, on s'assure que la valeur est bien comprise entre et et, dans ce cas, on passe la valeur de valid à True, sinon on affiche un message d'erreur. Dans le bloc except, on affiche également un message d'erreur. On finit par calculer l'âge et l'afficher, en dehors de la boucle while.

Voici le résultat d'une exécution du programme :

1. Année de naissance ? BLA

2. Veuillez entrer un nombre naturel.

4. Année de naissance ? -12

5. L'année doit être comprise entre 0 et 2016.

7. Année de naissance ? 1973

8. Tu as 43 ans.

La boucle s'est donc exécutée trois fois avant que l'utilisateur n'entre une valeur valide et obtienne ainsi son âge.

IV-C-1-a - Le type Exception

Comme on l'a vu en début de chapitre, différents types d'erreurs peuvent survenir. Lorsqu'on utilise l'instruction try-except, le bloc except capture toutes les erreurs possibles qui peuvent survenir dans le bloc try correspondant. Une exception est en fait représentée par un objet, instance de la classe Exception. On peut récupérer cet objet en précisant un nom de variable après except comme dans cet exemple :

1. try:

2. a = int(input('a ? '))

3. b = int(input('b ? '))

4. print(a, '/', b, '=', a / b)

5. except Exception as e:

6. print(type(e))

7. print(e)

On récupère donc l'objet de type Exception dans la variable e. Dans le bloc except, on affiche son type et sa valeur. Voici deux exemples d'exécution qui révèlent deux types d'erreurs différents :

- si on ne fournit pas un nombre entier, il ne pourra être converti en int et une erreur de type ValueError se produit :

a ? trois
<class 'ValueError'>
invalid literal for int() with base 10: 'trois'

- si on fournit une valeur de pour b, on aura une division par zéro qui produit une erreur de type ZeroDivisionError :

a ? 5
b ? 0
<class 'ZeroDivisionError'>
division by zero

IV-C-1-b - Capturer une erreur spécifique

Chaque type d'erreur est donc défini par une classe spécifique. On va pouvoir associer plusieurs blocs except à un même bloc try, pour exécuter un code différent en fonction de l'erreur capturée. Lorsqu'une erreur se produit, les blocs except sont parcourus l'un après l'autre, du premier au dernier, jusqu'à en trouver un qui corresponde à l'erreur capturée. Réécrivons l'exemple précédent en capturant les exceptions spécifiques pour les deux cas d'erreur qu'on a pu observer (erreur de conversion et division par zéro) :

1. try:

2. a = int(input('a ? '))

3. b = int(input('b ? '))

4. print(a, '/', b, '=', a / b)

5. except ValueError:

6. print('Erreur de conversion.')

7. except ZeroDivisionError:

8. print('Division par zéro.')

9. except:

10. print('Autre erreur.')

Remarquez tout d'abord qu'on n'est pas obligé de spécifier une variable lorsqu'on capture une exception spécifique. Cette fois-ci, lorsqu'une erreur se produit dans le bloc try, ce sera l'un des blocs except seulement qui sera exécuté, selon le type de l'erreur qui s'est produite. Le dernier bloc except est là pour prendre toutes les autres erreurs.

L'ordre des blocs except est très important et il faut les classer du plus spécifique au plus général, celui par défaut devant venir en dernier. En effet, si on commence par un bloc except pour une exception de type Exception, il sera toujours exécuté et tous les autres qui le suivent ne le seront jamais. Dans l'exemple suivant, ce sera donc toujours « Autre erreur. » qui sera affiché dès qu'une erreur se produit dans le bloc try :

1. try:

2. a = int(input('a ? '))

3. b = int(input('b ? '))

4. print(a, '/', b, '=', a / b)

5. except Exception:

6. print('Autre erreur.')

7. except ValueError:

8. print('Erreur de conversion.')

9. except ZeroDivisionError:

10. print('Division par zéro.')

On aura, par exemple, le résultat suivant si on spécifie une valeur nulle pour le dénominateur :

a ? 2
b ? 0
Autre erreur.

Sans s'attarder sur la raison précise de ce comportement, qui nécessiterait des notions avancées en programmation orientée objet, il faut savoir que le type Exception englobe les types ValueError et ZeroDivisionError.

IV-C-1-b-i - Gestionnaire d'erreur partagé

Enfin, il est possible d'exécuter le même code pour différents types d'erreurs, en les listant dans un tuple après le mot réservé except. Si on souhaite exécuter le même code pour une erreur de conversion et de division par zéro, il faudrait écrire :

1. try:

2. a = int(input('a ? '))

3. b = int(input('b ? '))

4. print(a, '/', b, '=', a / b)

5. except (ValueError, ZeroDivisionError) as e:

6. print('Erreur de calcul :', e)

7. except:

8. print('Autre erreur.')

Le deuxième bloc except capture donc les erreurs de type ValueError et ZeroDivisionError. L'exception capturée est stockée dans la variable e que l'on affiche pour avoir des informations sur la cause de l'erreur apparue. On pourrait, par exemple, observer le résultat suivant lors d'une exécution du programme :

a ? 2
b ? 0
Erreur de calcul : division by zero

IV-C-1-c - Propagation d'erreur

Que se passe-t-il lorsqu'on ne capture pas une exception à l'aide d'une instruction try-except ? Cette dernière va en fait remonter la séquence des appels de fonctions. Prenons, par exemple, le programme suivant qui comporte deux fonctions, appelées en chaine :

1. def fun():

2. print(1 / 0)

4. def compute():

5. fun()

7. compute()

Le programme appelle donc la fonction compute qui, elle-même, appelle la fonction fun, cette dernière produisant une erreur à cause de la division par zéro. Voici le résultat que l'on obtient en exécutant ce programme :

1. Traceback (most recent call last):

2. File "program.py", line 7, in <module>

3. compute()

4. File "program.py", line 5, in compute

5. fun()

6. File "program.py", line 2, in fun

7. print(1 / 0)

8. ZeroDivisionError: division by zero

Dans un sens, la trace d'erreur permet de suivre le déroulement de l'exécution, partant du programme principal vers la fonction fun, en passant par la fonction compute. Dans l'autre sens, on peut suivre la propagation de l'erreur depuis la fonction fun vers le programme principal, en passant par la fonction compute.

Une erreur qui n'est pas capturée va donc se propager, en remontant la séquence des appels de fonctions qui ont été faits. On peut modifier le programme en capturant, par exemple, l'erreur dans la fonction compute :

1. def fun():

2. print(1 / 0)

4. def compute():

5. try:

6. fun()

7. except:

8. print('Erreur.')

10. compute()

Dans ce cas, l'erreur qui est générée dans la fonction fun va juste se propager dans la fonction compute où elle sera arrêtée par l'instruction try-except. Le résultat de l'exécution est donc :

Erreur.

Il est important de capturer l'erreur à l'endroit où son traitement est le plus approprié, mais on ne va pas entrer dans les détails dans le cadre de ce livre consacré aux bases.

IV-C-1-d - Le bloc finally

Parfois, on souhaite exécuter des instructions dans tous les cas, avant que l'exécution de continue après le bloc try-except. Le problème est que lorsqu'une erreur se produit dans le bloc try, l'exécution de ce dernier est interrompue pour se poursuivre dans le bloc except correspondant.

Pour cela, on peut utiliser le mot réservé finally qui permet d'introduire un bloc qui sera exécuté soit après que le bloc trys'est exécuté complètement sans erreur, soit après avoir exécuté le bloc except correspondant à l'erreur qui s'est produite lors de l'exécution du bloc try. On obtient ainsi une instruction try-except-finally dont voici un exemple d'utilisation :

1. print('Début du calcul.')

2. try:

3. a = int(input('a ? '))

4. b = int(input('b ? '))

5. print('Résultat :', a / b)

6. except:

7. print('Erreur.')

8. finally:

9. print('Nettoyage de la mémoire.')

10. print('Fin du calcul.')

Si l'utilisateur fournit des valeurs correctes pour a et b, l'exécution du bloc try se passera sans erreur, c'est-à-dire que le résultat de la division de a par b sera affiché, puis que la phrase signalant que la mémoire est nettoyée sera affichée :

1. Début du calcul.

2. a ? 2

3. b ? 8

4. Résultat : 0.25

5. Nettoyage de la mémoire.

6. Fin du calcul.

Le bloc finally a donc bien été exécuté, après l'exécution sans erreur du bloc try et avant que l'exécution du programme ne continue après l'instruction try-except.

Par contre, si une erreur se produit, alors le résultat ne sera pas affiché puisque le bloc try sera arrêté ; on aura simplement l'affichage d'un message d'erreur. Par contre, la phrase signalant que la mémoire est nettoyée sera de nouveau affichée témoignant bien du fait que le bloc finally a été exécuté, cette fois-ci après le bloc except, mais toujours avant la reprise de l'exécution après l'instruction try-except :

1. Début du calcul.

2. a ? 2

3. b ? 0

4. Erreur.

5. Nettoyage de la mémoire.

6. Fin du calcul.

Enfin, sachez que l'on peut se limiter à une instruction try-finally, sans définir un seul bloc except. Dans ce cas, si une erreur se produit dans le bloc try, elle sera propagée après exécution du bloc finally.

IV-D - Définir une exception

Terminons ce chapitre en voyant comment définir ses propres exceptions. Comme on a déjà pu le voir, une exception est un objet et du coup, il faut qu'il existe quelque part une classe pour qu'on puisse en créer des instances. Définir un nouveau type d'exception revient donc à définir une nouvelle classe.

IV-D-1 - Génération d'erreur

Avant de définir nos propres exceptions, voyons comment générer une erreur dans un programme grâce à l'instruction raise.

Il suffit en fait d'utiliser le mot réservé raise suivi d'une référence vers un objet représentant une exception. L'exemple suivant reprend la fonction récursive permettant de calculer la factorielle d'un nombre naturel positif :

1. def fact(n):

2. if n < 0:

3. raise ArithmeticError()

4. if n == 0:

5. return 1

6. return n * fact(n - 1)

8. print(fact(-12))

Par rapport à la version de la fonction présentée au chapitre 6, on a ajouté une instruction if testant si n est strictement négatif, dans lequel cas on génère une exception de type ArithmeticError, qui représente une erreur arithmétique.

Si on exécute ce programme qui tente de calculer la factorielle de , il va s'arrêter brutalement avec l'erreur suivante :

1. Traceback (most recent call last):

2. File "program.py", line 8, in <module>

3. print(fact(-12))

4. File "program.py", line 3, in fact

5. raise ArithmeticError()

6. ArithmeticError

L'erreur observée est de type ArithmeticError, et c'est précisément celle qu'on a générée à la deuxième ligne du corps de la fonction fact avec l'instruction raise. On va, évidemment, pouvoir se protéger de cette erreur à l'aide d'un try-except lorsqu'on appelle la fonction. Le programme suivant capture spécifiquement l'exception de type ArithmeticError :

1. try:

2. n = int(input('Entrez un nombre : '))

3. print(fact(n))

4. except ArithmeticError:

5. print('Veuillez entrer un nombre positif.')

6. except:

7. print('Veuillez entrer un nombre.')

Cette fois-ci, on n'aura plus de trace d'erreur puisque l'erreur est capturée et gérée. On obtient, par exemple, le résultat d'exécution suivant :

Entrez un nombre : -12
Veuillez entrer un nombre positif.

Si on avait entré du texte ne correspondant pas à un nombre entier, le gestionnaire d'erreur par défaut (le dernier except) aurait été exécuté.

Enfin, il faut savoir que, dans une fonction, l'instruction raise se comporte comme l'instruction return, à savoir qu'elle a pour conséquence que l'exécution du corps de la fonction est immédiatement quittée. L'erreur se propage ensuite en remontant la séquence des appels de fonctions, jusqu'à être attrapée. Si elle n'est jamais attrapée, le programme se termine en erreur.

IV-D-1-a - Créer un type d'exception

Dans l'exemple précédent, on a généré une exception de type ArithmeticError, dont la classe existe déjà en Python. On pourrait se limiter aux types d'exceptions déjà existants, voire utiliser le type générique Exception, mais il est parfois plus pratique et plus lisible de définir ses propres types. Pour cela, il suffit de définir une nouvelle classe, comme le montre l'exemple suivant :

class NoRootException(Exception):
pass

Cette classe est tout simplement vide puisque son corps n'est constitué que de l'instruction pass qui, pour rappel, ne fait juste rien.

On remarque néanmoins une petite différence par rapport à ce qu'on a vu sur la définition de classe, c'est le Exception qui a été ajouté entre parenthèses après le nom de la classe. Sans rentrer dans le détail, cela permet de signifier à Python que cette classe est de « type exception », ce qui permettra de l'utiliser avec try-except et raise.

Une fois cette classe définie, on va pouvoir l'utiliser. Définissons une fonction trinomialroots qui calcule et renvoie les racines d'un trinôme du second degré de la forme , mais qui, contrairement à la version vue au chapitre 3, génère une erreur lorsqu'il n'y a pas de racine réelle :

1. from math import sqrt

3. def trinomialroots(a, b, c):

4. delta = b ** 2 - 4 * a * c

5. # Aucune racine réelle

6. if delta < 0:

7. raise NoRootException()

8. # Une racine réelle double

9. if delta == 0:

10. return -b / (2 * a)

11. # Deux racines réelles simples

12. x1 = (-b + sqrt(delta)) / (2 * a)

13. x2 = (-b - sqrt(delta)) / (2 * a)

14. return (x1, x2)

La quatrième ligne du corps de la fonction génère une erreur de type NoRootException à l'aide de l'instruction raise, dans le cas où le trinôme n'admet pas de racine réelle. Pour que ce programme fonctionne, il faut évidemment que la classe NoRootException soit définie dans le même fichier que la fonction trinomialroots.

Lorsqu'on appelle cette fonction, on va donc pouvoir utiliser l'instruction try-except pour attraper cette erreur, lorsqu'elle survient. Essayons, par exemple, de calculer et d'afficher les racines réelles du trinôme . Pour cela, on appelle donc la fonction trinomialroots en lui passant en paramètres , et puisque correspond à , et  :

1. try:

2. print(trinomialroots(1, 0, 2))

3. except NoRootException:

4. print('Pas de racine réelle.')

Puisque ce trinôme n'admet pas de racine réelle, la parabole correspondante se trouvant au-dessus de l'axe des , une erreur sera générée et capturée par le bloc except, comme on le constate sur le résultat de l'exécution du programme :

Pas de racine réelle.

Tout ceci est donc possible, car on a défini une nouvelle classe de « type exception », permettant de générer une erreur de ce type avec raise et de l'attraper avec except.

IV-D-1-b - Exception paramétrée

Une exception se définit par une classe, et on peut donc y ajouter tout ce qu'on a vu, à savoir des variables d'instance, des accesseurs, des mutateurs et des méthodes. Cette possibilité permet de paramétrer l'exception, c'est-à-dire de lui ajouter de l'information. En récupérant l'objet correspondant à l'exception dans l'instruction except, on pourra l'utiliser et donc, par exemple, appeler ses méthodes.

Si on revient sur l'exemple des racines du trinôme du second degré, il pourrait être utile de connaitre la valeur du discriminant lorsqu'aucune racine réelle n'existe. On va pour cela ajouter une variable d'instance et un accesseur à la classe NoRootException :

1. class NoRootException(Exception):

2. def __init__(self, delta):

3. self.__delta = delta

5. @property

6. def delta(self):

7. return self.__delta

On modifie ensuite l'instruction qui génère l'erreur dans la fonction trinomialroots, puisqu'il faut maintenant passer un paramètre lors de la création d'un nouvel objet de type NoRootException :

raise NoRootException(delta)

On peut maintenant récupérer la valeur du discriminant dans le bloc except, à partir de l'objet représentant l'exception qui s'est produite. Il suffit pour cela de faire appel à l'accesseur delta :

1. try:

2. print(trinomialroots(1, 0, 2))

3. except NoRootException as e:

4. print('Pas de racine réelle.')

5. print('Delta =', e.delta)

L'exécution de ce programme affiche donc la valeur dudit discriminant, et on voit bien qu'il est négatif :

Pas de racine réelle.
Delta = -8

V - Manipulation de fichiers

Une fois que l'exécution d'un programme est terminée, toutes les données qu'il a créées et stockées dans des variables sont supprimées de la mémoire. Pour pouvoir stocker des informations de manière permanente, il faut les sauvegarder dans des fichiers. Ce chapitre commence par présenter quelques notions liées au concept de fichier, puis enchaine en voyant comment créer et modifier des fichiers textes et binaires.

V-A - Fichier

Un fichier est une abstraction d'une partie de la mémoire qui représente une information. Celle-ci peut prendre différentes formes : un document texte, une image, un son, une vidéo, un exécutable… Au plus bas niveau, toute information est stockée de la même manière, à savoir comme une séquence de bits ( ou ) qu'il faut interpréter pour obtenir l'information de haut niveau représentée.

Plusieurs opérations peuvent être réalisées avec des fichiers. On peut tout d'abord en créer et en supprimer et lire et modifier leur contenu. Un fichier est également caractérisé par plusieurs informations que l'on peut obtenir, telles que son nom, sa taille, sa date de création et de dernière modification, ses permissions d'accès, etc. Toutes ces opérations peuvent être réalisées en Python, à l'aide de fonctions faisant essentiellement partie des modules os, os.path et shutil qui font le relais entre l'interpréteur Python et le système d'exploitation (Linux, Windows, MacOS…), qui est le seul à avoir directement accès aux disques. On ne va pas détailler toutes ces opérations ici, mais se concentrer sur la lecture et l'écriture de fichiers.

On distingue couramment deux types de fichiers, qui seront manipulés à des niveaux différents :

- un fichier texte est constitué d'une séquence de caractères, permettant de stocker une chaine de caractères sur disque. Un fichier .py contenant le code source d'un programme Python est un exemple d'un tel fichier ;

- un fichier binaire est constitué d'une séquence de bits, organisés en paquets de huit, appelés octets. Un fichier .png avec une image est un exemple d'un tel fichier.

La seule raison pour laquelle on différencie ces deux types de fichiers est que Python propose des fonctions spécifiques différentes permettant de facilement les manipuler. Dans l'absolu, il n'y a qu'un seul type de fichiers, les fichiers texte étant également des fichiers binaires.

V-A-1 - Créer et modifier un fichier

Pour manipuler un fichier, que ce soit en mode texte ou binaire, il y a trois principales étapes à suivre.

- Il faut d'abord ouvrir le fichier désiré, en lecture ou en écriture. S'il s'agit d'un nouveau fichier, il doit être avant tout créé.

- Une fois le fichier ouvert, on va pouvoir effectuer des opérations de lecture et d'écriture sur ce dernier.

- Enfin, une fois que l'on a terminé, il faut fermer le fichier pour libérer les ressources allouées par le système d'exploitation.

Deux erreurs peuvent survenir lorsqu'on manipule un fichier. Tout d'abord, il se peut que le fichier que l'on tente d'ouvrir n'existe pas, dans lequel cas une erreur de type FileNotFoundError est générée. Ensuite, durant la lecture ou l'écriture, différentes situations d'erreur peuvent survenir comme le disque qui devient plein, l'utilisateur qui n'a pas les droits suffisants pour lire/écrire un fichier, etc. Dans toutes ces situations, une erreur de type IOError survient, signalant en fait une erreur d'entrée/sortie. Si on veut un programme robuste, il faudra les traiter à l'aide d'un try-except.

L'exception IOError est en fait une erreur générique d'entrée/sortie et on peut se limiter à gérer cette dernière. Néanmoins, il est parfois utile de gérer ses cas particuliers, parmi lesquels on a :

- FileNotFoundError si le fichier n'existe pas ;

- FileExistsError si le fichier existe déjà ;

- PermissionError si le programme n'a pas les droits d'accès nécessaires sur le fichier ;

- et IsADirectoryError si le fichier est en fait un dossier.

V-A-1-a - Chemin

Lorsqu'on veut créer un nouveau fichier ou en ouvrir un existant pour le lire ou le modifier, il faut préciser l'endroit où il est stocké, en spécifiant son chemin. Deux types de chemins sont possibles : relatif ou absolu.

Un chemin relatif décrit l'endroit où se trouve le fichier par rapport à l'endroit d'où est exécuté le programme. On peut connaitre le répertoire courant à l'aide de la fonction getcwd du module os :

import os

print(os.getcwd())

Si on exécute ce programme sur une machine avec MacOS, on pourrait, par exemple, obtenir le résultat suivant :

/Users/combefis/Desktop

Un chemin relatif localise un fichier par rapport au répertoire courant. Si on indique juste un nom de fichier, cela revient à le chercher dans le répertoire courant. Si le chemin que l'on demande contient des répertoires, ils seront suivis à partir du répertoire courant. Enfin, on peut utiliser la notation spéciale ../ pour remonter d'un répertoire. Le tableau de la figure 1 reprend des chemins relatifs avec le chemin absolu correspondant, en supposant que l'on se trouve dans le répertoire courant montré ci-dessus.

Figure 1. Un chemin relatif se décrit par rapport au répertoire courant, la notation spéciale ../ permettant de remonter d'un répertoire.

Chemin relatif

Chemin absolu

data.txt

/Users/combefis/Desktop/data.txt

src/program.py

/Users/combefis/Desktop/src/program.py

../image.png

/Users/combefis/image.png

../movies/food.mp4

/Users/combefis/movies/food.mp4

Un chemin absolu donne donc la position d'un fichier à partir de la racine du disque. On peut transformer un chemin relatif en un absolu à l'aide de la fonction abspath du module os.path. De plus, on peut tester si un chemin correspond à un fichier qui se trouve réellement sur le disque avec la fonction exists du même module.

L'exemple suivant teste si un fichier référencé par un chemin relatif existe sur le disque, et affiche un message signalant l'existence ou non du fichier :

1. import os.path

3. path = 'data.txt'

5. print('Le fichier', os.path.abspath(path), end=' ')

6. if os.path.exists(path):

7. print('existe.')

8. else:

9. print('n\'existe pas.')

Si le fichier en question n'existe pas, on obtiendra le résultat suivant lors de l'exécution du programme :

Le fichier /Users/combefis/Desktop/data.txt n'existe pas.

Attention qu'il y a des différences sous Windows. Tout d'abord, la racine du disque est indiquée par un nom de lecteur, comme C:\, par exemple. De plus, le séparateur utilisé entre les répertoires est \ au lieu de /.

V-B - Fichier texte

Un fichier texte est composé d'une séquence de caractères. Parmi ceux-ci, on retrouve le caractère de saut de ligne et on peut donc également voir un fichier texte comme une séquence de lignes.

Comme dit précédemment, un fichier texte n'est qu'un fichier binaire. Les bits stockés dans le fichier sont en fait lus par blocs qui sont ensuite interprétés comme des caractères. Cette traduction se fait en suivant l'encodage qui a été utilisé pour sauvegarder le fichier. Python travaille par défaut avec l'encodage UTF-8, où chaque caractère par blocs de 8 bits. Ce principe est détaillé plus loin dans cette section.

V-B-1 - Ouverture et fermeture

L'ouverture d'un fichier se fait à l'aide de la fonction prédéfinie open. Il suffit de lui passer en paramètre le chemin du fichier à ouvrir. La fonction renvoie un identifiant vers le fichier ouvert, que l'on pourra ensuite utiliser pour lire et écrire dans le fichier. Deux exceptions peuvent survenir lors de l'ouverture d'un fichier, FileNotFoundError si le fichier n'a pas été trouvé et IOError pour les autres erreurs.

L'exemple suivant tente d'ouvrir le fichier data.txt et affiche l'identifiant renvoyé par la fonction open, avant de le refermer :

1. try:

2. file = open('data.txt')

3. print(file)

4. file.close()

5. except FileNotFoundError:

6. print('Fichier introuvable.')

7. except IOError:

8. print('Erreur d\'ouverture.')

L'exécution du programme affiche le résultat suivant, qui dévoile plusieurs informations par rapport au fichier ouvert :

<_io.TextIOWrapper name='data.txt' mode='r' encoding='UTF-8'>

La variable file contient donc une référence vers un objet de type io.TextIOWrapper. On peut voir trois attributs de cet objet :

- le nom du fichier ouvert est data.txt (name) ;

- le fichier est ouvert en mode lecture seule (mode) ;

- et enfin, l'encodage utilisé est UTF-8 (encoding).

Une fois que l'on a fini avec le fichier, il faut le fermer pour libérer les ressources qui ont été allouées en mémoire pour son traitement. Pour cela, on fait simplement appel à la méthode close.

V-B-1-a - Mode d'ouverture

Par défaut, un fichier texte est ouvert en lecture seule, c'est-à-dire que seules les opérations permettant de lire son contenu sont autorisées. D'autres modes d'ouverture sont possibles, et peuvent être spécifiés comme deuxième paramètre de la fonction open. Un mode est décrit par un ou plusieurs caractères ayant chacun une signification particulière, repris dans le tableau de la figure 2.

Figure 2. Le mode d'ouverture d'un fichier se décrit à l'aide d'un ou plusieurs caractères, définissant les opérations autorisées sur le fichier.

Caractère

Description

r

Lecture (par défaut)

w

Écriture (avec remise à zéro)

x

Création exclusive (erreur si fichier déjà existant)

a

Écriture (avec ajout à la fin)

b

Mode binaire

t

Mode texte (par défaut)

Par défaut, le mode d'ouverture d'un fichier est rt, à savoir que l'accès se fait en mode texte et en lecture seule. Pour écrire dans un fichier, on utilisera le mode w ou a. Dans les deux cas, le fichier ouvert est créé s'il n'existe pas. Si le fichier existe, son contenu est complètement effacé dans le premier cas, et l'écriture démarre à la fin du contenu dans le second cas. Enfin, le mode x permet de générer une erreur de type FileExistsError si le fichier existe déjà.

V-B-1-b - Lecture

Pour lire un fichier texte, préalablement ouvert, plusieurs méthodes sont utilisables. Le plus facile consiste à utiliser la méthode read qui va lire l'intégralité du fichier et renvoyer son contenu sous forme d'une chaine de caractères. Lors de la lecture, une exception de type IOError peut se produire, si le disque est corrompu ou s'il est déconnecté pendant la lecture, par exemple. Le programme suivant affiche l'intégralité du contenu du fichier data.txt :

1. try:

2. file = open('data.txt')

3. print(file.read())

4. file.close()

5. except FileNotFoundError:

6. print('Fichier introuvable.')

7. except IOError:

8. print('Erreur d\'entrée/sortie.')

Malgré que cette méthode soit très pratique, elle n'est pas forcément recommandée, surtout lorsque le fichier à lire est gros. En effet, la totalité du fichier va être lue depuis le disque et être placée dans une variable Python, ce qui va consommer de la mémoire.

V-B-1-b-i - Lecture ligne par ligne

Parfois, on souhaite pouvoir lire les lignes d'un fichier séparément. Une fois le fichier lu intégralement, on pourrait parcourir la chaine de caractères ainsi obtenue et identifier les lignes en repérant les caractères de retour à la ligne (\n sous Linux et MacOS et \r\n sous Windows).

Pour faciliter les choses, il existe une méthode readlines qui renvoie une liste de chaines de caractères, dont chaque élément correspond à une ligne. Les instructions suivantes ouvrent le fichier data.txt en lecture seule pour récupérer son contenu comme une liste de lignes qui sont ensuite affichées à l'aide d'une boucle for :

1. file = open('data.txt')

2. content = file.readlines()

3. file.close()

5. for line in content:

6. print(line)

Si on suppose que le fichier data.txt possède cinq lignes, chacune contenant juste un chiffre allant de à , le résultat de l'exécution du programme serait le suivant :

1. 1

3. 2

5. 3

7. 4

9. 5

Comme vous le constatez, il y a une série de lignes vides dans l'affichage produit. La méthode readlines ne supprime en fait pas le caractère de fin de ligne dans le résultat qu'elle renvoie. Si on ne veut pas de ce ou ces caractères de fin de ligne, il faut s'en débarrasser manuellement. Pour ce faire, on peut, par exemple, utiliser la méthode rstrip des chaines de caractères qui permet de supprimer tous les caractères blancs (espace, tabulation, retour à la ligne…) qui se trouvent à la droite d'une chaine de caractères. Il suffit donc de remplacer la boucle for par :

for line in content:
print(line.rstrip())

V-B-1-b-ii - Itérateur de lignes

Avec la méthode readlines, on a le même problème qu'avec read, à savoir que l'intégralité du fichier est lue en une fois, et doit donc être stockée en mémoire. Une autre solution consiste à utiliser un itérateur sur le fichier ouvert, que l'on va pouvoir parcourir à l'aide d'une boucle for. De nouveau, le ou les caractères de retour à la ligne seront inclus et doivent être supprimés avec rstrip si on n'en veut pas. Voici la nouvelle version de la lecture du fichier data.txt :

file = open('data.txt')
for line in file:
print(line.rstrip())

Le premier avantage est que le code est beaucoup plus compact et lisible. Le deuxième avantage est que le fichier est lu au fur et à mesure sur le disque, et pas intégralement comme avec les deux solutions précédentes.

V-B-1-b-iii - Écriture

Voyons maintenant comment écrire un fichier texte. L'ouverture et la fermeture du fichier fonctionnent exactement de la même manière que pour la lecture, si ce n'est que le mode doit inclure w ou a. De nouveau, une exception de type IOError peut se produire, si le disque devient plein pendant l'écriture, par exemple.

Pour écrire dans un fichier, on utilise la méthode write qui ajoute la chaine de caractères reçue en paramètre dans le fichier. L'exemple suivant écrit la table de multiplication de dans le fichier data.txt :

1. try:

2. file = open('data.txt', 'w')

3. file.write('Table de 7 :\n')

4. for i in range(10):

5. file.write('{} x 7 = {}\n'.format(i, i * 7))

6. file.close()

7. except IOError:

8. print('Erreur d\'entrée/sortie.')

On ouvre d'abord le fichier en mode écriture avec w. On écrit ensuite une première ligne avant de rentrer dans une boucle pour afficher les lignes de la table de multiplication. Remarquez que l'on doit manuellement ajouter le retour à la ligne dans la chaine de caractères envoyée à la fonction write. Voici le fichier data.txt produit par ce programme :

1. Table de 7 :

2. 0 x 7 = 0

3. 1 x 7 = 7

4. 2 x 7 = 14

5. 3 x 7 = 21

6. 4 x 7 = 28

7. 5 x 7 = 35

8. 6 x 7 = 42

9. 7 x 7 = 49

10. 8 x 7 = 56

11. 9 x 7 = 63

Quelques remarques sont à soulever par rapport à l'écriture dans un fichier texte.

- Lorsqu'on fait une écriture de fichier, sa fermeture à la fin des opérations avec la méthode close est très importante. En effet, sans cet appel, il se peut que les données ne soient pas écrites sur le disque, mais uniquement dans la mémoire tampon du système d'exploitation. Le fichier créé sera donc vide sur le disque.

- Avec le mode w, si le fichier existe, son contenu est complètement effacé et l'écriture commence à son début. Pour éviter cela, il faut soit utiliser le mode a, dans lequel l'écriture commence après le contenu actuel du fichier s'il existe déjà, soit utiliser le mode x qui provoque une erreur de type FileExistsError si le fichier existe déjà, ou enfin tester avant si le fichier existe ou non, avec la fonction exists du module os.path.

- Enfin, si on utilise le caractère \n pour la fin de ligne, elles ne seront pas forcément valables sur tous les systèmes d'exploitation. Le ou les caractères qui identifient une fin de ligne se trouvent dans la variable globale os.linesep du module os.

V-B-1-b-iv - Encodage

En informatique, tout caractère est en fait associé à un identifiant numérique qui est typiquement un nombre entier. Cette correspondance est établie dans une table de caractères. Par exemple, la table de caractères ASCII ou iso-646 (l'American Standard Code for Information Interchange (ASCII) a été développé pour représenter les caractères anglais) contient un total de caractères et est utilisée pour représenter les caractères anglais. Cette dernière est reprise à la figure 3, où l'identifiant numérique de chaque caractère est obtenu en additionnant le produit du numéro de sa ligne par avec le numéro de sa colonne (sachant que , …).

Figure 3. La table de caractères ASCII (iso-646) comporte 128 caractères utilisés pour représenter les caractères anglais.

Deux fonctions prédéfinies permettent d'effectuer la conversion entre un caractère et son identifiant numérique correspondant. La fonction ord donne l'identifiant numérique correspondant à un caractère tandis que la fonction chr fait l'opération inverse. Examinons l'exemple suivant :

print(ord('|'))
print(chr(65))

Le caractère | doit avoir comme code et le caractère dont le code est est le A. Ces calculs sont confirmés par le résultat de l'exécution des deux instructions :

124
A

À la table de caractères, il faut associer une méthode d'encodage, c'est-à-dire une manière de représenter chaque identifiant numérique en une suite de bits. Pour l'ASCII, ce n'est pas très compliqué, il suffit de bits pour chaque caractère afin de pouvoir tous les représenter et c'est ce qui est fait.

V-B-1-b-v - Unicode

Python fonctionne avec la table de caractères Unicode (ISO 10646), un standard d'échange de texte qui a vu le jour en 1987. Cette table comporte plus de caractères (la liste de tous les caractères Unicode peut être consultée sur le site web suivant : http://unicode-table.com/) dans sa dernière version.

De plus, Python utilise l'encodage UTF-8 pour représenter les caractères Unicode, dans lequel l'unité de base est un bloc de bits. Cet encodage est compatible avec l'ASCII, c'est-à-dire que ses caractères ont le même identifiant numérique.

Comme le montre la figure 4, Unicode contient beaucoup de caractères, permettant ainsi de couvrir toutes les langues du monde et encore plus, comme les emojis, par exemple.

Figure 4. La table de caractères Unicode comporte plus de 128000 caractères, couvrant toutes les langues du monde et encore plus.

V-B-1-b-vi - Séquence d'échappement

Si on souhaite insérer un caractère Unicode dans une chaine de caractères, sans savoir le taper directement au clavier, on peut utiliser une séquence d'échappement comme on a vu au chapitre 2.

Pour insérer un caractère Unicode, il suffit d'écrire \u ou \U suivi de l'identifiant numérique du caractère voulu. La différence entre les deux notations est que la première se limite aux identifiants représentables sur 16 bits alors que la seconde permet d'aller jusque 32 bits.

Par exemple, pour écrire le symbole de l'intégrale double et l'emoji étonné/effrayé, il suffit d'écrire les instructions suivantes :

print('\u222C')
print('\U0001F631')

V-B-1-b-vii - Choix de l'encodage

Lorsqu'on lit ou écrit un fichier texte dont l'encodage n'est pas UTF-8, il faut le préciser lors de l'ouverture à l'aide du paramètre nommé encoding. Évidemment, on sera du coup limité par rapport aux caractères que l'on pourra lire et écrire.

Par exemple, le programme suivant provoque une erreur d'exécution, car on tente d'écrire un caractère n'étant pas dans la table ASCII alors que le fichier a été créé suivant cet encodage :

file = open('data.txt', 'w', encoding='ascii')
file.write('€')
file.close()

1. Traceback (most recent call last):

2. File "program.py", line 3, in <module>

3. file.write('€')

4. UnicodeEncodeError: 'ascii' codec can't encode character '\u20ac' in position 0: ordinal not in range(128)

V-C - Gestionnaire de contexte

Pour lire ou écrire un fichier, il faut commencer par l'ouvrir et terminer en le fermant, toutes les opérations sur le fichier se passant entre ces deux opérations. C'est ce que l'on appelle un contexte dans lequel on rentre pour pouvoir effectuer une série d'opérations avant de le quitter. Python propose une construction permettant de rendre le code plus lisible dans de telles situations, et c'est le sujet de cette section.

V-C-1 - Fermeture du fichier

Comme on l'a déjà dit plusieurs fois, il est important de fermer un fichier ouvert une fois que les opérations sur ce dernier sont finies. Le problème qui peut survenir avec tous les exemples que l'on a vus précédemment est que, si une exception se produit pendant la lecture ou l'écriture du fichier, l'exécution saute dans le bloc except correspondant et la fermeture n'aura jamais lieu.

Pour résoudre ce problème, on pourrait se dire qu'il suffit de placer la fermeture du fichier dans un bloc finally pour être sûr qu'elle soit exécutée à tous les coups :

1. try:

2. file = open('data.txt')

3. print(file.read())

4. except FileNotFoundError:

5. print('Fichier introuvable.')

6. except IOError:

7. print('Erreur d\'entrée/sortie.')

8. finally:

9. file.close()

Cette solution n'est pas encore idéale, car une erreur sera produite par le close dans le cas où le fichier n'a pas su être ouvert. En effet, dans ce cas, la variable file n'existe pas et on se retrouve avec l'erreur suivante :

1. Fichier introuvable.

2. Traceback (most recent call last):

3. File "program.py", line 9, in <module>

4. print(file)

5. NameError: name 'file' is not defined

On va donc rajouter un nouveau bloc try qui va exclure l'ouverture du fichier, pour pouvoir lui attacher un bloc finally qui ferme le fichier. On sera ainsi certain que si le fichier a été ouvert, il sera d'office fermé. Le programme devient donc :

1. try:

2. file = open('data.txt')

3. try:

4. print(file.read())

5. finally:

6. file.close()

7. except FileNotFoundError:

8. print('Fichier introuvable.')

9. except IOError:

10. print('Erreur d\'entrée/sortie.')

Ce code devient difficilement lisible, mais propose une solution élégante pour s'assurer de la fermeture du fichier, et donc de la libération des ressources, dans tous les cas.

V-C-1-a - Instruction with

Python propose l'instruction with pour proprement gérer un contexte et automatiquement libérer les ressources au moment où le contexte est quitté. Voyons d'abord comment l'exemple précédent se réécrit :

1. try:

2. with open('data.txt') as file:

3. print(file.read())

4. except FileNotFoundError:

5. print('Fichier introuvable.')

6. except IOError:

7. print('Erreur d\'entrée/sortie.')

On utilise donc le mot réservé with suivi de l'appel de fonction qui crée l'objet qui sera utilisé dans le contexte, à savoir le fichier. On peut affecter le résultat renvoyé par la fonction à une variable en plaçant le nom désiré après le mot réservé as. Le bloc de code qui suit sera exécuté dans le contexte et la variable initialisée par le with y sera accessible. Une fois la fin de ce bloc atteint, la ressource du contexte sera libérée, c'est-à-dire que le fichier sera fermé.

On peut ouvrir plusieurs contextes avec la même instruction with. Il suffit pour cela de les séparer par des virgules, chacun ayant sa propre variable associée, évidemment. Voici, par exemple, comment on pourrait très facilement copier le contenu du fichier texte a.txt vers le fichier b.txt :

with open('a.txt') as src, open('b.txt', 'w') as dst:
dst.write(src.read())

Une fois le bloc with terminé, les deux fichiers ouverts sont automatiquement fermés. Pour être complet, il faudrait évidemment ajouter la gestion des erreurs d'entrée/sortie sur cet exemple. De plus, ce serait mieux de vérifier que le fichier destination n'existe pas déjà si on veut éviter que ce dernier ne soit écrasé, en utilisant le mode x, par exemple.

V-D - Fichier binaire

Maintenant que l'on sait manipuler des fichiers textes, intéressons-nous à la manipulation de bas niveau, à savoir aux fichiers binaires. L'avantage par rapport aux fichiers textes est qu'ils sont plus compacts en termes d'espace occupé et également plus rapide à lire et écrire. Par contre, la difficulté avec ces fichiers est qu'il faut, pour pouvoir les manipuler, connaitre précisément l'organisation des données en leur sein.

L'ouverture et la fermeture d'un fichier binaire se font de la même manière qu'avec un fichier texte, si ce n'est qu'il faut spécifier le bon mode. Il suffit d'ajouter b dans le descripteur de mode, pour signaler qu'il s'agit d'un fichier binaire. Pour la lecture, on utilise donc rb et l'écriture se définit avec wb. Pour ouvrir le fichier binaire data.bin en lecture, il suffit d'écrire :

with open('data.bin', 'rb') as file:
# opérations sur le fichier

V-D-1 - Lecture et écriture d'objets avec pickle

Le module pickle propose plusieurs fonctions qui vont permettre de directement lire et écrire des objets Python au format binaire depuis un fichier, sans que l'on ait besoin de savoir comment ils sont convertis en binaire. L'avantage de cette façon de procéder réside donc dans sa simplicité d'utilisation.

V-D-1-a - Écriture

Commençons par voir comment écrire des données dans un fichier binaire avec le module pickle. L'exemple suivant écrit une chaine de caractères suivie d'une liste de cinq nombres entiers dans le fichier data.bin, en mode binaire :

1. import pickle

3. name = 'Temperature (2016)'

4. data = [12, -9, 7, 112, 99]

6. try:

7. with open('data.bin', 'wb') as file:

8. pickle.dump(name, file, pickle.HIGHEST_PROTOCOL)

9. pickle.dump(data, file, pickle.HIGHEST_PROTOCOL)

10. except (IOError, pickle.PicklingError):

11. print('Erreur d\'écriture.')

Après avoir importé le module pickle, on ouvre le fichier data.bin en mode binaire et en écriture. On utilise ensuite la fonction dump du module pickle pour écrire un objet dans le fichier. Cette fonction nécessite trois paramètres : une référence vers l'objet à écrire, une référence vers le fichier où écrire et enfin une option. Cette dernière définit notamment la manière avec laquelle les objets sont traduits en binaire et est souvent simplement fixée à pickle.HIGHEST_PROTOCOL.

Comme on peut le voir sur la figure 5, le fichier généré par l'exécution de ce programme est bel et bien un fichier binaire, pas lisible directement avec un éditeur texte.

Figure 5. Un fichier binaire ne sait pas être lu directement avec un éditeur de texte.

L'écriture d'un objet dans un fichier binaire peut générer une erreur de type pickle.PicklingError si jamais l'objet ne peut pas être converti. De plus, l'exception de type IOError est toujours possible en cas d'erreur d'écriture dans le fichier.

V-D-1-a-i - Lecture

Pour lire un fichier binaire, on utilise simplement la fonction load du module pickle. Il faut lire les objets successivement dans le fichier, avec autant d'appels que nécessaires à la fonction load. Les instructions suivantes lisent le fichier qui a été écrit par l'exemple précédent :

1. import pickle

3. try:

4. with open('data.bin', 'rb') as file:

5. name = pickle.load(file)

6. data = pickle.load(file)

7. print(name, data, sep='\n')

8. except (IOError, pickle.UnpicklingError):

9. print('Erreur de lecture.')

La fonction load prend en seul paramètre une référence vers le fichier dans lequel elle doit lire un objet. Cette lecture peut provoquer une erreur de type pickle.UnpicklingError si jamais l'objet ne sait pas être lu depuis le fichier. De nouveau, une exception de type IOError peut se produire, en cas d'erreur de lecture cette fois-ci.

L'exécution du programme récupère bien les deux objets qui étaient stockés dans le fichier data.bin, comme en témoigne le résultat affiché :

Temperature (2016)
[12, -9, 7, 112, 99]

V-D-1-b - Lecture et écriture de données primitives avec struct

Le module struct permet également de lire et écrire dans un fichier binaire, mais à un niveau plus bas. Il offre en effet des fonctions permettant de lire et écrire des données primitives ; on effectue avec ce dernier des manipulations brutes de données binaires.

V-D-1-b-i - Encodage/décodage de chaines de caractères

Comme on l'a vu précédemment, un caractère n'est rien d'autre qu'un nombre entier, à savoir son identifiant numérique. On peut convertir une chaine de caractères en la suite d'octets correspondants, à l'aide de la méthode encode, qui prend en paramètre l'encodage désiré. On obtient en retour un objet bytes, qui représente une liste d'octets.

L'exemple suivant encode une chaine de caractères en UTF-8 et affiche la liste des différents octets correspondants :

1. s = 'Hello'

2. data = s.encode('utf-8')

4. print(type(data))

5. print(data)

6. for b in data:

7. print(b)

1. <class 'bytes'>

2. b'Hello'

3. 72

4. 101

5. 108

6. 108

7. 111

Comme on peut le constater sur le résultat de l'exécution, la variable data est bien de type bytes. De plus, si on affiche simplement la variable, Python affiche la chaine de caractères correspondante, préfixée d'un b pour se rappeler que c'est une séquence d'octets et pas de caractères. Enfin, la boucle for qui parcourt data affiche bien cinq nombres entiers, qui sont les identifiants numériques des lettres du mot « Hello ».

Dans l'autre sens, on peut convertir une séquence d'octets représentée par un objet bytes en une chaine de caractères avec la méthode decode. Pour reconvertir la variable data en une chaine de caractères, on écrit l'instruction suivante :

print(data.decode('utf-8'))

L'exécution de cette instruction affiche bien Hello, sans le préfixe b cette fois-ci puisqu'il s'agit d'une chaine de caractères de type str :

Hello

V-D-1-b-ii - Écriture

Reprenons le même exemple que précédemment, à savoir l'écriture d'une chaine de caractères et d'une liste d'entiers. Pour utiliser le module struct, il faut avant tout définir précisément le format de la donnée à écrire, octet par octet. La figure 6 montre comment on va stocker ces données dans le fichier :

1. On commence par écrire un nombre entier, qui représente le nombre de caractères que contient la chaine à écrire ;

2. Viennent ensuite les caractères de la chaine en question ;

3. Puis on écrit de nouveau un nombre entier, qui représente cette fois-ci le nombre d'éléments de la liste d'entiers ;

4. Et on termine avec les éléments de la liste.

Figure 6. Pour stocker des données au format binaire dans un fichier à l'aide du module struct, il faut définir précisément le format avec lequel ce sera réalisé.

Pour écrire une séquence d'octets dans un fichier binaire, on utilise la fonction pack du module struct. Cette dernière prend en paramètres une chaine de caractères qui décrit le type de la donnée à écrire et la donnée en question. Le tableau de la figure 7 reprend les principaux descripteurs de type de donnée à utiliser avec la fonction pack.

Figure 7. Pour écrire des données dans un fichier binaire avec la fonction pack du module struct, il faut décrire le type de la donnée avec une chaine de caractères.

Caractère

Description

h

Nombre entier signé (court)

H

Nombre entier non signé (court)

i

Nombre entier signé

I

Nombre entier non signé

f

Nombre flottant

c

Caractère

s

Chaine de caractères

?

Booléen

Pour écrire dans le fichier binaire, on passe par la méthode write, comme avec les fichiers texte, à qui on passe en paramètre le résultat de l'appel de la fonction pack. Voici comment l'exemple précédent, qui écrit une chaine de caractères suivie d'une liste de nombres entiers, se réécrit avec le module struct :

1. import struct

3. name = 'Temperature (2016)'

4. data = [12, -9, 7, 112, 99]

6. try:

7. with open('data.bin', 'wb') as file:

8. file.write(struct.pack('H', len(name)))

9. for c in name:

10. file.write(struct.pack('c', c.encode('utf-8')))

11. file.write(struct.pack('H', len(data)))

12. for elem in data:

13. file.write(struct.pack('h', elem))

14. except IOError:

15. print('Erreur d\'écriture.')

On écrit donc le nombre de caractères de la chaine comme un entier (H) puis, grâce à une boucle, chacun des caractères (c) encodé avec UTF-8. On suit exactement le même principe pour la liste d'entiers.

On aurait pu écrire les données d'une autre manière, en passant directement à pack tous les caractères ou tous les nombres entiers en une fois. Il suffit pour cela de faire précéder le descripteur de type de donnée par le nombre d'éléments à écrire.

On peut donc modifier l'exemple précédent en remplaçant les instructions qui font l'écriture avec les suivantes :

1. n = len(name)

2. file.write(struct.pack('H', n))

3. file.write(struct.pack('{}s'.format(n), name.encode('utf-8')))

4. n = len(data)

5. file.write(struct.pack('H', n))

6. file.write(struct.pack('{}h'.format(n), *data))

V-D-1-b-iii - Lecture

La lecture d'un fichier binaire créé avec struct se fait avec la fonction unpack qui prend deux paramètres, à savoir le format décrivant le type de donnée à lire et la séquence d'octets lue à l'aide de la fonction read sur le fichier. La fonction unpack renvoie un tuple avec les données qu'elle a lues, ce qui fait que lorsqu'on ne souhaite lire qu'un seul élément, il va falloir récupérer le premier élément du tuple comme on peut le voir sur l'exemple suivant qui lit le fichier créé précédemment :

1. import struct

3. try:

4. with open('data.bin', 'rb') as file:

5. n = struct.unpack('H', file.read(2))[0]

6. name = ''

7. for i in range(n):

8. raw = struct.unpack('c', file.read(1))

9. name += raw[0].decode('utf-8')

10. n = struct.unpack('H', file.read(2))[0]

11. data = []

12. for i in range(n):

13. raw = struct.unpack('h', file.read(2))

14. data.append(raw[0])

15. print(name, data, sep='\n')

16. except IOError:

17. print('Erreur de lecture.')

Lorsqu'on effectue la lecture dans le fichier, avec la méthode read, il faut préciser le nombre d'octets que l'on veut lire. Un nombre entier non signé court occupe deux octets et un caractère en occupe un.

Notez enfin que ce programme de lecture fonctionne évidemment avec les deux manières d'écrire que l'on vient de voir. En effet, les deux façons de procéder écrivent les données dans le même ordre dans le fichier binaire. Il est donc très important de bien définir le format dans lequel les données sont écrites, ce qui permet de lire et écrire des fichiers écrits avec le module struct.

Tout comme pour l'écriture, on peut simplifier la lecture en lisant directement plusieurs données du même type :

1. n = struct.unpack('H', file.read(2))[0]

2. raw = struct.unpack('{}s'.format(n), file.read(n))

3. name = raw[0].decode('utf-8')

4. n = struct.unpack('H', file.read(2))[0]

5. raw = struct.unpack('{}h'.format(n), file.read(2 * n))

6. data = list(raw)

Le module struct est plus complexe à utiliser que le module pickle, ce dernier écrivant directement et automatiquement des objets Python dans un fichier binaire, alors qu'il faut soi-même définir le format lorsqu'on utilise le module struct. Par contre, l'avantage du module struct est qu'il génère un fichier binaire bien plus compact. Pour l'exemple que l'on a utilisé, on se retrouve avec un fichier qui fait octets au lieu de pour le fichier créé avec le module pickle.

VI - Interface graphique

Tous les programmes que l'on a écrits jusqu'à présent fonctionnent en mode console. Dans ce chapitre, on va voir comment construire des interfaces graphiques permettant une tout autre interaction avec l'utilisateur. Pour cela, on va utiliser la bibliothèque Kivy qui permet de construire des interfaces modernes et adaptables. On va également voir la programmation évènementielle qui permet de programmer les interactions avec l'utilisateur. Enfin, le chapitre se termine par la réalisation de dessins et l'application de simples transformations.

VI-A - bibliothèque Kivy

Il existe de nombreuses bibliothèques pour réaliser des interfaces graphiques avec Python. Les principales différences qu'il y a entre ces bibliothèques sont le support de plusieurs systèmes d'exploitation (Linux, Windows, MacOS…) ainsi que les composants graphiques proposés (bouton, zone de texte, menu, liste déroulante…). Dans la bibliothèque de base fournie avec Python, on retrouve la bibliothèque graphique Tk, très basique et pas forcément esthétique. Dans les autres choix majeurs, portables sur les principaux systèmes d'exploitation, on retrouve PyQt et WxPython. Dans ce livre, on va utiliser la bibliothèque Kivy (la bibliothèque Kivy peut être téléchargée sur son site officiel : https://kivy.org/), open source, portable, moderne et qui tourne également sur Android et iOS.

VI-A-1 - Hello World!

Voyons immédiatement comment créer un premier programme avec une interface graphique à l'aide de la bibliothèque Kivy. Pour ne pas changer, on va simplement créer un Hello World. Le programme suivant affiche une fenêtre qui contient la phrase « Hello World! » :

1. from kivy.app import App

2. from kivy.uix.label import Label

4. class HelloApp(App):

5. def build(self):

6. return Label(text='Hello World!', font_size='100sp')

8. HelloApp().run()

L'exécution de ce programme crée une nouvelle fenêtre qui ne contient qu'une zone de texte non éditable avec la phrase « Hello World! », en taille pixels, comme on peut le voir sur la figure 1.

Figure 1. La bibliothèque Kivy permet de créer des programmes basés sur une interface graphique, comme celle-ci qui montre la phrase « Hello World! » dans une zone de texte non éditable.

Une interface graphique se définit à l'aide d'une nouvelle classe de type App, comme on l'a indiqué entre parenthèses après le nom de la classe. On a déjà vu cette construction précédemment, pour définir ses propres exceptions. Dans cette classe, il faut, au minimum, définir une méthode build qui va construire, et renvoyer, les composants à placer dans la fenêtre. Dans notre cas, on va se limiter à un composant de type Label, c'est-à-dire une zone de texte non éditable. Enfin, pour lancer le programme, il suffit de créer une instance de la classe qui a été définie, et d'appeler run.

Cette méthode run, spécifique aux objets de type App, initialise toute une série de choses permettant d'obtenir une interface graphique. Elle s'occupe notamment de mettre en place l'interaction avec le hardware de l'écran, de discuter avec les dispositifs d'entrée/sortie tels que l'écran multitouch, le clavier, l'accéléromètre, etc. et enfin, elle planifie plusieurs tâches à exécuter de manière régulière.

VI-A-1-a - Fenêtre

Le principal élément d'une application qui possède une interface graphique est la fenêtre. Par défaut, une application Kivy se charge de créer une fenêtre, dans laquelle les composants créés par la méthode build seront placés.

On peut facilement personnaliser quelques caractéristiques de cette fenêtre. Tout d'abord, on peut modifier son titre et l'icône associée à la fenêtre et à l'application à l'aide des variables d'instance title et icon, à modifier dans la méthode build. On peut également modifier la taille, en pixels, de la fenêtre en configurant deux options. L'exemple suivant modifie ces trois éléments :

1. from kivy.app import App

2. from kivy.config import Config

3. from kivy.uix.button import Label

5. class HelloApp(App):

6. def build(self):

7. self.title = 'Hello World!'

8. self.icon = 'hello.png'

9. return Label(text='Hello World!')

11. Config.set('graphics', 'width', '300')

12. Config.set('graphics', 'height', '150')

14. HelloApp().run()

La modification de la taille de la fenêtre passe donc par une fonction dans la classe Config du module kivy.config et elle doit être faite avant le lancement de l'application.

VI-A-1-b - Composant graphique

Une interface graphique est construite à partir de composants que l'on place sur une fenêtre. On retrouve plusieurs types de composants tels que des zones de texte éditables ou non, des boutons, des listes, des cases à cocher, etc. Les composants sont placés les uns dans les autres, permettant ainsi de les organiser visuellement sur la fenêtre. Par conséquent, chaque composant possède un composant parent. La figure 2 montre une fenêtre remplie de composants, ainsi qu'une vue explosée où on voit comment ils ont été imbriqués.

Figure 2. Les composants graphiques sont placés les uns dans les autres dans une fenêtre, créant ainsi une hiérarchie de composants.

Certains composants, appelés conteneurs, n'ont aucun rendu visuel. Ils permettent simplement d'en accueillir d'autres, afin d'organiser l'aspect visuel global de l'interface graphique. Dans l'exemple de la figure 3, on en a utilisé deux.

Pour comprendre comment sont organisés les composants d'une fenêtre, on peut représenter les liens entre ces derniers sous forme d'un arbre, comme le montre la figure 4. Un flèche arrive sur chaque composant, reliant son composant parent à lui. De plus, une ou plusieurs flèches partent de chaque composant, le reliant à ses composants enfants, c'est-à-dire ceux qui y sont imbriqués.

Figure 3. On peut représenter la hiérarchie qu'il y a entre les composants d'une fenêtre, reliant les composants parents à leurs composants enfants.

VI-A-1-b-i - Widget

Un widget est un composant qui offre une interaction spécifique avec l'utilisateur, en pouvant lui montrer une information et en offrant une ou des interactions possibles avec lui. Un bouton permet, par exemple, de montrer un texte et de recevoir un clic gauche. On ne va pas passer en revue tous les widgets proposés par Kivy, mais uniquement les principaux.

Commençons avec un exemple d'une simple interface graphique qui regroupe trois composants de base.

- Le label est une zone permettant d'accueillir un texte qui ne peut pas être modifié. On l'utilise, par exemple, pour renseigner le nom d'un champ à remplir dans un formulaire.

- La zone de texte est une zone dans laquelle l'utilisateur peut entrer un texte. On l'utilise, par exemple, comme champ d'un formulaire.

- Le bouton est une zone sur laquelle l'utilisateur peut cliquer, pour déclencher une action. On l'utilise, par exemple, pour permettre à l'utilisateur de valider un formulaire.

La figure 5 montre le résultat que l'on souhaite obtenir, à savoir aligner horizontalement un label, une zone de texte et un bouton. Pour ce faire, on va utiliser un conteneur de type BoxLayout qui aligne les composants qui lui sont ajoutés, de gauche à droite et horizontalement.

Figure 4. Pour aligner trois widgets horizontalement, on utilise un conteneur de type BoxLayout qui permet de gérer la mise en page.

Voici le programme qui permet de construire l'interface graphique que l'on souhaite pour la fenêtre de connexion :

1. from kivy.app import App

2. from kivy.config import Config

3. from kivy.uix.boxlayout import BoxLayout

4. from kivy.uix.button import Button

5. from kivy.uix.label import Label

6. from kivy.uix.textinput import TextInput

8. class LoginApp(App):

9. def build(self):

10. self.title = 'Se connecter'

11. box = BoxLayout(orientation='horizontal')

12. box.add_widget(Label(text='Pin code'))

13. box.add_widget(TextInput())

14. box.add_widget(Button(text='Entrer'))

15. return box

17. Config.set('graphics', 'width', '350')

18. Config.set('graphics', 'height', '50')

20. LoginApp().run()

Le premier composant que l'on crée, et qui sera renvoyé par la méthode build, est le BoxLayout. Ce dernier va contenir tous les autres composants qui lui seront ajoutés à l'aide de sa méthode add_widget. De plus, ils seront alignés horizontalement comme on l'a précisé dans le constructeur lors de la création du conteneur. Les composants sont placés dans le BoxLayout en suivant l'ordre des appels à add_widget.

Malgré que la fenêtre que l'on vient de construire soit très simple, le nombre de lignes de code et d'imports a déjà bien augmenté. On va voir, plus loin dans ce chapitre, comment facilement décrire les composants à mettre dans une fenêtre et leur agencement, à l'aide d'un fichier KV.

On peut proposer un choix à l'utilisateur de différentes manières, selon le type de choix que l'on souhaite lui proposer.

- La case à cocher propose un choix booléen à l'utilisateur, qui peut la cocher ou non. On l'utilise, par exemple, pour proposer à l'utilisateur d'activer ou non une option.

- Le bouton radio permet de sélectionner un choix parmi un groupe d'options. On l'utilise, par exemple, pour proposer plusieurs choix à l'utilisateur qui doit en choisir un seul.

- La liste déroulante permet de proposer toute une liste de choix à l'utilisateur qui pourra en choisir un seul. On l'utilise lorsqu'on a plus de choix qu'avec le bouton radio, pour permettre à l'utilisateur de sélectionner son pays, par exemple.

L'exemple suivant crée une interface graphique pour gérer des feux de signalisation et reprend ces trois composants :

1. from kivy.app import App

2. from kivy.uix.boxlayout import BoxLayout

3. from kivy.uix.checkbox import CheckBox

4. from kivy.uix.label import Label

5. from kivy.uix.spinner import Spinner

7. class SignallingApp(App):

8. def build(self):

9. self.title = 'Feu de signalisation'

10. # Sélection de la ville du feu

11. city = BoxLayout(orientation='horizontal')

12. city.add_widget(Label(text='Feu'))

13. cities = ('Bruxelles', 'Gent', 'Namur')

14. city.add_widget(Spinner(values=cities))

15. # Activation ou non du feu

16. activation = BoxLayout(orientation='horizontal')

17. activation.add_widget(CheckBox())

18. activation.add_widget(Label(text='Activer le feu'))

19. # Choix de l'état du feu (rouge, orange ou vert)

20. state = BoxLayout(orientation='horizontal')

21. state.add_widget(CheckBox(group='state'))

22. state.add_widget(Label(text='Rouge'))

23. state.add_widget(CheckBox(group='state'))

24. state.add_widget(Label(text='Orange'))

25. state.add_widget(CheckBox(group='state'))

26. state.add_widget(Label(text='Vert'))

27. # Composant principal

28. box = BoxLayout(orientation='vertical')

29. box.add_widget(city)

30. box.add_widget(activation)

31. box.add_widget(state)

32. return box

34. SignallingApp().run()

La figure 6 montre le résultat obtenu en exécutant ce programme. La liste déroulante s'obtient avec un objet Spinner à qui on spécifie la liste des choix à proposer sous forme d'un tuple, avec le paramètre nommé values du constructeur. On voit d'ailleurs qu'elle est dépliée en l'attente d'un choix de l'utilisateur. Les cases à cocher et les boutons radio sont tous les deux représentés par un objet de type CheckBox. Pour obtenir des boutons radio, il faut les placer dans un même groupe que l'on définit à l'aide du paramètre nommé group du constructeur.

Figure 5. Plusieurs widgets peuvent être utilisés pour proposer un choix à l'utilisateur, parmi lesquels les cases à cocher et boutons radio (CheckBox) et les listes déroulantes (Spinner).

La construction de cette interface graphique est encore plus complexe et plus lourde que la construction de la précédente. On a, en effet, dû utiliser plusieurs conteneurs imbriqués les uns dans les autres pour arriver à la mise en page désirée. La figure 7 montre la hiérarchie des composants utilisés dans cette fenêtre.

Figure 6. L'interface graphique du programme de gestion de feux de signalisation possède trois niveaux d'imbrication de composants, permettant un arrangement vertical de lignes de composants arrangés horizontalement.

VI-A-1-b-ii - Gestionnaire de mise en page

Si on revient un moment sur l'exemple précédent, on se rend compte que les composants de l'interface graphique sont organisés en trois lignes et une colonne. Pour la construire, on a utilisé un conteneur de type BoxLayout, qui n'est sans doute pas le plus adapté pour cela. Un tel conteneur n'a aucun aspect visuel particulier, son seul but étant d'accueillir d'autres composants en les organisant dans l'espace d'une façon particulière. Ce type de composant est un conteneur gestionnaire de mise en page. Une fois le composant créé, on lui en ajoute d'autres grâce à la méthode add_widget.

Un conteneur de type BoxLayout positionne ses composants les uns à côté des autres, soit horizontalement, soit verticalement. On précise le sens que l'on désire à l'aide du paramètre nommé orientation du constructeur, qui peut valoir horizontal ou vertical.

Un autre conteneur très utilisé est le GridLayout, qui permet d'organiser les composants qu'il héberge sous forme d'une grille. Le nombre de lignes et de colonnes que l'on souhaite est spécifié par les paramètres nommés rows et cols du constructeur. Les éléments que l'on ajoute à ce conteneur sont placés dans la grille de gauche à droite et de haut en bas. L'exemple suivant crée une interface graphique avec boutons placés dans une grille de trois lignes et quatre colonnes :

1. from kivy.app import App

2. from kivy.config import Config

3. from kivy.uix.button import Button

4. from kivy.uix.gridlayout import GridLayout

6. class GridGameApp(App):

7. def build(self):

8. self.title = 'Grid Game'

9. grid = GridLayout(rows=3, cols=4)

10. for i in range(12):

11. grid.add_widget(Button(text=str(i + 1)))

12. return grid

14. Config.set('graphics', 'width', '200')

15. Config.set('graphics', 'height', '150')

17. GridGameApp().run()

Le résultat produit par l'exécution de ce programme est montré à la figure 8. Comme on a utilisé les nombres de à comme textes sur les boutons, on voit explicitement l'ordre dans lequel ils ont été ajoutés.

Figure 7. Un conteneur de type GridLayout organise les composants qu'il contient sous la forme d'une grille dont on spécifie le nombre de lignes et de colonnes.

Enfin, on peut positionner les éléments exactement là où on le souhaite, avec la taille que l'on souhaite en utilisant un conteneur de type FloatLayout. Pour cela, il faut tout d'abord spécifier la taille du conteneur avec le paramètre nommé size_hint de son constructeur. Ensuite, lorsqu'on lui ajoute des composants, ceux-ci seront placés à la position qui a été spécifiée par le paramètre nommé pos. L'exemple suivant, dont le résultat se trouve à la figure 9, crée une fenêtre avec un conteneur de type FloatLayout qui contient deux boutons qui ont été placés à des endroits bien précis. Ces boutons ont par ailleurs une taille fixée à (0.3, 0.2), c'est-à-dire % de la fenêtre en largeur et % en hauteur :

1. from kivy.app import App

2. from kivy.config import Config

3. from kivy.uix.button import Button

4. from kivy.uix.floatlayout import FloatLayout

6. class FreePosApp(App):

7. def build(self):

8. self.title = 'Free Positioning'

9. box = FloatLayout(size=(200, 150))

10. box.add_widget(Button(text='A', size_hint=(0.3, 0.2), pos=(0, 0)))

11. box.add_widget(Button(text='B', size_hint=(0.3, 0.2), pos=(50, 80)))

12. return box

14. Config.set('graphics', 'width', '200')

15. Config.set('graphics', 'height', '150')

17. FreePosApp().run()

Figure 8. Un conteneur de type FloatLayout permet de placer les composants qu'il contient à n'importe quelle position que l'on précise lors de leur création.

VI-A-1-c - Fichier KV

Simplifier la construction de l'interface graphique, c'est-à-dire établir la liste des composants avec leurs propriétés et leur placement, peut se faire plus facilement à l'aide du langage KV. La description se fait dans un fichier séparé, qui porte le même nom que celui de l'application (à savoir celui de la classe sans App), et avec l'extension .kv. Reprenons l'exemple précédent de la fenêtre de login ; le programme principal devient :

1. from kivy.app import App

2. from kivy.config import Config

4. class LoginApp(App):

5. pass

7. Config.set('graphics', 'width', '350')

8. Config.set('graphics', 'height', '50')

10. LoginApp().run()

À côté du fichier contenant ce programme, on crée donc un autre fichier, nommé login.kv, avec le contenu suivant :

1. BoxLayout:

2. orientation: 'horizontal'

3. Label:

4. text: 'Pin code'

5. TextInput

6. Button:

7. text: 'Entrer'

Le langage KV permet en fait de décrire un genre de dictionnaire de manière textuelle. Le premier composant déclaré dans ce fichier est celui qui sera inséré dans la fenêtre, c'est-à-dire celui qui est renvoyé par la méthode build, c'est-à-dire un conteneur de type BoxLayout dans notre cas. Vient ensuite un bloc indenté qui reprend les propriétés du composant et les autres composants qu'on veut lui imbriquer. Dans notre cas, on imbrique, dans l'ordre, un label, une zone de texte et un bouton, dans le conteneur.

VI-B - Programmation évènementielle

Maintenant que l'on sait construire l'interface graphique, c'est-à-dire placer les composants dans la fenêtre, on va voir comment définir l'interaction avec l'utilisateur. Lors de cette interaction, de nombreux évènements seront générés tels que des clics, des déplacements de souris, des pressions sur des touches du clavier, des activations de timer, des changements de valeurs de l'accéléromètre, etc. À ces différents évènements, il est possible d'associer des gestionnaires, c'est-à-dire une portion de code qui définit quoi faire en réaction à leur occurrence.

VI-B-1 - Boucle de gestion d'évènements

Un programme doté d'une interface graphique, une fois démarré, ne sera normalement quitté que suite à une action de l'utilisateur (il clique par exemple sur un bouton « quitter »). La figure 9 illustre les étapes de la vie d'un tel programme :

1. Le programme commence par une phase d'initialisation qui va notamment récupérer des informations sur les composants de hardware tels que l'écran, ou les divers senseurs. Cette phase initialise également des structures de données qui seront nécessaires au bon fonctionnement et à la gestion de l'interface graphique ;

2. Le programme démarre ensuite une boucle de gestion d'évènements. Dans cette boucle, potentiellement infinie, le programme reçoit des notifications de tous les évènements qui le concernent et il les traite. Pour les évènements auxquels sont associés des gestionnaires, il exécute ces derniers. La gestion des évènements se fait de manière séquentielle, ils sont traités les uns à la suite des autres ;

3. Lorsqu'un des évènements reçus demande au programme de se quitter, il rentre dans une phase de terminaison durant laquelle il va libérer toutes les ressources allouées avant de se terminer.

Cette boucle de gestion d'évènements est précisément démarrée par la méthode run, appelée sur l'instance de la classe de type App représentant l'application avec interface graphique nouvellement définie. Elle s'exécute jusqu'à ce que le programme soit quitté, avec l'appel sys.exit(0) qui serait exécuté dans un gestionnaire, par exemple.

Figure 9. L'exécution d'un programme avec une interface graphique commence par une initialisation, puis vient une boucle « infinie » qui gère les évènements avant une éventuelle terminaison du programme.

VI-B-1-a - Gestionnaire d'évènements

Lorsqu'un évènement se produit, et si aucun gestionnaire d'évènements ne lui a été associé dans le programme, il sera juste ignoré. Pour associer une action à exécuter lors de l'occurrence d'un évènement, il va donc falloir en définir. Trois éléments vont intervenir pour cela :

- la source de l'évènement est le composant sur lequel l'interaction a eu lieu ou celui qui l'a causé ;

- l'évènement à proprement parler est d'un certain type selon ce qui s'est produit ;

- et le gestionnaire d'évènements est la méthode à exécuter pour chaque occurrence de l'évènement.

En fonction du type d'évènement, certaines informations complémentaires peuvent être disponibles. Voici quelques exemples d'évènements :

- un bouton peut avoir être enfoncé suite à un clic dessus ;

- un clic gauche ou droit peut avoir été fait sur une zone de dessin, aux coordonnées  ;

- un élément d'une liste déroulante peut avoir été sélectionné ;

- un élément à l'intérieur d'un composant peut avoir été glissé-déposé (drag and drop) d'une position de départ à une position d'arrivée  ;

- une touche du clavier peut avoir été enfoncée ou relâchée ;

- la souris peut être rentrée dans ou avoir quitté une zone donnée.

Un gestionnaire d'évènements est donc une méthode que l'on va attacher à un évènement sur une source donnée. L'exemple suivant permet de quitter le programme lorsqu'on clique sur un bouton :

1. import sys

3. from kivy.app import App

4. from kivy.config import Config

5. from kivy.uix.anchorlayout import AnchorLayout

6. from kivy.uix.button import Button

8. class QuitApp(App):

9. def build(self):

10. self.title = 'Quit me'

11. box = AnchorLayout(anchor_x='center', anchor_y='center')

12. quit = Button(text='Quitter', size_hint=(0.7,0.3))

13. quit.bind(on_press=self._quit)

14. box.add_widget(quit)

15. return box

17. def _quit(self, source):

18. sys.exit(0)

20. Config.set('graphics', 'width', '200')

21. Config.set('graphics', 'height', '200')

23. QuitApp().run()

La première chose que l'on peut voir, c'est l'utilisation d'un conteneur de type AnchorLayout. Ce dernier permet de positionner le composant qu'il contient à une position prédéterminée comme au centre, au nord… Dans cet exemple, on l'a centré selon les deux axes et . On crée ensuite le bouton, comme d'habitude, et on l'ajoute dans le conteneur qui est renvoyé par la méthode build.

La nouveauté, par rapport aux précédents exemples, c'est l'ajout d'une méthode dont on a préfixé le nom avec _ pour signaler qu'elle est à usage privé dans la classe. Il s'agit en fait d'un gestionnaire d'évènements que l'on va associer au clic gauche sur le bouton en appelant dessus la méthode bind et en la passant au paramètre nommé on_press :

quit.bind(on_press=self._quit)

Figure 10. Un bouton peut être centré au milieu de son composant mère si ce dernier est un conteneur de type AnchorLayout.

Pendant l'exécution du programme, lorsque l'utilisateur clique sur le bouton, un évènement de type press avec ce dernier en source est enregistré. Une fois que c'est à son tour d'être traité par la boucle d'évènements, puisqu'un gestionnaire lui a été associé, le programme l'exécute et, dans ce cas-ci, le programme est donc quitté.

VI-B-1-a-i - Gestionnaire d'évènements dans le fichier KV

On peut évidemment associer des gestionnaires d'évènements à des composants directement depuis le fichier KV. Pour ce faire, on va devoir importer l'application dans le fichier KV, afin d'avoir accès aux méthodes représentant les gestionnaires d'évènements.

Néanmoins, une différence notable avec l'utilisation de la méthode bind est qu'on ne doit pas spécifier la méthode à appeler, mais bien faire l'appel que l'on désire. Une première conséquence est qu'il faut soi-même fournir le paramètre qui identifie la source de l'évènement.

On commence donc par complètement supprimer la méthode build de la classe QuitApp ainsi que tous les imports qui y sont liés. Pour le reste, on ne doit toucher à rien. La nouvelle version de la classe est donc :

1. import sys

3. from kivy.app import App

4. from kivy.config import Config

6. class QuitApp(App):

7. def _quit(self, source):

8. print(source)

9. sys.exit(0)

11. Config.set('graphics', 'width', '200')

12. Config.set('graphics', 'height', '200')

14. QuitApp().run()

Vient ensuite le fichier quit.kv, dont le composant principal est donc un conteneur de type AnchorLayout :

1. #:import App kivy.app.App

3. AnchorLayout:

4. anchor_x: 'center'

5. anchor_y: 'center'

6. Button:

7. text: 'Quit'

8. size_hint: (0.7,0.3)

9. on_press: App.get_running_app()._quit(self)

On commence par importer la classe App, ce qui se fait comme en Python, mais préfixé des caractères \#:. On récupère ensuite l'instance de la classe QuitApp qui représente le programme qui est exécuté avec l'appel App.get_running_app(). Sur cette instance, on appelle la méthode quit en lui passant en paramètre self qui représente le bouton qui a produit l'évènement. On a donc bien dû appeler la méthode manuellement.

VI-B-1-b - Composant personnalisé

Il est parfois utile de vouloir définir un nouveau composant personnalisé, qui est construit à partir d'autres existants. Il suffit, pour cela, de définir une nouvelle classe dont le type est, par exemple, BoxLayout. Pour illustrer cela, construisons un programme qui calcule automatiquement l'âge de l'utilisateur à partir de son année de naissance, qu'il doit fournir.

La figure 11 montre le look de l'interface graphique du calculateur d'âge. Une zone de texte permet à l'utilisateur d'entrer son année de naissance et, une fois qu'il l'aura validée avec la touche ENTER, l'âge calculé sera affiché dans le label inférieur.

Figure 11. Après avoir entré son année de naissance, l'utilisateur peut obtenir son âge en pressant la touche ENTER.

Pour réaliser cette interface graphique, on va définir un nouveau composant appelé AgeCalculatorForm et, comme d'habitude, la classe de type App qui décrit le programme :

1. class AgeCalculatorForm(BoxLayout):

2. pass

4. class AgeCalculatorApp(App):

5. pass

Il s'agit donc simplement d'une classe de type BoxLayout, dont le corps est vide. Tel quel, un composant de type AgeCalculatorForm est donc complètement équivalent à un composant BoxLayout. Associé à cela, un fichier agecalculator.kv décrit les composants de l'interface :

1. AgeCalculatorForm:

2. orientation: 'vertical'

3. BoxLayout:

4. orientation: 'horizontal'

5. Label:

6. text: 'Année de naissance'

7. TextInput:

8. multiline: False

9. Label

Comme on peut le voir, le composant principal, c'est-à-dire celui qui est ajouté à la fenêtre, est le composant AgeCalculatorForm. La zone de texte est limitée à une ligne grâce à l'option multiline fixée à False. Comme on va le voir à la section suivante, l'avantage de créer son propre composant est qu'on va pouvoir récupérer facilement les informations entrées par l'utilisateur dans la zone de texte.

VI-B-1-c - État des widgets

Lorsqu'on a une interface graphique avec des widgets qui demandent de l'information, il est évidemment utile de savoir la récupérer. De même, lorsqu'un widget présente de l'information à l'utilisateur, il est utile de pouvoir la mettre à jour. Pour ce faire, on peut procéder de différentes manières, selon qu'on utilise ou non un fichier KV. Comme la bonne pratique consiste à en utiliser un, on se limitera à la façon de faire utilisant ce fichier.

La première chose à faire consiste à définir des propriétés dans le nouveau composant qu'on a créé, afin de pouvoir faire le lien avec les composants qui sont définis dans le fichier KV. Comme le lien à faire est vers des composants, on doit utiliser une propriété de type objet, à savoir une instance de la classe ObjectProperty. On ajoute donc deux variables d'instance à la classe AgeCalculatorForm :

class AgeCalculatorForm(BoxLayout):
birthyear_input = ObjectProperty()
age_label = ObjectProperty()

La première variable va servir à faire le lien avec la zone de texte et la seconde avec le label où sera affiché l'âge calculé. Les liens sont établis dans le fichier agecalculator.kv qui devient comme suit :

1. AgeCalculatorForm:

2. birthyear_input: birthyear

3. age_label: age

4. orientation: 'vertical'

5. BoxLayout:

6. orientation: 'horizontal'

7. Label:

8. text: 'Année de naissance'

9. TextInput:

10. id: birthyear

11. multiline: False

12. Label:

13. id: age

La première chose à faire est d'associer un identifiant à l'aide de l'attribut id aux composants que l'on souhaite lier. On ajoute ensuite les deux propriétés qui sont définies dans la classe AgeCalculatorForm dans le fichier KV en leur donnant comme valeurs les identifiants choisis.

La dernière étape consiste à récupérer l'année de naissance, calculer l'âge et l'afficher dans le label. On ajoute pour cela une méthode _compute dans la classe AgeCalculatorForm :

def _compute(self, source):
age = 2016 - int(self.birthyear_input.text)
self.age_label.text = 'Vous avez {} ans.'.format(age)

Il ne reste plus qu'à appeler cette méthode lorsque l'utilisateur enfonce la touche ENTER dans la zone de texte. On modifie pour cela le fichier KV en ajoutant la ligne suivante dans le bloc du TextInput :

on_text_validate: root._compute(self)

L'évènement auquel on associe un gestionnaire est donc on_text_validate. De plus, on peut directement utiliser la variable root qui représente le composant de type AgeCalculatorForm qui est défini, sans devoir passer par App.get_running_app() comme on a dû le faire précédemment.

VI-C - Dessin

Terminons ce chapitre en examinant brièvement comment réaliser des dessins et animations à l'aide de Kivy. Cette bibliothèque permet de réaliser des dessins sophistiqués statiques ou animés en exploitant les capacités d'OpenGL et de SDL.

OpenGL est une collection de fonctions permettant de réaliser des calculs d'images 2D et 3D comme de la géométrie d'objets et du calcul de projection à l'écran, par exemple. SDL est une bibliothèque que l'on utilise pour créer des jeux 2D permettant notamment de gérer l'affichage vidéo, l'audio numérique et les périphériques tels que la souris et le clavier, par exemple.

VI-C-1 - Composant canvas

On peut dessiner dans un composant en utilisant sa propriété canvas. Il suffit d'y définir une séquence d'opérations graphiques à réaliser, comme dessiner un rectangle ou changer la couleur du pinceau. Pour que le résultat soit intéressant, il vaut évidemment mieux utiliser un conteneur pour avoir une zone de dessin vierge au départ.

La figure 12 montre un exemple d'une fenêtre avec un conteneur de type BoxLayout sur lequel on a dessiné différents éléments. Pour ce faire, on a défini un nouveau composant de type BoxLayout, dans lequel on fera les dessins. Voici le code Python du programme :

1. from kivy.app import App

2. from kivy.uix.boxlayout import BoxLayout

4. class DrawForm(BoxLayout):

5. pass

7. class DrawApp(App):

8. pass

10. DrawApp().run()

On définit donc un nouveau composant DrawForm qui est un conteneur de type BoxLayout. C'est dans ce dernier que le dessin sera réalisé, à l'aide de la propriété canvas.

Figure 12. La propriété canvas d'un composant permet de définir des dessins qui sont réalisés dans le composant.

Vient ensuite le fichier KV qui contient la description du dessin à réaliser. Dans ce dernier, on définit la séquence des opérations de dessin à faire grâce à la propriété canvas. Ces opérations seront réalisées dans l'ordre dans lequel elles sont définies :

1. DrawForm:

2. canvas:

3. Color:

4. rgb: [1, 1, 1]

5. Rectangle:

6. pos: (0, 0)

7. size: self.size

8. Color:

9. rgb: [0.7, 0.2, 0]

10. Rectangle:

11. pos: (50, 200)

12. size: (400, 150)

Quatre opérations graphiques seront donc réalisées au moment où le programme est lancé et que l'interface graphique s'affiche :

1. La première opération change la couleur qui est utilisée à blanc ;

2. La deuxième dessine un rectangle dont le coin inférieur gauche est en , c'est-à-dire le coin inférieur gauche de la zone de dessin, et dont la taille est la même que celle du composant. En gros, on peint l'arrière-fond du composant intégralement en blanc ;

3. On change ensuite de nouveau la couleur de dessin, en la fixant à un rouge composé de de rouge et de bleu ;

4. On termine en dessinant un plus petit rectangle, qui sera donc en rouge, dont le coin inférieur droit est en et de pixels de largeur et pixels de hauteur.

VI-C-1-a - Transformation

Il est possible d'appliquer trois différentes transformations aux dessins réalisés. Une rotation d'un certain angle peut se faire autour d'un axe donné par rapport à un point d'origine. Une translation peut se faire suivant un certain vecteur. Enfin, une mise à l'échelle selon les différents axes peut se faire par rapport à un point d'origine.

Voici une modification de l'exemple précédent qui utilise les trois transformations pour dessiner un nouveau rectangle bleu :

1. DrawForm:

2. canvas:

3. Color:

4. rgb: [1, 1, 1]

5. Rectangle:

6. pos: (0, 0)

7. size: self.size

8. Color:

9. rgb: [0.7, 0.2, 0]

10. Rectangle:

11. pos: (50, 200)

12. size: (400, 150)

13. Translate:

14. x: 200

15. y: 0

16. Rotate:

17. origin: (450, 200)

18. angle: -45

19. axis: (0, 0, 1)

20. Scale:

21. origin: (450, 200)

22. x: 0.5

23. y: 0.5

24. Color:

25. rgb: [0, 0.2, 0.7]

26. Rectangle:

27. pos: (50, 200)

28. size: (400, 150)

On repart donc du rectangle rouge qu'on va redessiner à la dernière étape, après avoir changé la couleur en bleu, mais surtout après avoir défini une séquence de transformations à appliquer. Sans rentrer dans les détails mathématiques, il faut savoir que les opérations de transformations sont appliquées dans l'ordre inverse de leur apparition. Dans cet exemple, on en définit trois qui vont donc s'appliquer dans l'ordre suivant :

1. On commence avec une mise à l'échelle diminuant de moitié les dimensions de l'objet selon les deux axes ( et ), grâce au facteur , et par rapport au point d'origine qui correspond au coin inférieur droit du rectangle rouge ;

2. On enchaine ensuite avec une rotation, autour du même point d'origine, de degrés dans le sens antihorloger, selon l'axe (la rotation se fait donc uniquement dans le plan ) ;

3. On termine enfin avec une translation de pixels vers la droite (axe ) et de en hauteur (axe ).

Les transformations, une fois définies, s'appliquent à tous les objets qui seront dessinés par après. La figure 13 montre le résultat produit par l'exécution de l'exemple. L'ordre des transformations est très important et on n'obtient, en général, pas le même résultat final lorsqu'on modifie l'ordre des transformations. Il faut également faire attention à l'origine qui est utilisée pour les rotations et mises à l'échelle.

Figure 13. L'utilisation de transformations permet de modifier la manière avec laquelle un objet est dessiné dans le canvas.

VII - Qualité de code

Nous voilà déjà au bout de cette initiation à la programmation avec le langage Python. Pour terminer ce livre, on va s'intéresser à la qualité de code et aux conventions de codage à adopter lorsqu'on écrit un programme. C'est en effet une bonne chose que le programme fonctionne et fasse ce qu'il faut, mais s'il est de bonne qualité, c'est encore mieux. En particulier, on va évidemment voir quelles sont les conventions utilisées en Python, pour écrire des programmes pythoniques.

VII-A - Qualité de code

Un code est correct lorsqu'il fait ce qu'il faut, c'est-à-dire qu'il calcule le résultat qu'on attend de lui. Cette notion générale est plutôt difficile à vérifier et on s'intéresse dès lors plutôt à un code correct par rapport à ses spécifications. Un tel code, une fois exécuté, satisfait toujours ses postconditions, peu importe comment on l'appelle et avec quels paramètres, pour autant qu'on respecte les préconditions. Il s'agit, en somme, d'un code dont l'implémentation respecte le contrat établi par ses spécifications. Il est parfois possible de s'assurer qu'un code est correct par rapport à ses spécifications.

Une fois qu'on a un tel code, on pourrait déjà être content. Mais il faut savoir que, le temps de sa durée de vie, un code est plus souvent lu qu'écrit. Il est dès lors important d'accorder un certain soin à sa rédaction, tout comme un auteur n'écrit pas un livre n'importe comment. Un code de qualité en est un qui respecte une série de conventions et règles de bonne pratique, le rendant facile à lire et comprendre par n'importe qui. Un autre avantage d'un tel code est qu'il sera plus facile à mettre à jour et qu'il sera moins probable d'y avoir des bogues.

VII-A-1 - Outil Pylint

Un code peut être audité pour évaluer sa qualité. Il s'agit, en bref, de parcourir un code pour vérifier qu'il respecte une série de critères et règles précises, notamment en termes de qualité. De manière générale, on peut également évaluer sa robustesse, son niveau de sécurité, identifier ses éventuelles vulnérabilités, etc.

Concernant la qualité de code, il est possible d'automatiser toute une série de vérifications. Par exemple, l'outil Pylint permet de vérifier automatiquement de nombreuses règles. Il peut notamment :

- vérifier les conventions de codage décrites dans le PEP 0008 ;

- détecter automatiquement des erreurs comme des variables ou imports non utilisés, etc. ;

- repérer des duplications de code et autres mauvaises pratiques nécessitant de modifier le code pour améliorer sa qualité.

Prenons l'exemple de programme suivant :

1. import sys

3. def getbirthyear(value):

4. while(True):

5. try:

6. birthyear=int(input('Année de naissance ? '))

7. if 0 <= birthyear <= 2016: return birthyear

8. except ValueError:

9. pass

11. b = getbirthyear(None)

12. print('Vous avez {} ans.'.format(2016 - b))

Le programme demande à l'utilisateur d'entrer son année de naissance. Tant qu'il ne fournit pas un nombre qui est compris entre et , une boucle infinie permet de lui redemander d'entrer une valeur correcte. Une fois qu'il a donné une valeur correcte, son âge est calculé et affiché. Enfin, on a placé la partie du programme qui demande l'année de naissance dans une fonction getbirthyear.

L'exécution de l'outil Pylint sur ce programme donne le résultat suivant :

1. No config file found, using default configuration

2. ************* Module test

3. C:  4, 0: Unnecessary parens after 'while' keyword (superfluous-parens)

4. C:  6, 0: Exactly one space required around assignment

5. birthyear=int(input('Année de naissance ? '))

6. ^ (bad-whitespace)

7. C: 11, 0: Trailing whitespace (trailing-whitespace)

8. C: 12, 0: Final newline missing (missing-final-newline)

9. C:  1, 0: Missing module docstring (missing-docstring)

10. C:  3, 0: Missing function docstring (missing-docstring)

11. C:  7,39: More than one statement on a single line (multiple-statements)

12. W:  3,17: Unused argument 'value' (unused-argument)

13. C: 11, 0: Invalid constant name "b" (invalid-name)

14. W:  1, 0: Unused import sys (unused-import)

Chaque ligne de ce rapport contient plusieurs informations :

- elle commence avec une lettre indiquant le type de message (C pour une convention non respectée, W pour un avertissement de mauvais style et E pour une erreur qui pourrait causer un bogue) ;

- viennent ensuite deux nombres indiquant le numéro de la ligne et le numéro de la colonne liés au message fourni ;

- et enfin, vient une description textuelle de l'élément de mauvaise qualité trouvé.

Pour comprendre le genre d'éléments de mauvaise qualité que Pylint est capable de retrouver, passons en revue chaque ligne du rapport :

1. Des parenthèses inutiles ont été détectées après le mot réservé while, ce qui alourdit le code inutilement ;

2. Il manque exactement une espace avant et après le symbole = utilisé pour faire une affectation ;

3. Il y a des espaces inutiles au bout de la ligne ;

4. Il manque un retour à la ligne en fin de fichier ;

5. Il manque la documentation du module ;

6. Il manque la documentation de la fonction ;

7. Plusieurs instructions ont été placées sur la même ligne (on a en effet le corps de l'instruction if qui a été placé sur la même ligne, ce qui est autorisé par Python lorsque ce dernier n'est composé que d'une seule instruction) ;

8. Le paramètre value n'est pas utilisé (la fonction getbirthyear reçoit en effet un paramètre inutile) ;

9. Le nom de la constante b est invalide (le nom est en effet trop court) ;

10. L'import de sys n'est pas utilisé.

Toutes ces remarques vont pouvoir être prises en compte afin de modifier le programme. Notez bien que ce dernier est correct, car il fait exactement ce pour quoi il a été conçu. Le seul reproche qu'on lui fait est de ne pas avoir un certain niveau de qualité étant donné qu'une série de conventions et règles de bonne pratique n'ont pas été respectées. Voici la nouvelle version du programme, qui passe tous les tests de qualité faits par Pylint :

1. """Module de calcul d'âge."""

3. def getbirthyear():

4. """Demande son année de naissance à l'utilisateur."""

5. while True:

6. try:

7. birthyear = int(input('Année de naissance ? '))

8. if 0 <= birthyear <= 2016:

9. return birthyear

10. except ValueError:

11. pass

13. YEAR = getbirthyear()

14. print('Vous avez {} ans.'.format(2016 - YEAR))

Pylint ne sait évidemment pas tout vérifier. Par exemple, la variable YEAR est-elle vraiment nécessaire puisqu'elle n'est utilisée qu'une seule fois. On aurait directement pu faire l'appel à la fonction getbirthyear dans la dernière instruction et remplacer les deux dernières lignes par l'unique instruction suivante :

print('Vous avez {} ans.'.format(2016 - getbirthyear()))

On détaillera les conventions PEP 0008, vérifiées par Pylint, dans la deuxième section de ce chapitre.

VII-A-1-a - Règles de bonne pratique

Voyons maintenant plusieurs règles de bonne pratique à respecter si l'on veut écrire un code de qualité. Ce que l'on va ici voir n'est pas nécessairement lié à Python et peut s'appliquer à des programmes écrits dans d'autres langages.

VII-A-1-a-i - Variable

On utilise une variable pour une seule raison, qui est clairement établie. Il faut, par exemple, éviter d'avoir une variable dont la signification dépend de la valeur de son contenu. De même, on évitera d'utiliser une variable fourre-tout qui change de rôle en fonction de la partie du programme dans laquelle on se trouve.

On utilise une variable pour stocker une valeur qui est utilisée plusieurs fois, afin d'éviter une duplication de code. On peut aussi utiliser une variable pour éviter un nombre magique, c'est-à-dire un nombre qui apparait dans une instruction sans que l'on sache forcément ce qu'il représente. Le mettre dans une variable, qui aura donc un nom, permet de rendre sa signification explicite. Pour faire mieux, on va même mettre son nom en majuscules, ce qui signifie, par convention, qu'il s'agit d'une constante. L'exemple suivant comporte deux erreurs de style :

for code in database['beverage']['beers']:
print(database['beverage']['beers'][code]['name'])
print(database['beverage']['beers'][code]['price'] * 1.21)

Comme on utilise plusieurs fois la valeur database['beverage']['beers'], on va la placer dans une variable. De plus, la multiplication par est faite pour calculer le prix incluant les taxes qui sont de et on va le rendre explicite en définissant une constante TAX_RATE :

1. TAX_RATE = 0.21

2. beers = database['beverage']['beers']

3. for code in beers:

4. print(beers[code]['name'])

5. print(beers[code]['price'] * (1 + TAX_RATE))

Après cette première amélioration du code, on se rend compte que l'accès à beers[code] est dupliqué au sein de la boucle. On pourrait donc encore l'améliorer en créant une nouvelle variable, dans la boucle, pour stocker la bière en cours de traitement :

1. TAX_RATE = 0.21

2. beers = database['beverage']['beers']

3. for code in beers:

4. beer = beers[code]

5. print(beer['name'])

6. print(beer['price'] * (1 + TAX_RATE))

Et qu'en est-il du chiffre magique 1 ? On pourrait vouloir le remplacer, mais c'est normalement suffisamment clair qu'il s'agit simplement d'une formule permettant de calculer le prix taxes incluses lorsqu'on connait le taux de taxation.

Enfin, vous aurez remarqué qu'on n'a pas choisi le nom des variables utilisées n'importe comment. Un bon nom de variable doit expliquer sa raison d'être, en décrivant la variable et son contenu. De manière générale, on évite des noms trop courts ou trop longs (on parle souvent d'un nombre de caractères compris entre et , en moyenne).

De manière générale, on évite les lettres ambigües. Il est, par exemple, facile de confondre char1 (chiffre ) avec charl (lettre ) ou COnf (lettre ) et C0nf (chiffre ). De plus, on essaie, dans la mesure du possible, d'utiliser des noms anglais et on n'oublie pas de faire attention à l'orthographe. Enfin, on évitera d'avoir des noms proches dans le même programme (par exemple input et inputVal) ou similaires (par exemple clientRep et clientRec).

Voici encore trois autres exemples de noms à éviter, avec un exemple de bon nom à mettre à la place :

1. # Pas bien

2. My_AgE = 27

3. istheavatardeadornot = False

4. the_price_of_the_item = 8.99

6. # Bien

7. age = 25

8. dead = True

9. price = 12.99

VII-A-1-a-ii - Expression booléenne

Il est très important de bien maitriser le type booléen pour éviter d'écrire du code inutile. Tout d'abord, il faut se rappeler qu'une condition d'un if est une expression booléenne. Dès lors, si on utilise une instruction if-else pour initialiser une variable booléenne, on doit en fait s'en passer et directement affecter la condition à la variable :

1. # Pas bien

2. if x > 170:

3. accepted = True

4. else:

5. accepted = False

7. # Bien

8. accepted = x > 170

Si les valeurs affectées à la variable accepted étaient inversées, on aurait utilisé l'opérateur logique not pour inverser la valeur de la condition :

1. # Pas bien

2. if x > 170:

3. refused = False

4. else:

5. refused = True

7. # Bien

8. refused = not x > 170

De plus, pour tester si une variable booléenne vaut True ou False, on ne doit pas utiliser l'opérateur d'égalité. En effet, la variable, étant de type booléen, est déjà elle-même une condition valable :

1. # Pas bien

2. if hasmoney == True:

3. print('Je peux acheter !')

5. if canaccess == False:

6. print('Ne pas rentrer !')

8. # Bien

9. if hasmoney:

10. print('Je peux acheter !')

12. if not canaccess:

13. print('Ne pas rentrer !')

Enfin, il ne faut pas hésiter à simplifier les expressions booléennes, surtout celles utilisées comme condition, en exploitant les équivalences logiques. Par exemple, not x == y est équivalent à x != y :

1. # Pas bien

2. success = not grade < 10

3. result = not (temperature > 15 and temperature <= 32)

5. # Bien

6. success = grade >= 10

7. result = temperature <= 15 or temperature > 32

Le deuxième exemple exploite les lois de De Morgan qui disent que not (x and y) est équivalent à not x or not y et que not (x or y) est équivalent à not x and not y.

VII-A-1-a-iii - Variable indexée

Lorsqu'on doit stocker plusieurs fois la même information, mais concernant des entités différentes, on doit utiliser une liste plutôt que de déclarer plusieurs variables qui auront presque les mêmes noms, ne différant que par un nombre ajouté en fin de nom, par exemple. Cela permet d'éviter de déclarer pleins de variables et permet également un traitement plus facile à l'aide de boucles :

1. # Pas bien

2. grade1 = 12.5

3. grade2 = 7.5

4. grade3 = 18.5

6. print(grade1)

7. print(grade2)

8. print(grade3)

10. # Bien

11. grades = [12.5, 7.5, 18.5]

13. for i in range(3):

14. print(grades[i])

La liste est donc adaptée lorsqu'on a une séquence de données indexées par un nombre entier. Lorsque le nom des variables cache des propriétés, on va plutôt se tourner vers un dictionnaire :

1. # Pas bien

2. grade_louis_python = 18.5

3. grade_louis_proba = 8.5

4. grade_valerian_python = 2.5

5. grade_sam_python = 10

6. grade_cousin_proba = 11.5

8. # Bien

9. grades = {

10. 'louis': {'python': 18.5, 'proba': 8.5},

11. 'valerian': {'python': 2.5},

12. 'sam': {'python': 10},

13. 'cousin': {'proba': 11.5}

14. }

VII-A-1-a-iv - Instruction inutile

De manière générale, il faut essayer de garder un code le plus compact possible et éviter les instructions inutiles. On essaiera ainsi de limiter la longueur des lignes (en nombre de caractères) et également le niveau d'indentation. On peut, par exemple, éliminer le bloc else dans deux situations précises.

Lorsqu'on utilise une instruction if-else pour initialiser une variable, qui peut donc recevoir deux valeurs différentes, on peut éliminer le bloc else en initialisant la variable avec la valeur qu'elle aurait eue dans ce dernier, avant l'instruction if. En somme, c'est comme si on affectait une valeur par défaut à la variable, puis qu'on la changeait si une condition est satisfaite :

1. # Pas bien

2. if grade < 10:

3. verdict = 'Raté'

4. else:

5. verdict = 'Réussi'

7. # Bien

8. verdict = 'Réussi'

9. if grade < 10:

10. verdict = 'Raté'

L'autre situation où on peut diminuer le niveau d'indentation est lorsqu'on définit une fonction qui renvoie deux valeurs possibles en fonction d'une condition. Dans ce cas, et puisque return quitte immédiatement l'exécution du corps d'une fonction, on peut simplement éliminer le else et placer le contenu de son bloc après l'instruction if :

1. # Pas bien

2. def verdict(grade):

3. if grade < 10:

4. return 'Raté'

5. else:

6. return 'Réussi'

8. # Bien

9. def verdict(grade):

10. if grade < 10:

11. return 'Raté'

12. return 'Réussi'

VII-A-1-a-v - Duplication de code

Enfin, un dernier point qu'il faut essayer d'éviter à tout prix, ce sont les duplications de code. On a déjà vu qu'on pouvait en éliminer en définissant des nouvelles variables lorsqu'on avait une duplication exacte qui faisait référence à une même valeur.

On peut en fait combattre la duplication exacte et quasi exacte de code, et on le fait pour plusieurs raisons. Tout d'abord, si on trouve une erreur dans le code dupliqué, il faudra aller la corriger partout avec le risque d'oublier des corrections et de laisser des bogues. De plus, la duplication alourdit inutilement le code, le rendant difficile à lire. Enfin, lorsqu'on fait de la quasi-duplication, on finit par s'embrouiller et ne plus cerner les subtiles différences, rendant toute mise à jour du code périlleuse.

Une technique permettant d'éliminer de la quasi-duplication de code consiste à définir une nouvelle fonction, qui prend autant de paramètres qu'il y a de différences entre les codes similaires. Regardons l'exemple suivant pour comprendre cela :

1. # Pas bien

2. dvd = 17.50

3. coca = 0.70

4. print('Prix :', dvd * 1.21)

5. print('Prix :', coca * 1.21)

7. # Bien

8. def taxprice(price):

9. return price * 1.21

11. items = {'dvd': 17.50, 'coca': 0.70}

12. for name in items:

13. print('Prix :', taxprice(items[name]))

VII-A-1-a-vi - Découpe en fonctions

Lorsqu'on code, il faut également réfléchir au futur et à la réutilisabilité. Il est ainsi parfois utile de définir une nouvelle fonction, qui est suffisamment générale que pour pouvoir servir à nouveau, éventuellement dans un autre contexte. Partons de l'exemple suivant qui a pour but de trier un tuple de deux valeurs :

1. # Pas bien

2. data = (12, 7)

3. if data[0] < data[1]:

4. sorted = data

5. else:

6. sorted = (data[1], data[0])

On pourrait commencer par éliminer le bloc else avec la technique de la valeur par défaut. On pourrait même, avant cela, inverser les deux blocs du if-else et la condition du if, de sorte que le cas par défaut soit sorted = data :

1. # Mieux

2. data = (12, 7)

3. sorted = data

4. if data[0] >= data[1]:

5. sorted = (data[1], data[0])

Enfin, pour faire encore mieux, il convient d'anticiper le fait que l'on risque de devoir trier un tuple de deux éléments dans le futur. Il deviendrait dès lors intéressant de définir une fonction pour cela, qui sera réutilisable en cas de besoin. On définit donc une fonction sortpair, qui n'est pour le moment appelée qu'une seule fois, mais qui pourra l'être directement dans le futur si besoin :

1. # Bien

2. def sortpair(data):

3. if data[0] >= data[1]:

4. return (data[1], data[0])

5. return data

7. sorted = sortpair((12, 7))

VII-A-1-a-vii - Mise en page

L'aspect visuel global d'un code, à savoir sa mise en page, est également important. Un bon layout permet de faire ressortir la logique du code et d'aider la personne qui doit le lire et le comprendre. On va pour cela utiliser les blancs et les lignes vides pour faire ressortir des blocs de code et les parenthèses pour mettre en évidence des parties d'expressions. L'exemple suivant montre un exemple de code compact et pas aéré qui, malgré qu'il fonctionne, n'est pas facile à comprendre :

1. # Pas bien

2. x=12+3*y

3. if x>=0:print("Hello, it's me")

4. else:print("Heeeeey macarena!")

6. # Bien

7. x = 12 + 3 * y

8. if x >= 0:

9. print("Hello, it's me")

10. else:

11. print("Heeeeey macarena!")

Un autre élément de layout concerne l'ordre dans lequel on écrit certaines parties de code. Ainsi, dans une classe, on définira d'abord le constructeur, puis les accesseurs et mutateurs et enfin les méthodes.

VII-A-1-a-viii - Commentaire

Last but not least, on n'hésitera pas à agrémenter les programmes que l'on écrit de commentaires. On retrouve plusieurs types de commentaires dans un programme :

- les commentaires qui se contentent de répéter ce que fait le code, le paraphrasant, sont tout à fait à proscrire. On suppose, en effet, que les personnes qui vont lire votre code savent tout aussi bien que vous comprendre le Python ;

- expliquer ce que fait le code, non pas en le paraphrasant, mais en détaillant son fonctionnement ou sa logique, est une bonne chose à faire. Il s'agit donc d'aider les lecteurs à comprendre la logique du code, par rapport au problème résolu ;

- enfin, un dernier type de commentaire parfois utilisé, et dont il ne faut pas abuser, permet d'insérer des marqueurs ou des métainformations dans le code. On ajoutera ainsi, par exemple, l'auteur et la version du code, ou des lignes de tirets pour faire ressortir des séparations logiques.

Dans l'exemple suivant, on a clairement mis trop de commentaires qui se contentent, par ailleurs, de décrire en français le code qui est écrit juste en dessous :

1. data = [8, 4, -2, 6]

2. value = 12

4. found = False

5. # On parcourt tous les éléments de la liste data

6. # qui passent dans la variable elem qu'on compare

7. # avec value pour vérifier si c'est la même ou non

8. for elem in data:

9. if elem == value:

10. # Si on a trouvé la valeur cherchée, on le

11. # signale via la variable found et on quitte

12. # la boucle qui parcourt la liste data

13. found = True

14. break

16. print('Found' if found else 'Not found')

Un seul commentaire au début du bloc de code qui recherche la valeur dans la liste aurait été amplement suffisant, bien qu'il ne soit même pas forcément nécessaire. Si on veut vraiment en mettre un, alors on pourrait avoir quelque chose du genre :

1. # Cherche si 'value' se trouve dans la liste 'data'

2. found = False

3. for elem in data:

4. # [...]

Dans la mesure du possible, si on peut se passer de commentaires, on les évitera pour ne pas alourdir inutilement un code source. Comme ledit très bien Steve McConnell : « Good code is its own documentation ». Python étant fait pour être lisible et concis, un code bien écrit en respectant les règles de style et conventions en usage devrait pouvoir être facilement compris. Les seuls commentaires qu'il devrait y avoir dans un code Python doivent donc servir à des explications en lien avec le problème résolu par le programme. Si vous ressentez le besoin de mettre un commentaire pour expliquer un bout de code, posez-vous d'abord la question de savoir si vous n'avez pas écrit un code trop complexe, qui peut être simplifié.

VII-B - Convention de codage

Terminons ce livre en beauté en passant en revue différentes conventions globalement acceptées, qui vous permettront d'écrire du code pythonique. Pour rappel, les deux principaux mots clés souvent associés au Python sont lisibilité et concision.

Suivre des conventions apporte plusieurs avantages aux développeurs. Il est tout d'abord plus facile de rentrer dans le code écrit par un autre développeur que soi. Cela permet également d'obtenir un code plus uniforme pour un projet qui est écrit par plusieurs développeurs. La productivité d'une équipe peut ainsi être accrue, grâce aux gains de temps obtenus par une meilleure lisibilité du code et grâce à la consistance accrue au sein du projet.

VII-B-1 - PEP 0008

On a déjà eu l'occasion de découvrir une partie des règles énoncées dans le PEP 0008 au chapitre 2. Voyons maintenant les règles qu'on n'avait pas encore pu voir précédemment, car elles concernaient de la matière abordée entre-temps :

- un code source Python devrait être enregistré en UTF-8 ;

- concernant les espaces :

- il faut utiliser quatre espaces pour un niveau d'indentation, et on évite la tabulation horizontale ;

- lorsqu'on a une longue liste de valeurs entre parenthèses (paramètres d'une fonction/méthode, condition d'un if/while), on peut les séparer sur plusieurs lignes en alignant le tout verticalement :

def createperson(firstname, lastname, birthdate,
sex, address, weight, height):
if (sex == 'F' or
sex == 'M'):
# ...

- lorsqu'on a une longue liste de valeurs, entre parenthèses, crochets ou accolades, on peut les séparer sur plusieurs lignes en indentant le contenu :

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

- lorsqu'on sépare une expression sur plusieurs lignes, on placera les opérateurs en début de chaque nouvelle ligne :

total = (price
+ tax
- sale)

- on insère deux lignes vides avant et après une définition de classe ou de fonction, et une ligne vide avant et après une définition de méthode dans une classe ;

- on ne met pas d'espace à l'intérieur de parenthèses, crochets ou accolades, avant un deux-points, autour d'un deux-points dans un slice, avant la parenthèse ouvrante d'un appel de fonction/méthode, avant un crochet pour accéder à un élément d'une liste ou d'un dictionnaire :

# Pas bien
def func(name = 'Jon'):
preferences = { name    : ['Seb'  , 'Alexis'  ] }
values      = data   [2 :4]
verdict     = getresult ('Tom')

# Bien
def func(name='Jon'):
preferences = {name: ['Seb', 'Alexis']}
values = data[2:4]
verdict = getresult('Tom')

- on ne met qu'une seule espace avant et après l'opérateur d'affectation :

# Pas bien
data=32
result     =   7

# Bien
data = 32
result = 7

- une ligne trop longue, notamment si elle dépasse caractères, peut être coupée avec le caractère de continuation (\) :

with open('/Users/johndoe/Desktop/video-ole-ole.mp4'), \
open('/Users/johndoe/Desktop/marchand-brouette.jpg'):
# [...]

- concernant les imports :

- on écrit une ligne pour chaque module importé avec import, mais on peut les rassembler lorsqu'on utilise from ... import :

# Pas bien
from math import cos
from math import sin
import os, sys

# Bien
from math import cos, sin
import os
import sys

- on organise les imports en trois blocs : d'abord les imports de la bibliothèque standard, puis ceux de bibliothèques tierces et enfin ceux des bibliothèques développées pour l'application ;

- il faut éviter, autant que possible, d'importer * ;

- il n'y a aucune recommandation particulière pour délimiter les chaines de caractères, on peut utiliser librement des guillemets simples ou doubles, sauf pour les triples guillemets où on préfèrera les doubles pour respecter la convention de Docstring ;

- on n'ajoute pas d'espaces au bout d'une ligne ;

- on entoure toujours les opérateurs binaires suivants d'espaces : =, +=, -=…, ==, <, >, !=, <=, >=, in, not in, is, is not, and, or et not ;

- on ne met pas d'espaces autour de l'opérateur = pour les paramètres nommés, lors de l'appel ou pour spécifier une valeur par défaut ;

- concernant les noms :

- ne pas utiliser les lettres l ( minuscule), O ( majuscule) et I ( majuscule) dans un nom de variable, fonction, classe ;

- un module ou un package doit avoir un nom court et rien qu'avec des minuscules ;

- un nom de classe commence par une majuscule et suit la casse chameau (en casse chameau, appelée camelcase en anglais, chaque mot commence par une majuscule comme dans HotBeverageDistributor, par exemple) ;

- un nom de fonction/méthode et de variable est en minuscules, en séparant éventuellement les mots avec des tirets de soulignement (_), si cela augmente la lisibilité ;

- un nom de constante est complètement en majuscules, en séparant éventuellement les mots avec des tirets de soulignement ;

1. from math import sqrt

3. class ComplexNumber:

4. def __init__(self, real, imag):

5. self.__real = real

6. self.__imag = imag

8. def norm(self):

9. return sqrt(self.__real ** 2 + self.__imag ** 2)

11. ZERO = ComplexNumber(0, 0)

VII-B-1-a - Code pythonique

Terminons avec quelques conventions de bon usage, adoptées par la grande majorité des programmeurs Python, qui font en sorte qu'un code est qualifié de pythonique, ou non. Voici, en vrac, une série d'éléments auxquels il convient de faire attention et qu'il faut exploiter pour rendre son code pythonique (pour en savoir plus, vous pouvez consulter les deux sites web suivants : http://docs.python-guide.org/en/latest/writing/style/ et http://web.archive.org/web/20180411011411/http://python.net/~goodger/projects/pycon/2007/idiomatic/handout.html) :

- pour intervertir les valeurs de deux variables a et b, on doit exploiter l'affectation multiple :

# Pas bien
temp = a
a = b
b = temp

# Bien
a, b = b, a

- on doit exploiter le déballage lorsqu'on parcourt une structure de données qui contient des tuples ou des listes d'éléments. Par exemple, si on a une liste qui contient des contacts représentés par un prénom, un nom et un âge, on pourra écrire :

data = [['John', 'Doe', 27], ['Tom', 'Doe', 45]]
for (first, last, age) in data:
print('{} {} ({} ans)'.format(first, last, age))

- construire une chaine de caractères à partir de plusieurs valeurs est plus rapide avec la méthode join qu'avec une boucle de concaténations :

data = ['hello', 'world!']

# Pas bien
result = ''
for elem in data:
result += str(elem) + ' '
print(result[:-1])

# Bien
print(' '.join(data))

- utiliser l'opérateur in lorsque c'est possible ;

- utiliser la méthode get des dictionnaires, qui permet d'obtenir la valeur associée à une clé ou une autre valeur que l'on spécifie si la clé n'est pas dans le dictionnaire :

stock = {}

# Pas bien
for (item, quantity) in data:
if item not in stock:
stock[item] = 0
stock[item] += quantity

# Bien
for (item, quantity) in data:
stock[item] = stock.get(item, 0) + quantity

- une séquence vide valant False dans une condition, on ne doit pas utiliser len pour tester qu'elle est non vide :

# Pas bien
if len(data) != 0:
print(data[0])

# Bien
if data:
print(data[0])

- exploiter au maximum la définition de liste par compréhension pour remplacer une construction faite par une boucle dans laquelle on utilise une condition. Par exemple, pour filtrer une liste et n'en garder que les valeurs paires, on peut faire :

data = [8, -1, 15, 7, 9, 12]

# Pas bien
even = []
for elem in data:
if elem % 2 == 0:
even.append(elem)

# Bien
even = [x for x in data if x % 2 == 0]

Enfin, si vous avez oublié la philosophie qui est derrière Python, si vous avez l'impression d'être sur le mauvais chemin et de ne plus être assez pythonique, lancez Python en mode interactif et importez le module this pour quelques rappels :

1. >>> import this

2. The Zen of Python, by Tim Peters

4. Beautiful is better than ugly.

5. Explicit is better than implicit.

6. Simple is better than complex.

7. Complex is better than complicated.

8. Flat is better than nested.

9. Sparse is better than dense.

10. Readability counts.

11. Special cases aren't special enough to break the rules.

12. Although practicality beats purity.

13. Errors should never pass silently.

14. Unless explicitly silenced.

15. In the face of ambiguity, refuse the temptation to guess.

16. There should be one-- and preferably only one --obvious way to do it.

17. Although that way may not be obvious at first unless you're Dutch.

18. Now is better than never.

19. Although never is often better than *right* now.

20. If the implementation is hard to explain, it's a bad idea.

21. If the implementation is easy to explain, it may be a good idea.

22. Namespaces are one honking great idea -- let's do more of those!