Aller au contenu

Algo & Prog 1 Python : CM séance 03

3. Exceptions, contrôles et récursivité

3.1. Exceptions

On a vu que l'interpréteur Python détecte toutes sortes d'erreurs :

>>> print(zztop)    # NameError: name 'zztop' is not defined
>>> 10/0            # ZeroDivisionError: division by zero
>>> int("toto")     # ValueError: invalid literal for int() with base 10: 'toto'

Ces erreurs s'appellent des exceptions et on peut les gérer dans le programme :

try :
    bloc1
except nomException:
    bloc2

Le bloc 1 est exécuté ; si il y a une erreur nomException, le bloc 1 est interrompu et on saute au bloc 2. Le programme ne s'arrête pas : on dit que l'exception a été captée.

Exemple :

try :
    a = int("toto")
    print("conversion réalisée")
except ValueError :
    print("pas possible")
    a = None

On peut aussi récupérer le message d'erreur :

try :
    bloc1
except nomException as detail :
    bloc2

Exemple :

try :
    a = int("toto")
    print("conversion réalisée")
except ValueError as detail :
    print ("Erreur:", detail)
    a = None

Affiche :

Erreur: invalid literal for int() with base 10: 'toto'

On peut aussi capter plusieurs exceptions : donner un tuple

try :
    bloc1
except (nomException1, nomException2, ...) as detail :
    bloc2

Exemple :

try :
    foo = {}
    a = foo['bar']
    print("foobar")
except (NameError, KeyError, ValueError) as detail :
    print ("Erreur:", detail)

On peut mettre plusieurs except pour différentier les exceptions :

try :
    bloc1
except nomException1 :
    bloc2
except nomException2 :
    bloc3
...
except :        # on capte toute autre exception
    blocn

Il est possible d'émettre une exception avec raise :

try :
    a = int(input("Entrez un nombre > 10 : "))
    if a <= 10 :
        raise ValueError("Entier <= 10")
    print ("L'entier est correct")
except ValueError as detail :
    print("Erreur :", detail)

>>> 
    Entrez un nombre > 10 : 20      # L'entier est correct
>>> 
    Entrez un nombre > 10 : toto    # Erreur : invalid literal ... 'toto'
>>> 
    Entrez un nombre > 10 : 8       # Erreur : Entier <= 10

Lorsqu'une exception est émise (par raise ou une erreur) dans une fonction, cette fonction est interrompue, de même pour la fonction qui l'appelait, et ainsi de suite jusqu'à l'appel par le programme principal, tant que l'exception n'est pas captée par un except.

3.2. Contrôle de boucle

On peut contrôler les boucles while et for avec : break, continue

  • break sort immédiatement de la boucle ;
  • continue passe immédiatement à l'itération suivante.

Exemple : afficher les 5 premiers entiers pairs d'une liste

li = [ 5, 7, 8, 2, 6, 12, 7, 5, 11, 14, 8, 17 ]
n = 0
for e in li :
    if e % 2 != 0 : 
        continue
    n += 1
    print (e)
    if n >= 5 : 
        break

On peut rajouter un else à la fin d'un while ou d'un for : le bloc du else sera exécuté si la boucle s'est terminée sans break.

Exemple : recherche du premier élément > k dans une liste

k = 10     # essayer aussi avec 20
li = [ 5, 7, 8, 2, 6, 12, 7, 5, 11, 14, 8, 17 ]
for e in li :
    if e > k :
        print (e)
        break
else :
    print ("non trouvé")

Le bloc du else est aussi exécuté s'il n'y a aucune itération (puisqu'il n'y a pas eu de break) :

while False :
    print ("glop glop")
else :
    print ("pas glop")

for i in [] :
    print ("glop glop")
else :
    print ("pas glop")

Sorties par force croissante :

  • break : sort de la boucle
  • return : sort de la fonction
  • raise : sort de toutes les fonctions jusqu'à un try except
  • exit : fin du programme (import sys ; sys.exit())
Bibliographie : à propos de break et return
Eric S. Roberts, Loop exits and structured programming: reopening the debate, SIGCSE pp 268-272, 1995. https://dl.acm.org/doi/abs/10.1145/199688.199815

3.3. Récursivité

Une fonction peut s'appeler elle même ; on dit alors qu'elle est récursive. Les appels intermédiaires sont mémorisés dans la pile d'exécution.

Il y a un moment où la fonction doit arrêter de s'appeler, et renvoyer un résultat : c'est le cas terminal.

Voici des exemples.

3.3.1. Factorielle

Définie par : \(1! = 1\) et \(n! = n * (n-1)!\)

def factorielle (n) :
    if n <= 1 :
        return 1                    # cas terminal
    return n * factorielle (n-1)    # cas général

>>> factorielle(5)      # 120

Version avec des traces :

def factorielle (n) :
    print ("début factorielle (", n, ")", sep="")
    if n <= 1 :
        print ("renvoie 1, cas terminal")
        return 1                    # cas terminal
    print ("appel factorielle (", n, "-1)", sep = "")
    f = factorielle (n-1)
    print ("résultat obtenu :", f)
    print ("renvoie n * f =", n,"*",f,"=", n*f)
    return n * f

Profondeur de récursion = niveau d'appel maximum.
L'appel initial est à profondeur 1.

Pour factorielle(n), la profondeur est n :

  • au niveau 1, factorielle(n) appelle factorielle(n-1) ;
  • au niveau 2, factorielle(n-1) appelle factorielle(n-2) ;
  • ...
  • au niveau n-1, factorielle(2) appelle factorielle(1) ;
  • au niveau n, factorielle(1) est le cas terminal.

En python la profondeur de récursion est limitée :

>>> factorielle(998)    # gros chiffre
>>> import math
>>> math.log(factorielle(998),10)    # 2561.605078733907

>>> factorielle(999)    # RuntimeError: maximum recursion depth exceeded

On peut consulter ou modifier la profondeur limite :

>>> import sys
>>> sys.getrecursionlimit()
1000
>>> sys.setrecursionlimit(2000)

3.3.2. Suite de Fibonacci

Définie par : \(F_n = F_{n-1} + F_{n-2}\) ; \(F_1 = F_2 = 1\)

Les valeurs sont : 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, ...

def fibonacci (n) :
    if n <= 2 :
        return 1                    # cas terminal
    return fibonacci (n-1) + fibonacci (n-2)

>>> fibonacci(10)      # 55

Efficacité, nombre d'appels ? → rajouter un print

def fibonacci (n) :
    print ("fibo", n)
    if n <= 2 :
        return 1
    return fibonacci (n-1) + fibonacci (n-2)

>>> fibonacci(10)      # 109 appels !

→ Beaucoup de recalculs !

Meilleure approche : avec variables auxiliaires

def fibonacci (n) :
    return fibo_rec (n, 1, 1)

def fibo_rec (n, a, b) :
    print ("fibo_rec", n, a, b)
    if n <= 2 :
        return a
    return fibo_rec (n-1, a+b, a)

>>> fibonacci(10)
fibo_rec 10 1 1
fibo_rec 9 2 1
fibo_rec 8 3 2
fibo_rec 7 5 3
fibo_rec 6 8 5
fibo_rec 5 13 8
fibo_rec 4 21 13
fibo_rec 3 34 21
fibo_rec 2 55 34
55

Il s'agit d'une récursion terminale (= appel sans calcul de la fonction récursive).

Cette approche s'inspire de la version itérative :

def fibonacci2(n) :
    if n <= 2 :
        return 1
    a = 1 ; b = 1
    for i in range(2,n) :
        a,b = a+b,a
    return a

Certains langages dits fonctionnels détectent les récursions terminales et les transforment en itérations (en économisant au passage de mémoriser les résultats intermédiaire dans la pile d'exécution).

3.3.3. Arbres binaires

Les arbres binaires sont des structures naturellement récursives.

Un arbre binaire est un arbre avec racine dans lequel tout noeud a au plus deux fils : un éventuel fils gauche et un éventuel fils droit.

Exemple d'arbre binaire et de codage :

#            Racine
#            /    \
#           A      B
#            \    / \
#             C  D   E
#                   /
#                  F

nF = { "gauche" : None, "droit" : None, "valeur" : "F" }
nC = { "gauche" : None, "droit" : None, "valeur" : "C" }
nD = { "gauche" : None, "droit" : None, "valeur" : "D" }
nE = { "gauche" : nF  , "droit" : None, "valeur" : "E" }
nA = { "gauche" : None, "droit" : nC  , "valeur" : "A" }
nB = { "gauche" : nD  , "droit" : nE  , "valeur" : "B" }
nR = { "gauche" : nA  , "droit" : nB  , "valeur" : "R" }

>>> nR

Quelle est la profondeur de l'arbre ? (la niveau de la racine étant 1).
→ 1 + max (profondeur(fils gauche), profondeur(fils droit))

def profondeur (noeud) :
    if noeud == None :
        return 0
    return 1 + max (profondeur(noeud["gauche"]),
                    profondeur(noeud["droit"]))

>>> profondeur(nR)    # 4

Ordre de parcours et unicité des appels :

def profondeur (noeud) :
    if noeud == None :
        return 0
    print (noeud["valeur"], end=" ")                # trace rajoutée
    return 1 + max (profondeur(noeud["gauche"]),
                    profondeur(noeud["droit"]))

>>> profondeur (nR)
R A C B D E F 4

Cet ordre est celui d'un parcours en profondeur.

On peut aussi réaliser un parcours en largeur, c'est-à-dire niveau par niveau, avec une approche itérative : à partir d'une ligne courante de nœuds liste1, on construit la liste2 des fils de ces nœuds, puis on itère.

def profondeur2 (noeud) :  # version avec parcours en largeur
    res = 0
    if noeud == None :
        return res
    print (noeud["valeur"], end=" ")
    liste1 = [noeud["gauche"], noeud["droit"]]
    while len(liste1) > 0 :
        res += 1
        liste2 = []
        for s in liste1 :
            if s == None :
                continue
            print (s["valeur"], end=" ")
            liste2 += [s["gauche"], s["droit"]]
        liste1 = liste2
    return res

>>> profondeur2 (nR)
R A B C D E F 4