iteration – Sam & Max http://sametmax.com Du code, du cul Wed, 23 Dec 2020 13:35:02 +0000 en-US hourly 1 https://wordpress.org/?v=4.9.7 32490438 Un tools d’itertour, ou l’inverse http://sametmax.com/un-tools-ditertour-ou-linverse/ http://sametmax.com/un-tools-ditertour-ou-linverse/#comments Sat, 29 Oct 2016 20:26:22 +0000 http://sametmax.com/?p=20922 itertools. ]]> Ah, ça faisait longtemps que je ne vous avais pas fait un petit article bien long. Ca manquait d’opportunité de partager de la musique. Allez hop !

Python est un langage avec un “for” penchant pour l’itération si je puis dire. Et ce n’est que justice qu’il ait donc un module dédié pour les opérations d’itération : itertools.

itertools est un peu impénétrable pour quelqu’un qui n’a pas l’habitude, alors je vais vous faire un petit tour du propriétaire.

Principes de base

Itertools a pour but de manipuler les itérables, et si vous voulez en savoir plus sur le concept d’itérabilité, je vous renvoie à l’article sur les trucmuchables. Pour faire court, les itérables sont tout ce sur quoi on peut appliquer une boucle for.

Le problème, c’est que tous les itérables ne se comportent pas de la même façon. En effet, certains ont une taille définie:

>>> len("abcd")
4
>>> len(range(3))
3

D’autres non:

>>> len(open('/etc/fstab'))
Traceback (most recent call last):
  File "", line 1, in 
    len(open('/etc/fstab'))
TypeError: object of type '_io.TextIOWrapper' has no len()

Certains, se terminent:

>>> a = list(open('/etc/fstab'))
>>>

D’autres sont infinis:

>>> def infinite_generator():
...     while True:
...         yield 1
...
>>> list(infinite_generator) # bloque la VM jusqu'à crasher sur un MemoryError

Du coup, si vous voulez faire des opérations qui marchent sur tous les itérables, il faut écrire des algos spéciaux dit “paresseux” (en anglais, “lazy”), c’est à dire qui font le travail minimum. Ils se lancent à la dernière minute et ne lisent rien de plus que nécessaire.

Et c’est ce que contient itertools. Les fonctions de ce module marchent sur les fichiers, les générateurs, les sockets réseaux comme les listes, les tuples et les chaînes de caractères.

chain

Combine plusieurs itérables en un seul:

>>> from itertools import chain
>>> generator = chain("abc", range(3), [True, False, None])
>>> generator

>>> list(generator)
['a', 'b', 'c', 0, 1, 2, True, False, None]

Très utile pour boucler sur des itérables hétérogènes comme si c’était une seule et même structure. Chaîner des fichiers, des générateurs, etc.

islice

Comme la syntaxe de slicing [::]:

>>> carres_de_nombres_pairs = [x * x  for x in range(100) if x % 2 == 0]
>>> carres_de_nombres_pairs[3:11]
[36, 64, 100, 144, 196, 256, 324, 400]

Mais marche sur tous les itérables, même sur les générateurs:

>>> carres_de_nombres_pairs = (x * x  for x in range(100) if x % 2 == 0)
>>> carres_de_nombres_pairs
 at 0x7f2eab171a98>
>>> from itertools import islice
>>> generator = islice(carres_de_nombres_pairs, 3, 11)
>>> generator

>>> list(generator)
[36, 64, 100, 144, 196, 256, 324, 400]

cycle

Boucler de manière infinie sur un itérable. Quand on arrive à la fin, on revient au début.

>>> generator = cycle('abc')
>>> generator

>>> next(generator)
'a'
>>> next(generator)
'b'
>>> next(generator)
'c'
>>> next(generator)
'a'

C’est très pratique, mais assez dangereux donc faites des tests avant de l’utiliser. En effet si l’itérable génère les données à la volée, elles sont stockées dans un buffer, donc ça charge tout en mémoire. De plus, c’est une boucle infinie. Ne castez pas ça avec un tuple ou une liste :)

repeat

Prend n’importe quel élément, et le retourne encore et encore.

>>> generator = repeat("plop") # répète infiniment
>>> next(generator)
'plop'
>>> next(generator)
'plop'
>>> next(generator)
'plop'
>>> generator = repeat("plop", 2) # répète 2 fois
>>> for x in generator:
...     print(x)
...
plop
plop

On ne peut pas passer un callable, mais iter() le fait déjà. La signature normale de cette dernière prend un iterable, mais si on lui passe deux paramètres, un callable et un sentinel, on obtient un générateur qui va appeler le callable jusqu’à ce qu’il retourne une valeur égale au sentinel.

Exemple, vous voulez lire un fichier 50 caractères à la fois. On passe une fonction qui retourne les 50 caractères suivants (le callable) et une chaîne de caractères vide (le sentinel):

>>> f = open('/etc/fstab')
>>>
>>> def read_50_chars():
...     return f.read(50)
...
>>> generator = iter(read_50_chars, '') # lit 50 caractères à la fois jusqu'à tomber sur ''
>>> generator

>>> next(generator)
'# /etc/fstab: static file system information.\n#\n# '
>>> next(generator)
"Use 'blkid' to print the universally unique identi"
>>> next(generator)
'fier for a\n# device; this may be used with UUID= a'

iter() est un built-in, pas dans itertools, mais ça aurait été con de pas en parler.

zip_longest

Comme zip(), mais au lieu de s’arrêter quand le premier itérable est fini:

>>> list(zip('abc', range(5)))
[('a', 0), ('b', 1), ('c', 2)]

On remplit les valeurs manquantes:

>>> list(zip_longest('abc', range(5)))
[('a', 0), ('b', 1), ('c', 2), (None, 3), (None, 4)]
>>> list(zip_longest('abc', range(5), fillvalue="Tada !"))
[('a', 0), ('b', 1), ('c', 2), ('Tada !', 3), ('Tada !', 4)]

starmap

Comme map(), mais applique l’opérateur splat sur les arguments avant.

Si map() ressemble (en mieux) à:

def map_en_moins_bien(callable, iterable):
    for x in iterable:
       yield callable(x)

starmap() fait:

def map_en_moins_bien(callable, iterable):
    for x in iterable:
       yield callable(*x)

Exemple:

>>> nombres = [(1, 2), (3, 4), (5, 6)]
>>> def ajouter(a, b):
...     return a + b
...
>>> from itertools import starmap
>>> list(starmap(ajouter, nombres))
[3, 7, 11]

Ces fonctions sont moins utilisées à cause des listes en intension. Mais il y a des trucs rigolos comme:

>>> list(starmap(print, zip(range(3), map(int, "123"))))
0 1
1 2
2 3
[None, None, None]

Ouais, vous allez pas utiliser ça tous les jours :)

filterfalse

Le contraire de filter(), garde les résultats négatifs au lieu des résultats positifs:

>>> list(filter(est_pair, range(10)))
[0, 2, 4, 6, 8]
>>> from itertools import filterfalse
>>> list(filterfalse(est_pair, range(10)))
[1, 3, 5, 7, 9]

Comme précédemment, aujourd’hui on utilise peu ces fonctions car on a les listes en intension. Mais certaines astuces sont sympas comme:

>>> list(filter(bool, [True, False, 1, 0, 'foo', '']))
[True, 1, 'foo']
>>> list(filterfalse(bool, [True, False, 1, 0, 'foo', '']))
[False, 0, '']

groupby

Ah, groupby(), la fonction la moins facile à comprendre. D’abord, personne ne lit la doc, et la doc dit clairement qu’il faut trier les éléments de l’itérable AVANT d’appliquer groupby, sinon les résultats sont incohérents.

Ensuite, groupby() peut prendre un callback pour un usage avancé, et toutes les fonctions qui prennent un callback sont plus dures à comprendre. Mais pour que ça ait du sens, il faut que le tri préalable s’applique sur le même callback. Personne ne pige jamais ça.

Enfin, groupby() ne renvoie pas les éléments directement, mais des objets “groupers”, qui sont eux mêmes des générateurs. Tout est bien fait pour embrouiller le chaland, surtout pour une fonction qu’on utilise pas souvent.

Pour comprendre groupby(), le mieux est de commencer par l’entrée et la sortie. Vous avez un truc comme ça:

>>> noms = ['Zebulon', 'Léo', 'Alice', 'Bob', 'Anaïs', 'Adam', 'Bernardo', 'Io']

Et vous voulez les grouper par un critère, un peu comme un GROUP BY en SQL. Ca peut être n’importe quoi:

  • La valeur de la première lettre.
  • La position de la première voyelle.
  • Deux catégories: ceux qui contiennent des caractères non ASCII et les autres.

Prenons un exemple simple, et groupons les par le nombre de lettres qu’ils contiennent. Ca donnerait quelque chose comme ça:

[(2, ('Io',)),
 (3, ('Léo', 'Bob')),
 (4, ('Adam',)),
 (5, ('Alice', 'Anaïs')),
 (7, ('Zebulon',)),
 (8, ('Bernardo',))]

Pour obtenir ce résultat avec groupby(), il faut d’abord trier l’itérable (ici notre liste) par nombre de lettres:

>>> noms.sort(key=len)
>>> noms
['Io', 'Léo', 'Bob', 'Adam', 'Alice', 'Anaïs', 'Zebulon', 'Bernardo']

Si vous ne vous souvenez plus comment fonctionne le tri en Python, particulièrement le paramètre key, allez vite lire l’article dédié du lien précédent. En effet le mécanisme est le même pour groupby() donc il faut comprendre ce fonctionnement de toute manière.

Ensuite on applique groupby(), avec la même fonction key:

>>> noms = groupby(noms, key=len)
>>> noms

A ce stade, on a un générateur qu’on peut parcourir, mais il ne contient pas le résultat tel que je vous l’ai montré. A la place, il yield des paires (group, grouper):

>>> next(noms)
(2, )

Le premier élément, le groupe, est ce que votre fonction key retourne, c’est à dire ce sur quoi vous vouliez grouper. Ici la taille du mot.

En second, un objet grouper, qui est un générateur yieldant les mots qui correspondent à ce groupe.

Donc pour vraiment voir le contenu de tout ça, il faut se taper une double boucle imbriquée:

>>> from itertools import groupby
>>> for groupe, groupeur in groupby(noms, key=len):
...     print(groupe, ":")
...     for mot in groupeur:
...         print('-', mot)
2 :
- Io
3 :
- Léo
- Bob
4 :
- Adam
5 :
- Alice
- Anaïs
7 :
- Zebulon
8 :
- Bernardo

La raison de ce manque d’intuitivité, c’est que théoriquement groupby() peut travailler de manière paresseuse et donc les groupeurs sont des générateurs qui traitent l’arrivée des données au fur et à mesure. Mais c’est rare d’avoir des données lazy qui arrivent déjà pré-ordonnées. Ensuite ça suppose que vous voulez lire ces données au fur et à mesure, mais généralement le jeu de données groupées n’est pas suffisamment grand pour que ça soit un vrai problème et on caste directement le groupe en tuple ou en liste.

Dans la pratique donc, groupby() fait parti de ces fonctions un peu overkill, car un usage typique ne bénéficiera pas du côté paresseux. En tout cas ça ne m’est jamais arrivé.

dropwhile

dropwhile() prend un callable et un itérable. Le callable est appelé sur chaque élément, et tant qu’il retourne quelque chose de vrai, l’élément est ignoré. C’est un peu tordu car ça demande d’écrire sa fonction de filtre à l’inverse de ce qu’on veut.

Par exemple, si vous voulez commencer à lire un fichier à partir de la première ligne qui commence par un commentaire, il faut que votre fonction retourne True… si la ligne ligne ne commence PAS par un commentaire.

>>> def ne_commence_pas_par_un_commentaire(element):
...     return not element.startswith('#')
...
>>> generator = dropwhile(ne_commence_pas_par_un_commentaire, open('/etc/fstab'))
>>> generator

>>> next(generator)
'# /etc/fstab: static file system information.\n'

dropwhile veut dire “ignorer tant que”. Donc ici on ignore tant que la ligne ne commence pas par un commentaire.

takewhile

takewhile() est le contraire de dropwhile(), on prend les éléments tant qu’une condition est vraie, et on s’arrête dès que la condition n’est plus vraie. On les utilise souvent en combinaison d’ailleurs, pour slicer les itérables en utilisant des conditions plutôt que des indexes.

Comme pour dropwhile, il faut penser à l’envers. Vous voulez vous arrêter quand un nombre dépasse 10000 ? Alors il faut faire une fonction qui retourne True tant que le nombre ne dépasse PAS 10000. Jusqu’ici j’ai utilisé des fonctions traditionnelles, mais bien entendu, une lambda marche aussi:

>>> nombres = (x * x for x in range(1000000000000))
>>> from itertools import takewhile
>>> generator = takewhile(lambda x: x < 10000, nombres)
>>> next(generator)
0
>>> next(generator)
1
>>> next(generator)
4
>>> list(generator)[-1]
9801

count

count() est une relique du passé, qui fait ce que fait range() maintenant, mais en moins bien. Vous pouvez l’ignorer.

tee

tee() est une fonction très intéressante qui duplique votre itérable en autant de clones que vous le souhaitez. Pour ce faire, tee() garde en mémoire les derniers éléments générés, ce qui fait que si vous lisez les clones les uns après les autres, ça n’a aucun intérêt. Autant caster en liste et lire la liste plusieurs fois.

Non, l’interêt de tee() est si vous lisez les clones en parallèle. Car là, tee() ne garde en mémoire que les éléments qui n’ont pas été lus par le dernier clone utilisé.

Par exemple, ceci est inutile:

>>> from itertools import tee
>>> clone1, clone2, clone3 = tee((x for x in range(100)), 3)
>>> list(clone1)[:3]
[0, 1, 2]
>>> list(clone2)[:3]
[0, 1, 2]

Par contre ceci économise pas mal de mémoire:

>>> clone1, clone2, clone3 = tee((x for x in range(100)), 3)
>>> next(clone1)
0
>>> next(clone2)
0
>>> next(clone3)
0
>>> next(clone1)
1
>>> next(clone2)
1
>>> next(clone3) # la valeur 0 n'est plus en mémoire après ça
1

compress

compress() est une curiosité qui va surtout parler aux data-scientists et matheux, et d’une manière générale aux adeptes de numpy.

Cette fonction prend deux itérables, un avec des valeurs à filtrer, et un avec des valeurs qui indiquent s’il faut filtrer l’élément ou non:

>>> list(compress(['toto', 'tata', 'titi'], [True, False, True]))
['toto', 'titi']

Je n’ai pas trouvé de use case pour cette fonction dans ma vie de tous les jours. Si quelqu’un est inspiré en commentaire, je mettrai l’article à jour.

accumulate

accumulate() est un pur produit de la programmation fonctionnelle. C’est un peu comme un map(), mais au lieu de passer chaque élément au callable, on lui passe l’élément, et le résultat du dernier appel.

Par exemple, vous voulez multiplier tout une liste : [1, 2, 3, 4]

Vous allez faire:

(((1 * 2) * 3) * 4)

C’est typiquement pour ce genre de chose qu’on utiliserait la fonction reduce():

>>> reduce(lambda x, y: x * y, [1, 2, 3, 4])
24

accumulate() fait cela, mais en plus vous yield tous les résultats intermédiaires:

>>> list(accumulate([1, 2, 3, 4], lambda x, y: x * y))
[1, 2, 6, 24]

La signature est inversée par rapport à reduce(), c’est ballot. Dans d’autres langages vous trouverez ce genre de fonction sous le nom fold() ou scan(), mais c’est le même principe.

product

product() prend des itérables, et vous balance toutes les combinaisons des éléments de ces itérables. On s’en sert surtout pour éviter les boucles for imbriquées. Par exemple au lieu de faire:

>>> lettres = "abcd"
>>> chiffres = range(3)
>>> for lettre in lettres:
...     for chiffre in chiffres:
...         print(lettre, chiffre)
...
a 0
a 1
a 2
b 0
b 1
b 2
c 0
c 1
c 2
d 0
d 1
d 2

On va faire:

>>> from itertools import product
>>> for lettre, chiffre in product(lettres, chiffres):
...     print(lettre, chiffre)
...
a 0
a 1
a 2
b 0
b 1
b 2
c 0
c 1
c 2
d 0
d 1
d 2

combinations

Génère tous les combinaisons possibles des éléments d’un itérable:

>>> from itertools import combinations
>>> list(combinations('abcd', 2))
[('a', 'b'), ('a', 'c'), ('a', 'd'), ('b', 'c'), ('b', 'd'), ('c', 'd')]
>>> list(combinations('abcd', 3))
[('a', 'b', 'c'), ('a', 'b', 'd'), ('a', 'c', 'd'), ('b', 'c', 'd')]

combinations_with_replacement

Pareil, mais autorise les doublons:

>>> from itertools import combinations_with_replacement
>>> list(combinations_with_replacement('abcd', 3))
[('a', 'a', 'a'), ('a', 'a', 'b'), ('a', 'a', 'c'), ('a', 'a', 'd'), ('a', 'b', 'b'), ('a', 'b', 'c'), ('a', 'b', 'd'), ('a', 'c', 'c'), ('a', 'c', 'd'), ('a', 'd', 'd'), ('b', 'b', 'b'), ('b', 'b', 'c'), ('b', 'b', 'd'), ('b', 'c', 'c'), ('b', 'c', 'd'), ('b', 'd', 'd'), ('c', 'c', 'c'), ('c', 'c', 'd'), ('c', 'd', 'd'), ('d', 'd', 'd')]

permutations

Comme combinations, mais en générant aussi les mêmes combinaisons dans un ordre différent:

>>> list(permutations('abc', 3))
[('a', 'b', 'c'), ('a', 'c', 'b'), ('b', 'a', 'c'), ('b', 'c', 'a'), ('c', 'a', 'b'), ('c', 'b', 'a')]
]]>
http://sametmax.com/un-tools-ditertour-ou-linverse/feed/ 21 20922
itertools.product fait sauter des boucles imbriquées http://sametmax.com/itertools-product-fait-sauter-des-boucles-imbriquees/ http://sametmax.com/itertools-product-fait-sauter-des-boucles-imbriquees/#comments Tue, 03 Nov 2015 06:52:45 +0000 http://sametmax.com/?p=16986 product() depuis bel lurette, et je n'avais jamais réalisé son utilité. Des fois on a le truc sous les yeux, comme ça, et on voit rien. ]]> Je connais product() depuis bel lurette, et je n’avais jamais réalisé son utilité. Des fois on a le truc sous les yeux, comme ça, et on voit rien.

Vous savez, on veut parfois parcourir tous les trucs, et leur appliquer tous les machins. Ca donne une boucle imbriquée :

res = []
for truc in trucs:
    for machin in machins:
        res.append(bidule(machin, truc))

Un code parfaitement légitime, clair, lisible. Mais l’envie de faire une liste en intension est si forte !

C’est là que product() intervient, avec ses petits bras musclés :

from itertools import product
res  = [ bidule(machin, truc) for truc, machin in product(trucs, machins)]

Python va magiquement créer autant de tuples (machin, truc) qu’il y en a besoin.

Et parce que ça fait un mois que j’ai pas mis d’article, faut prendre tout de suite les bonnes habitudes :

Hommes nus assis sur un homme nus

for but in buts

]]>
http://sametmax.com/itertools-product-fait-sauter-des-boucles-imbriquees/feed/ 13 16986
Une boucle while de moins http://sametmax.com/une-boucle-while-de-moins/ http://sametmax.com/une-boucle-while-de-moins/#comments Tue, 23 Jun 2015 10:04:55 +0000 http://sametmax.com/?p=16427 Si vous devez retenir un truc de la partie Python de ce blog, c’est qu’en Python, l’itération est tout.

Du coup, on utilise pas beaucoup while, à part dans quelques cas particuliers.

Le cas d’école, c’est la lecture d’un fichier octet par octet.

Imaginez, vous créez un petit array de float écrits en 64 bits :

>>> import numpy as np
>>> a = np.sin(np.linspace(2.0, 3.0, num=100))
>>> a.dtype
dtype('float64')

Vous sauvegardez tout ça dans un fichier :

>>> a.tofile('/tmp/data')

Si vous voulez lire le fichier hors de numpy, il faut le charger float par float, donc le lire 64 bits par 64 bits soit par groupes de 8 octets.

La méthode canonique :

with open('/tmp/data', 'rb') as f:
    while True:
        nombre = f.read(8)
        if not nombre:
            break
        # faire un truc avec le nombre

Mais il existe une autre manière de faire cela, moins connue : utiliser iter().

with open('/tmp/data', 'rb') as f:
    for nombre in iter(lambda: f.read(8), b''):
        # faire un truc avec nombre

Cela marche car iter(), parmi ses nombreuses fonctionnalités, accepte un callable en paramètre (ici notre lambda), et va l’appeler jusqu’à ce que celui-ci retourne une valeur dite “sentinelle” (ici notre second paramètre).

]]>
http://sametmax.com/une-boucle-while-de-moins/feed/ 5 16427
Les exceptions sont itérables http://sametmax.com/les-exceptions-sont-iterables/ http://sametmax.com/les-exceptions-sont-iterables/#comments Sun, 02 Mar 2014 23:49:55 +0000 http://sametmax.com/?p=9656 Ok, c’est une bizarrerie, mais je tenais à la partager car ça m’a bien niqué : une exception est itérable en Python

>>> for x in Exception('Doh !'):
...     print x
...
Doh !

Pourquoi ? J’en ai aucune idée. Mais j’ai voulu faire un algo récursif sur des structures imbriquées contenant des exceptions. Et ça buggait. Parce que ça parcourait aussi les exceptions.

Le piège vicieux dans lequel peu de gens risquent de tomber vu qu’on programme pas ça tous les jours. Mais tout de même, une étrangetée de Python. Peut être un héritage de l’époque où on pouvait faire un raise sur une string.

]]>
http://sametmax.com/les-exceptions-sont-iterables/feed/ 6 9656
Parcourir un itérable par morceaux en Python http://sametmax.com/parcourir-un-iterable-par-morceaux-en-python/ http://sametmax.com/parcourir-un-iterable-par-morceaux-en-python/#comments Wed, 30 May 2012 17:09:24 +0000 http://sametmax.com/?p=809 Cet article a été mis à jour et contient maintenant du code Python en version 3

Parcourir un itérable par morceaux signifie qu’on le traite petit bout par petit bout. On parle aussi de fenêtre glissante.

Par exemple, on a une liste, et on veut traiter les éléments par lot de 3 :

>>> ["a", "b", "c", "d", "e", "f", "g"]
["a", "b", "c", "d", "e", "f", "g"]
>>> ("a", "b", "c")
("a", "b", "c")
>>> ("d", "e", "f")
("d", "e", "f")
>>> ("g",)
("g",)
>>> 

Pour les gens pressés

Il n’y a pas de fonction dans la bibliothèque standard de Python qui permette de le faire, mais on peut créer une très jolie solution à base de générateurs :

from itertools import chain, islice

def morceaux(iterable, taille, format=tuple):
    it = iter(iterable)
    while True:
        yield format(chain((next(it),), islice(it, taille - 1)))

Et on l’utilise ainsi :

>>> l = ["a", "b", "c", "d", "e", "f", "g"]
>>> for morceau in morceaux(l, 3):
...         print(morceau)
...
("a", "b", "c")
("d", "e", "f")
("g",)

Usage avancé

Grâce au troisième paramètre, on peut récupérer une sortie sous un format différent, par exemple une chaine de caractères :

>>> for morceau in morceaux(l, 3, ''.join):
        print(morceau)
...
abc
def
g

Le résultat retourné par morceaux() est un générateur :

>>> morceaux(l, 3)

>>> list(morceaux(l, 3))
[('a', 'b', 'c'), ('d', 'e', 'f'), ('g',)]

Et la fonction accepte n’importe quel itérable en paramètre, pas uniquement des listes. Par exemple une chaine de caractères :

>>> list(morceaux('123456789', 3, tuple))
[('1', '2', '3'), ('4', '5', '6'), ('7', '8', '9')]

Ou un fichier :

>>> f = open('/tmp/test', 'w', encoding='ascii')
>>> f.write('1\n2\n3\n4\n5\n6\n7\n8\n9\n10')
>>> f.close()
>>> list(morceaux(open('/tmp/test'), 3))
[('1\n', '2\n', '3\n'), ('4\n', '5\n', '6\n'), ('7\n', '8\n', '9\n'), ('10',)]

Comment ça marche ?

Cette fonction est un concentré d’astuces propres à Python : module itertools, mot-clé yield, iterable, passage de fonction en paramètre…

D’abord, on utilise la fonction built-in iter() :

it = iter(iterable)

Sur l’itérable passé en paramètre. Elle retourne un itérateur, c’est à dire un objet qui peut être passé à la fonction next(). next() permet d’accéder à la prochaine valeur de l’itérable jusqu’à épuisement de ce dernier :

>>> l = (1, 2, 3, 4, 5)
>>> iterateur = iter(l)
>>> next(iterateur)
1
>>> next(iterateur)
2
>>> next(iterateur)
3
>>> next(iterateur)
4
>>> next(iterateur)
5
>>> next(iterateur)
Traceback (most recent call last):
  File "", line 1, in 
    next(iterateur)
StopIteration

On crée ensuite une boucle infinie :

while True:

Cela permet de ne pas se soucier de la taille de l’itérable. Quand un itérateur arrive au bout d’un itérable, il lève l’exception StopIteration (c’est le mécanisme standard par lequel les boucles for itèrent, ce qui prouve une fois de plus que les exceptions s’utilisent partout en Python, et non juste dans la gestion des erreurs). Cette exception cassera la boucle infinie au moment opportun.

Ensuite on utilise deux fonctions du module itertools (un module spécialisé dans la manipulation des itérables) :

chain((next(it),), islice(it, taille - 1))

chain() prend deux itérables et retourne un générateur qui permet d’itérer sur l’un puis l’autre. Par exemple :

>>> l1 = (1, 2, 3)
>>> l2 = (4, 5, 6)
>>> chain(l1, l2)

>>> list(chain(l1, l2))
[1, 2, 3, 4, 5, 6]

islice() est comme la syntaxe [:], mais applicable à un itérable dont on ne connait pas la taille :

>>> l3 = (1, 2, 3, 4, 5, 6)
>>> islice(l3, 2)

>>> list(islice(l3, 2))
[1, 2]
>>> list(islice(l3, 2, 4))
[3, 4]

chain((next(it),), islice(it, taille - 1)) signifie donc “chainer un tuple qui contient la valeur suivante de l’itérable avec un autre itérable qui lui récupèrera la slice du reste du morceau extrait de l’itérable“.

Ouf.

Puis on applique :

format(...)

Sur le résultat. Ce qui permet de choisir dans quel format on souhaite avoir les morceaux (chaine, tuple, etc). Le comportement par défaut est de retourner des tuples.

Et enfin on utilise yield :

yield format(...)

Cela assure que morceaux() retourne un générateur qui créera chaque morceau à la volée, et non tout d’un coup en mémoire. Très utile si vous avez une grande liste :

>>> une_grande_liste = range(100000000)
>>> par_morceaux = morceaux(une_grande_liste, 3)
>>> next(par_morceaux)
(0, 1, 2)
>>> next(par_morceaux)
(3, 4, 5)
>>> next(par_morceaux)
(6, 7, 8)
>>> next(par_morceaux)
(9, 10, 11)

Malgré la taille de la liste, le résultat est presque instantané. Ce n’est pas parce que votre ordinateur est une bête de course. C’est parce que (3, 4, 5) n’est pas généré juste après (0, 1, 2). Il est généré au second appel de next(), à la volée.

Mais pourquoi faire si compliqué ?

Les bénéfices de cette approche sont :

  • La possibilité de parcourir par morceaux n’importe quel itérable : des chaines, des tuples, des listes, des dictionnaires, des querysets Django, des fichiers, etc.
  • La fonction accepte un itérable de n’importe quelle taille. En fait, elle accepte même un itérable de taille inconnu, par exemple un flux de données issu d’internet.
  • Cela consomme très peu de mémoire : on utilise des générateurs partout (merci à yield et au module itertools), donc toutes les valeurs sont générées uniquement quand on les demande, et jamais stockées pour rien.
  • Et d’ailleurs, comme on retourne un générateur, on peut passer le résultat à un processus utilisant des générateurs sans casser son empreinte mémoire. Faire des pipes d’un générateur à un autre est en effet un point fort de Python.
  • Mais comme le générateur est itérable, toute fonction qui accepte un itérable pourra utiliser le résultat : les fonctions de tri, les fonctions max, etc. La boucle for et les listes en intension l’acceptent aussi.
  • Le troisième paramètre garantit que chaque morceau est dans le format que l’on souhaite, pas quelque chose de figé qu’on devra caster : c’est un bonheur pour les one-liners.

C’est une solution d’une rare élégance car elle tient en quelques lignes et assure une gigantesque flexibilité, sans manger beaucoup de mémoire vive. Le seul défaut étant que la lecture enclenche le mécanisme de génération, qui est plus lent que de lire une structure de données, donc la lecture complète de toute la séquence est globalement plus lente que dans la plupart des autres approches.

Dans 90 % des cas, vous pouvez utiliser morceaux() sans souffrir de sa vitesse. Ceux qui bossent sur les 10 % restant ont un niveau tel qu’ils savent avec certitude s’ils peuvent utiliser cette approche ou non, donc si vous vous posez la question, c’est que vous n’en faites pas partie.

]]>
http://sametmax.com/parcourir-un-iterable-par-morceaux-en-python/feed/ 21 809