Du code, du communisme

Python love: les listes en intension (partie 2)

En première partie, nous avons vu les bases des listes en intension. Mais elles ont encore beaucoup de choses à offrir. Même si vous les utilisez depuis quelques temps, lisez la suite, vous pourriez bien apprendre quelque chose.

Les expressions génératrices

Il y a plus encore !
Quand vous faites ceci:

>>> nombres = [sum(range(nombre)) for nombre in range(0, 10, 2)]
>>> for nombre in nombres:
...     print(nombre)
...
1
6
15
28

La liste nombres est créée en mémoire.
Si la liste est petite, ce n’est pas un problème. Mais si la liste est grande, par exemple si c’est un fichier complet, cela peut devenir très consommateur de RAM.
Il existe un moyen de pallier à cela:

>>> nombres = (sum(range(nombre)) for nombre in range(0, 10, 2))
>>> for nombre in nombres:
...     print(nombre)
...
1
6
15
28

Vous ne voyez pas la différence ? Regardez de plus près la première ligne: les [] ont été remplacés par des parenthèses. Tout le reste de la syntaxe est la même.
Ce petit détail change absolument tout:

>>> [sum(range(nombre)) for nombre in range(0, 10, 2)]
[0, 1, 6, 15, 28]
>>> (sum(range(nombre)) for nombre in range(0, 10, 2))
 at 0x7f07bac94be0>

[] crée une liste.
() crée un générateur.
Un générateur ressemble beaucoup à une liste : c’est un itérable, donc on peut l’utiliser de la même manière dans une boucle for. La différence principale est que le générateur ne peut être lu qu’une seule fois. Si vous bouclez dessus une seconde fois, il sera vide:

>>> nombres = (sum(range(nombre)) for nombre in range(0, 10, 2))
>>> for nombre in nombres:
...     print(nombre)
...
1
6
15
28
>>> for nombre in nombres: # ceci n'affiche rien !
...     print(nombre)
...

La raison à cela est que le générateur ne contient pas toutes les valeurs de la liste. Il les génère.
Il calcule chaque valeur une à une, à la volée, quand la boucle for lui demande. Il calcule la première valeur, puis l’oublie, puis la deuxième, puis l’oublie, etc. Jusqu’à la dernière.
Cela signifie qu’on ne peut pas demander à un générateur un élément à un index en particulier:

>>> liste = [sum(range(nombre)) for nombre in range(0, 10, 2)]
>>> liste[0] # donne moi l'élément à l'index 0
>>> generateur = (sum(range(nombre)) for nombre in range(0, 10, 2))
>>> generateur[0]
Traceback (most recent call last):
File "", line 1, in 
TypeError: 'generator' object is not subscriptable

On utilise les générateurs partout où l’on a besoin d’une liste, mais qu’on ne souhaite pas stocker toute la liste en mémoire, et qu’on pense lire la liste une seule fois.
Il y a bien plus à dire sur les générateurs, qui est une notion très puissante et importante en Python. Si vous avez envie d’en savoir plus, par exemple si le mot clé yield vous intrigue, il y a un article dédié.

Il n’y a pas que les listes dans la vie

Python a une idée bien plus large de l’itération que les listes. En fait, tout objet « itérable » peut être utilisé dans une boucle for:

>>> une_liste = [1, 2, 3]
>>> for item in une_liste:
...     print(item)
...
1
2
3
>>> un_tuple = (1, 2, 3)
>>> for item in un_tuple:
...     print(item)
...
1
2
3
>>> un_dictionnaire = {'moi': 'tarzan', 'toi': 'jane'}
>>> for key in un_dictionnaire:
...     print(key)
...
moi
toi
>>> une_chaine_de_caracteres = "yabadabado"
>>> for lettre in une_chaine_de_caracteres:
...     print(lettre)
...
y
a
b
a
d
a
b
a
d
o
>>> un_fichier = open('fichier_de_test.txt')
>>> for line in un_fichier:
...     print(line)
...
b'ceci'
b'est'
b'un'
b'test'

Être itérable est un concept à part entière en Python.
Et il se trouve que les listes en intension acceptent n’importe quel itérable, pas juste les listes.
Tout comme les expressions génératrices.
Et plein de fonctions acceptent n’importe quel itérable. tuple(), liste(), join(), sum(), etc. Du coup on peut faire un tas de combos à faire se pâmer un fan de Tekken:

>>> [str(sum(range(int(nombre)))) for nombre in "123456789"]
['0', '1', '3', '6', '10', '15', '21', '28', '36']
>>> ', '.join([str(sum(range(int(nombre)))) for nombre in "123456789"])
'0, 1, 3, 6, 10, 15, 21, 28, 36'
>>> ', '.join(str(sum(range(int(nombre)))) for nombre in "123456789")
'0, 1, 3, 6, 10, 15, 21, 28, 36'

Notez sur la dernière ligne que l’on peut carrément supprimer les [], ne pas rajouter de parenthèses, et ça marche toujours. L’expression retourne un générateur, passé en paramètre à join().
On peut aussi chainer tout ça, les unes à la suite des autres et créer un gigantesque système de pipes à coup de générateurs, pluggable sur n’importe quel itérable.
Par exemple, vous voulez filtrer le contenu d’un fichier ?

>>> f = open('fichier_de_test.txt', encoding="ascii")
>>> lignes_non_vides = (line for line in f if line.strip())
>>> mot = "ni"
>>> lignes_qui_contiennent_un_mot = (l for l in lignes_non_vides if mot in l)
>>> lignes_qui_ne_finissent_pas_par_un_point = (l for l in lignes_qui_contiennent_un_mot if not l.endswith('.'))
>>> lignes_numerotees = enumerate(lignes_qui_ne_finissent_pas_par_un_point)

Afficher toutes les lignes non vides, du fichier fichier_de_test.txt, qui contiennent « ni » et numérotées:

>>> for numero, line in lignes_numerotees:
...     print("%s - %s" % (numero, line))
...
1 - Ni Dieu, ni maître
2 - Les chevaliers qui disent "ni"
3 - Con nichon ahhhhhhhh. Ca veut dire bonjour en japonais

À aucun moment l’intégralité du fichier n’est stocké en mémoire. Toutes les lignes sont traitées une par une.
Il y a encore énormément à dire sur les itérables mais je vais plutôt vous laisser digérer ce gros morceau.

Bonus 1: Les dictionnaires et les sets en intension

C’est le même principe pour les dictionnaires, on doit juste entourer de {} et séparer clé et valeur par :.

>>> {str(i): i for i in range(10)}
{'1': 1, '0': 0, '3': 3, '2': 2, '5': 5, '4': 4, '7': 7, '6': 6, '9': 9, '8': 8}

Idem pour les sets. Pour rappel, les sets sont des itérables non ordonnés, sans doublons :

>>> {1, 2, 3, 4, 4, 4, 4}
set([1, 2, 3, 4])

Ne confondez pas avec un dico : il n’y a que des valeurs, pas de clés.
On peut faire des sets en intension, la syntaxe ressemble à celle des dicos, mais sans le : :

>>> {i*i for i in range(10)}
set([0, 1, 4, 81, 64, 9, 16, 49, 25, 36])

Bonus 2: nested comprehension lists

Les listes en intension peuvent contenir des listes en intension de 2 manières.
La première est classique, et permet de créer des listes de listes (ou de générateurs):

>>> [[i*i for i in range(x)] for x in range(5)]
[[], [0], [0, 1], [0, 1, 4], [0, 1, 4, 9]]

La deuxième est beaucoup moins intuitive, permet de combiner plusieurs boucles. On peut par exemple aplatir une séquence de séquences. L’inverse en quelque sorte.
Ainsi, sous avez ceci:

[[], [0], [0, 1], [0, 1, 4], [0, 1, 4, 9]]

Vous voulez obtenir cela:

[0, 0, 1, 0, 1, 4, 0, 1, 4, 9]

Hop:

>>> liste_de_listes = [[], [0], [0, 1], [0, 1, 4], [0, 1, 4, 9]]
>>> [element_de_sousliste for sousliste in liste_de_listes for element_de_sousliste in sousliste]
[0, 0, 1, 0, 1, 4, 0, 1, 4, 9]

On peut aussi l’utiliser pour combiner des éléments de deux sources différentes.
Récupérer toutes les combinaisons de couleurs et formes :

>>> colors = ['red', 'green', 'yellow']
>>> forms = ['circle', 'square', 'triangle']
>>> [{'color': c, 'form': f} for c in colors for f in forms]
[{'color': 'red', 'form': 'circle'},
{'color': 'red', 'form': 'square'},
{'color': 'red', 'form': 'triangle'},
{'color': 'green', 'form': 'circle'},
{'color': 'green', 'form': 'square'},
{'color': 'green', 'form': 'triangle'},
{'color': 'yellow', 'form': 'circle'},
{'color': 'yellow', 'form': 'square'},
{'color': 'yellow', 'form': 'triangle'}]

La syntaxe n’est pas du tout intuitive, et pour se souvenir de l’ordre des choses, voici une astuce visuelle. Formez ainsi la liste dans votre esprit. Ceci:

[element_de_sousliste for sousliste in liste_de_listes for element_de_sousliste in sousliste]

Est en fait:

[element_de_sousliste # <= truc à mettre dans la nouvelle liste
    for sousliste in liste_de_listes # <= ordre normal d'une double boucle for
        for element_de_sousliste in sousliste]

Car une double boucle for serait ainsi faite:

nouvelle_liste = []
for sousliste in liste_de_listes:
    for element_de_sousliste in sousliste:
        nouvelle_liste.append(element_de_sousliste)

C’est bon, vous pouvez débranchez votre cerveau.