IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Pensez en Python

Comment maîtriser la science de l'informatique


précédentsommairesuivant

19. Les bonus

L'un de mes objectifs dans ce livre a été de vous en apprendre aussi peu que possible sur Python. Lorsqu'il y avait deux façons différentes de faire quelque chose, j'en ai choisi une et évité de parler de l'autre. Ou alors, parfois, j'ai mentionné la seconde dans un exercice.

Je voudrais maintenant revenir à quelques-unes des pépites que j'ai laissées de côté. Python offre un bon nombre de fonctionnalités qui ne sont pas réellement nécessaires - vous pouvez écrire de bons programmes sans elles -, mais elles peuvent parfois vous permettre d'écrire du code plus concis, ou plus lisible, ou plus efficace, et parfois même les trois en même temps.

19-1. Expressions conditionnelles

Nous avons étudié les instructions conditionnelles au chapitre 5.4Exécution conditionnelle. Ces conditions sont souvent utilisées pour choisir entre deux valeurs. Par exemple :

 
Sélectionnez
if x > 0:
    y = math.log(x)
else:
    y = float('nan')

Ce fragment de code vérifie si x est un nombre positif. Si c'est le cas, il calcule le logarithme de ce nombre. Sinon, la fonction math.log générerait une exception entraînant l'arrêt du programme. Pour éviter cet arrêt intempestif du programme, nous générons un « NaN » (Not a Number), une valeur spéciale en virgule flottante qui représente autre chose qu'un nombre.

Nous pouvons écrire ces instructions de façon plus concise en utilisant une expression conditionnelle :

 
Sélectionnez
y = math.log(x) if x > 0 else float('nan')

Que l'on peut lire comme suit : « y prend la valeur log(x) si x est plus grand que 0 ; sinon, ce n'est pas un nombre, il prend la valeur spéciale NaN. »

Les fonctions récursives se prêtent parfois à l'utilisation d'expressions conditionnelles. Par exemple, voici une version récursive de la fonction factorielle :

 
Sélectionnez
def factorielle(n):
    if n == 0:
        return 1
    else:
        return n * factorielle(n-1)

Nous pouvons la réécrire comme suit :

 
Sélectionnez
def factorielle(n):
    return 1 if n == 0 else n * factorielle(n-1)

Une autre utilisation des expressions conditionnelles est la gestion des arguments optionnels. Par exemple, voici la méthode init de la solution GoodKangaroo (voir exercice 2 du chapitre 17) :

 
Sélectionnez
def __init__(self, name, contents=None):
    self.name = name
    if contents == None:
        contents = []
        self.pouch_contents = contents

Nous pouvons la réécrire ainsi :

 
Sélectionnez
def __init__(self, name, contents=None):
    self.name = name
    self.pouch_contents = [] if contents == None else contents

D'une façon générale, vous pouvez remplacer une instruction conditionnelle par une expression conditionnelle si les deux branches contiennent des expressions simples qui sont soit renvoyées à une fonction appelante, soit affectées à la même variable.

19-2. Les listes en compréhension

Nous avons abordé dans la section 10.7Mapper, filtrer et réduire les mécanismes de mappage et de filtration. Par exemple, la fonction suivante prend en entrée une liste de chaînes de caractères, applique la méthode de chaîne capitalize à chacun des éléments et renvoie une nouvelle liste de chaînes :

 
Sélectionnez
def mettre_tout_en_capitales(t):
    res = []
    for s in t:
        res.append(s.capitalize())
    return res

Nous pouvons réécrire cette fonction de façon plus concise en utilisant une liste en compréhension :

 
Sélectionnez
def mettre_tout_en_capitales(t):
    return [s.capitalize() for s in t]

Les opérateurs crochets indiquent que nous construisons une nouvelle liste. L'expression à l'intérieur des crochets spécifie les éléments de la liste, et la clause for indique quelle séquence nous parcourons.

La syntaxe d'une liste en compréhension est un peu étrange parce que la variable de boucle, s dans notre exemple, apparaît dans l'expression (s.capitalize) avant qu'elle ne soit définie (for s in t).

Les listes en compréhension s'appellent parfois aussi listes en intension (avec un s et non un t, car le mot, issu de la philosophie et des mathématiques, s'oppose ici à extension).

Les listes en compréhension permettent également de filtrer des données. Par exemple, cette fonction sélectionne les éléments de t qui sont en lettres capitales et renvoie la nouvelle liste :

 
Sélectionnez
def capitales_seulement(t):
    res = []
    for s in t:
        if s.isupper():
            res.append(s)
    return res

Nous pouvons la réécrire en utilisant une liste en compréhension :

 
Sélectionnez
def capitales_seulement(t):
    return [s for s in t if s.isupper()]

Les listes en compréhension sont concises et faciles à lire, tout au moins pour les expressions simples. Et elles sont généralement plus rapides que des boucles for équivalentes, parfois beaucoup plus rapides.

Donc, si vous m'en voulez de ne pas les avoir mentionnées plus tôt, je vous comprends. Pour ma défense, je signalerais cependant que les listes en compréhension sont plus difficiles à déboguer parce que vous ne pouvez pas ajouter une instruction d'impression à l'intérieur de la boucle. Et je vous recommanderais de ne les utiliser que si le calcul est suffisamment simple pour que vous ayez une bonne chance de l'écrire correctement du premier coup. Ce qui, pour de purs débutants, signifie jamais.

19-3. Les générateurs

Les générateurs ressemblent aux listes en compréhension, mais avec des parenthèses à la place des crochets :

 
Sélectionnez
>>> g = (x**2 for x in range(5))
>>> g
<generator object <genexpr> at 0x7f4c45a786c0>

Le résultat est un objet générateur qui sait comment itérer sur une séquence de valeurs. Mais, à la différence d'une liste en compréhension, il ne s'empresse pas de calculer toutes les valeurs immédiatement : il attend qu'on lui demande d'en calculer une. La fonction interne next obtient et renvoie la valeur suivante du générateur :

 
Sélectionnez
>>> next(g)
0
>>> next(g)
1

Quand vous arrivez à la fin de la séquence, next signale une exception StoptIteration. Vous pouvez également utiliser une boucle for pour itérer sur les valeurs :

 
Sélectionnez
>>> for valeur in g:
... print(valeur)
4
9
16

Le générateur sait où il était arrivé dans la séquence, c'est pourquoi la boucle for ci-dessus a repris là où le dernier next était arrivé. La boucle for s'arrête silencieusement à la fin de la séquence, mais, si on l'appelle une nouvelle fois, il générera à nouveau une exception :

 
Sélectionnez
>>> next(g)
StopIteration

Les générateurs sont fréquemment utilisés avec des fonctions comme sum, max et min :

 
Sélectionnez
>>> sum(x**2 for x in range(5))
30

19-4. Les fonctions any et all

La fonction interne any de Python prend en entrée une série de valeurs booléennes et renvoie True si l'une au moins des valeurs est vraie. Cela fonctionne sur des listes :

 
Sélectionnez
>>> any([False, False, True])
True

Mais on s'en sert souvent avec des générateurs :

 
Sélectionnez
>>> any(lettre == 't' for lettre in 'monty')
True

Cet exemple ne paraît pas très utile, car il fait la même chose que l'opérateur in. Mais nous pourrions utiliser any pour réécrire certaines des fonctions de recherche que nous avons écrites dans la section 9.3Recherche.

Par exemple, la fonction evite :

 
Sélectionnez
def evite(mot, interdites):
    for lettre in mot:
        if lettre in interdites:
            return False
    return True

peut se réécrire comme suit :

 
Sélectionnez
def evite(mot, interdites):
    return not any(lettre in interdites for lettre in mot)

Ce qui se lit en presque bon français : « mot évite interdites s'il n'y a pas any lettre interdite dans mot ».

Utiliser any avec un générateur est efficace parce que fonction s'arrête (sans aller jusqu'au bout) dès qu'elle a trouvé une valeur satisfaisant la condition recherchée.

Il y a également une fonction interne, all, qui renvoie True si tous les éléments de la séquence sont vrais. À titre d'exercice, utilisez all pour réécrire la fonction utilise_toutes de la section 9.3Recherche.

19-5. Les ensembles (sets)

Dans la section 13.6Soustraction de dictionnaire, j'ai utilisé des dictionnaires pour trouver des mots figurant dans un document, mais pas dans une liste de mots. La fonction soustraire utilisait d1, contenant les mots du document sous la forme de clés, et d2, contenant la liste de mots. Elle renvoyait un dictionnaire contenant les clés de d1 ne figurant pas dans d2.

 
Sélectionnez
def soustraire(d1, d2):
    resultat = dict()
    for clef in d1:
        if clef not in d2:
            resultat[clef] = None
    return resultat

Dans chacun de ces trois dictionnaires, les valeurs associées à toutes les clés étaient None, car nous n'utilisions jamais ces valeurs. Ceci a pour effet de gaspiller l'espace mémoire.

Il existe en Python un autre type de donnée interne, l'ensemble (set), qui fonctionne comme une collection de clés d'un dictionnaire sans valeurs. Il est rapide d'ajouter un élément à un ensemble, de même que de vérifier l'appartenance d'un élément à un ensemble. Et les ensembles fournissent des méthodes et des opérateurs pour effectuer les opérations ensemblistes communes.

Par exemple, la soustraction d'ensembles réalisée ci-dessus peut se faire grâce à une méthode de différence ensembliste, difference, ou à l'opérateur - entre deux ensembles. Nous pouvons donc réécrire soustraire comme suit :

 
Sélectionnez
def soustraire(d1, d2):
    return set(d1) - set(d2)

Le résultat est un ensemble au lieu d'un dictionnaire, mais pour les opérations qui nous intéressent, comme l'itération ou le test d'appartenance, le comportement est identique.

Quelques-uns des exercices de ce livre peuvent se faire de façon concise et efficace en utilisant des ensembles. Voici par exemple une solution utilisant un dictionnaire de l'exercice 7 du chapitre 10 :

 
Sélectionnez
def has_duplicates(t):
    d = {}
    for x in t:
        if x in d:
            return True
        d[x] = True
    return False

Quand un élément apparaît pour la première fois, il est ajouté au dictionnaire. Si le même élément se présente à nouveau, la fonction renvoie True. À la fin, si aucun doublon n'a été rencontré, la fonction renvoie False.

En utilisant les ensembles, nous pouvons réécrire cette fonction ainsi :

 
Sélectionnez
def has_duplicates(t):
    return len(set(t)) < len(t)

Dans un ensemble, un élément ne peut être présent qu'une seule fois. Par conséquent, si un élément existe plusieurs fois dans t, l'ensemble sera plus petit que la liste d'origine. Inversement, s'il n'y a pas de doublon, l'ensemble et la liste d'origine auront la même taille.

Nous pouvons aussi utiliser des ensembles pour résoudre certains des exercices du chapitre 9Étude de cas : jouer avec les mots. Par exemple, voici notre version de utilise_uniquement avec une boucle :

 
Sélectionnez
def utilise_uniquement(mot, disponibles):
    for lettre in mot: 
        if lettre not in disponibles:
            return False
    return True

La fonction utilise_uniquement vérifie si toutes les lettres du mot sont disponibles. Nous pouvons la réécrire en employant un ensemble :

 
Sélectionnez
def utilise_uniquement(mot, disponibles):
    return set(mot) <= set(disponibles)

L'opérateur <=, qui s'écrirait mathématiquement (est inclus dans ou est égal), vérifie si un ensemble est un sous-ensemble d'un autre (ou est égal à l'autre), ce qui se vérifie si toutes les lettres de mot appartiennent à disponibles.

À titre d'exercice, réécrivez la fonction evite du chapitre 9Étude de cas : jouer avec les mots en employant des ensembles.

19-6. Les compteurs (Counter)

Un compteur (Counter) ressemble à un ensemble, sauf que si un élément est ajouté plus d'une fois, il n'est pas dédoublonné, mais le compteur enregistre le nombre de ses occurrences. Si le concept mathématique de multiensemble (parfois également appelé « sac ») vous est familier, alors sachez qu'un compteur est une façon naturelle de représenter un multiensemble.

Les compteurs ne sont pas un type de donnée interne de Python, mais sont définis dans un module standard nommé collections, que vous devez donc importer pour pouvoir utiliser les compteurs. Vous pouvez initialiser un compteur avec une chaîne de caractères, une liste ou toute autre chose supportant l'itération :

 
Sélectionnez
>>> from collections import Counter
>>> decompte = Counter('perroquet')
>>> decompte
Counter({'e': 2, 'r': 2, 'o': 1, 'q': 1, 'p': 1, 'u': 1, 't': 1})

Les compteurs se comportent à bien des égards comme des dictionnaires. Ils établissent une correspondance entre chaque clé et le nombre d'occurrences de cette clé. Comme les dictionnaires, il faut que les clés soient hachables.

À la différence des dictionnaires, les compteurs ne déclenchent aucune exception si on tente d'accéder à un élément qui ne s'y trouve pas : dans ce cas, ils renvoient simplement 0 :

 
Sélectionnez
>>> decompte['d']
0

Nous pouvons utiliser des compteurs pour réécrire la fonction is_anagram de l'exercice 6 du chapitre 10 :

 
Sélectionnez
def is_anagram(mot1, mot2):
    return Counter(mot1) == Counter(mot2)

Si deux mots sont des anagrammes, alors ils contiennent les mêmes lettres et chaque lettre le même nombre de fois, si bien que leurs compteurs sont équivalents.

Les compteurs fournissent des méthodes et opérateurs pour effectuer des opérations de type ensembliste, en particulier l'addition, la soustraction, l'union et l'intersection. Une méthode souvent utile est most_common, qui renvoie une liste de paires valeur-fréquence, triées de la plus fréquente à la plus rare :

 
Sélectionnez
>>> decompte = Counter('perroquet')
>>> for val, freq in decompte.most_common(3):
...     print(val, freq)
...
e 2
r 2
o 1

Le nombre passé en entrée à la fonction most_common (ici, 3) indique le nombre maximal de paires clé-valeur à parcourir. Si l'argument est manquant, la fonction most_common itérera sur l'ensemble des paires :

 
Sélectionnez
>>> for val, freq in count.most_common():
...     print(val, freq)
...
e 2
r 2
o 1
q 1
p 1
u 1
t 1

19-7. Dictionnaire de type defaultdict

Le module collections propose aussi la fonctionnalité defaultdict, qui ressemble à un dictionnaire si ce n'est que, si vous accédez à une clé qui n'existe pas, il peut générer une nouvelle valeur à la volée.

Quand vous créez un dictionnaire defaultdict, vous fournissez une fonction qui est utilisée pour créer de nouvelles valeurs. Une fonction utilisée pour créer des objets s'appelle parfois une usine (factory). Les fonctions internes de création de listes, d'ensembles et d'autres types peuvent servir d'usines :

 
Sélectionnez
>>> from collections import defaultdict
>>> d = defaultdict(list)

Notez que l'argument est list, qui est un objet de classe, et non list() qui serait une nouvelle liste. La fonction que vous fournissez n'est pas appelée tant que vous ne cherchez pas à accéder à une clé qui n'existe pas.

 
Sélectionnez
>>> t = d['nouvelle clé']
>>> t
[]

La nouvelle liste, que nous appelons ici t, est également ajoutée au dictionnaire. Il en résulte que si nous modifions t, le changement se répercutera dans le dictionnaire d :

 
Sélectionnez
>>> t.append('nouvelle valeur')
>>> d
defaultdict(<class 'list'>, {'nouvelle clé': ['nouvelle valeur']})

Si vous constituez un dictionnaire de listes, vous pouvez souvent écrire du code plus simple en utilisant des dictionnaires de type defaultdict. Dans ma solution à l'exercice 2 du chapitre 12, que vous pouvez télécharger à l'adresse anagram_sets.py, j'ai construit un dictionnaire établissant une correspondance entre une chaîne de caractères triés par ordre alphabétique et une liste de mots pouvant être écrits avec ces lettres. Par exemple, la chaîne de caractères 'ADEIR' pointe sur la liste de mots ['AIDER', 'ARIDE', 'RADIE', 'RAIDE']. Voici le code d'origine :

 
Sélectionnez
def all_anagrams(nomfichier):
    d = {}
    for ligne in open(nomfichier):
        mot = ligne.strip().lower()
        t = signature(mot)
        if t not in d:
            d[t] = [mot]
        else:
            d[t].append(mot)
    return d

Il est possible de simplifier cette fonction avec la fonctionnalité setdefault que vous avez peut-être utilisée dans l'exercice 2 du chapitre 11 :

 
Sélectionnez
def all_anagrams(nomfichier):
    d = {}
    for ligne in open(nomfichier):
        mot = ligne.strip().lower()
        t = signature(mot)
        d.setdefault(t, []).append(mot)
    return d

Cette solution présente cependant l'inconvénient de créer une nouvelle liste à chaque fois, même si elle n'est pas nécessaire. Pour des listes, ce n'est pas trop gênant. Mais si la fonction usine est complexe, ça peut le devenir.

Nous pouvons éviter cet écueil et simplifier le code en utilisant un defaultdict :

 
Sélectionnez
def all_anagrams(nomfichier):
    d = defaultdict(list)
    for ligne in open(nomfichier):
        mot = ligne.strip().lower()
        t = signature(mot)
        d[t].append(mot)
    return d

Ma solution à l'exercice 3 du chapitre 18, que vous pouvez télécharger à l'adresse PokerHandSoln.py utilise setdefault dans la fonction has_straightflush. Cette solution a le désavantage de créer un objet de type Hand à chaque itération dans la boucle, qu'il soit utile ou non. À titre d'exercice, réécrivez cette fonction en utilisant un defaultdict.

19-8. Tuples nommés

Beaucoup d'objets simples sont essentiellement des collections de valeurs apparentées. Par exemple, l'objet Point défini au chapitre 15Classes et objets contient deux nombres, x et y. Quand vous définissez une classe de ce genre, vous commencez souvent avec une méthode init et une méthode str :

 
Sélectionnez
class Point:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __str__(self):
        return '(%g, %g)' % (self.x, self.y)

Cela représente beaucoup de code pour ne pas dire grand-chose. Il existe en Python une façon plus concise de dire la même chose :

 
Sélectionnez
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])

Le premier argument est le nom de la classe que vous désirez créer et le second une liste d'attributs dont les objets Point ont besoin, sous la forme de chaînes de caractères. La valeur de retour de la fonction namedtuple est un objet classe :

 
Sélectionnez
>>> Point
<class '__main__.Point'>

Point possède automatiquement des méthodes comme __init__ et __str__, si bien que vous n'avez pas besoin de les écrire.

Pour créer un objet Point, il suffit d'utiliser la classe Point comme une fonction :

 
Sélectionnez
>>> p = Point(1, 2)
>>> p
Point(x = 1, y = 2)

La méthode init affecte les arguments aux attributs en utilisant les noms que vous avez fournis. La méthode str affiche une représentation de l'objet de type Point et de ses attributs.

Vous pouvez également accéder aux éléments du tuple nommé par leur nom :

 
Sélectionnez
>>> p.x, p.y
(1, 2)

Mais vous pouvez aussi manipuler un tuple nommé comme un tuple :

 
Sélectionnez
>>> p[0], p[1]
(1, 2)
>>> x, y = p
>>> x, y
(1, 2)

Les tuples nommés fournissent une façon rapide de définir des classes simples. Le problème est que les classes simples ne restent pas toujours simples. Il se peut que vous vouliez ultérieurement ajouter des méthodes à un tuple nommé. Dans ce cas, vous pourriez définir une nouvelle classe héritant du tuple nommé :

 
Sélectionnez
class Point_plus_riche(Point):
# ajoutez de nouvelles méthodes ici

Ou alors vous pourriez décider de passer à une définition de classe conventionnelle.

19-9. Assembler des arguments avec mot-clé

Nous avons vu à la section 12.4Arguments tuples à longueur variable comment écrire une fonction qui assemble ses arguments dans un tuple :

 
Sélectionnez
def affiche_tout(*args):
    print(args)

Vous pouvez appeler cette fonction avec un nombre quelconque d'arguments positionnels (c'est-à-dire d'arguments n'utilisant pas de mots-clés).

 
Sélectionnez
>>> affiche_tout(1, 2.0, '3')
(1, 2.0, '3')

Mais l'opérateur * ne peut pas assembler des arguments dotés de mots-clés :

 
Sélectionnez
>>> affiche_tout(1, 2.0, troisieme='3')
TypeError: printall() got an unexpected keyword argument 'troisieme'

Pour assembler des arguments avec mots-clés, vous pouvez utiliser l'opérateur ** :

 
Sélectionnez
def affiche_tout(*args, **kwargs):
    print(args, kwargs)

Vous pouvez choisir le nom qu'il vous plaira pour le paramètre d'assemblage des arguments avec mots-clés, mais il est usuel de l'appeler kwargs. Le résultat est un dictionnaire établissant une correspondance entre les mots-clés et les valeurs.

 
Sélectionnez
>>> affiche_tout(1, 2.0, troisieme='3')
(1, 2.0) {'troisieme': '3'}

Si vous avez un dictionnaire de mots-clés et de valeurs, vous pouvez utiliser l'opérateur de dispersion ** pour appeler une fonction :

 
Sélectionnez
>>> d = dict(x=1, y=2)
>>> Point(**d)
Point(x=1, y=2)

Sans cet opérateur, la fonction traiterait d comme un argument positionnel unique, et affecterait donc d à x, et se plaindrait ensuite qu'il n'y ait rien à affecter à y :

 
Sélectionnez
>>> d = dict(x=1, y=2)
>>> Point(d)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: __new__() missing 1 required positional argument: 'y'

Quand vous utilisez des fonctions qui ont un nombre élevé de paramètres, il est souvent utile de créer et de passer en paramètre des dictionnaires qui spécifient les options fréquemment utilisées.

19-10. Glossaire

  • expression conditionnelle : une expression qui peut prendre une valeur parmi deux possibles, en fonction d'une condition.
  • liste en compréhension : une expression avec une boucle for entre crochets qui produit une nouvelle liste.
  • générateur : une expression avec une boucle for entre parenthèses qui produit un objet générateur.
  • multiensemble : une entité mathématique qui établit une correspondance entre les éléments d'un ensemble et le nombre d'occurrences de chacun de ces éléments.
  • usine : une fonction, habituellement passée en paramètre, utilisée pour créer des objets.

19-11. Exercices

Exercice 1

La fonction suivante calcule récursivement les coefficients binomiaux :

 
Sélectionnez
def coeff_binomial(n, k):
    """Calcule le coefficient binomial "k parmi n".
    n: nombre d'essais
    k: nombre de succès
    renvoie : int
    """
    if k == 0:
        return 1
    if n == 0:
        return 0
    resultat = coeff_binomial(n-1, k) + coeff_binomial(n-1, k-1)
    return resultat

Réécrivez le corps de la fonction en utilisant des expressions conditionnelles imbriquées.

Remarque : cette fonction n'est pas très efficace parce qu'elle finit par recalculer encore et encore les mêmes valeurs. Vous pourriez la rendre plus efficace en la mémoïsant (mise en cache de ces valeurs, voir section 11.6Mémos). Mais vous découvrirez sans doute qu'elle est plus difficile à mémoïser si vous l'écrivez avec des expressions conditionnelles.


précédentsommairesuivant

Licence Creative Commons
Le contenu de cet article est rédigé par Allen B. Downey et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Pas d'Utilisation Commerciale - Partage dans les Mêmes Conditions 3.0 non transposé.
Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright © 2016 Developpez.com.