Iterators, map, reduce

De très nombreux algorithmes consistent à appliquer un traitement à une séquence de données. Les langages de programmation modernes proposent des itérateurs afin d’écrire le parcours des collections de données de manière concise et efficace (en temps comme en mémoire).

Ces itérateurs permettent d’abstraire l’implémentation de la séquence (liste, chaîne de caractères, ensemble, etc). Un même parcours pourra donc s’appliquer à différentes structures de données. Par exemple:

def print_all(s):
    for x in s:
        print(x)

print_all(range(1, 10, 2))
print_all([0,1,2,3,4])
print_all({5,6,7,8,9})
print_all('abcdef')

Par ailleurs, un itérateur permet de parcourir une séquence sans nécessairement la stocker entièrement en mémoire. Il est ainsi possible de parcourir des collections qui ne peuvent pas être stockées dans la mémoire disponible (par exemple, de très gros fichiers).

Parcourir une séquence

Intervalle d’entiers

On considère les deux programmes Python suivants, iter_list.py:

x = -1
for i in list(range(100000000)):
    if i == x:
        print("Trouvé")
        break

et iter_range.py:

x = -1
for i in range(100000000):
    if i == x:
        print("Trouvé")
        break

Lancer successivement les deux scripts dans un terminal, en utilisant la commande shell time pour observer leurs temps d’exécution, par exemple:

$ time ./iter_list.py
./iter_list.py  4.15s user 1.15s system 92% cpu 5.727 total
$ time ./iter_range.py
./iter_range.py  3.81s user 0.04s system 99% cpu 3.854 total

On observe une grande variabilité sur les temps d’exécution. En moyennant sur plusieurs exécutions, on observe que les temps d’exécution de ./iter_range.py et ./iter_list.py sont proches.

Ouvrir deux terminaux à l’écran. Lancer la commande top dans le premier, et le script iter_list.py dans le second. Estimer la quantité de mémoire consommée par le script iter_list.py. Procéder de même pour iter_range.py. Que pouvez-vous en déduire?

Un fichier

Obtenir le fichier dp.txt à l’aide de la commande suivante:

$ curl -fL https://raw.githubusercontent.com/kkrypt0nn/wordlists/refs/heads/main/wordlists/passwords/dutch_passwords.txt -o db.txt

ATTENTION: pensez à effacer le fichier db.txt à la fin du TP car celui-ci fait plus de 40MB.

Les deux programmes ci-dessous parcourent entièrement le fichier db.txt.

  • iter_read.py
import time

secret = ''
f = open("db.txt")
lines = f.read().split("\n")
for word in lines:
    if word == secret:
        print("Trouvé!")
        break
time.sleep(5)
f.close()
  • iter_readline.py
import time

secret = ''
f = open("db.txt")
while True:
    line = f.readline()
    if line == "": # fin de fichier
        break
    word = line.strip()
    if word == secret:
        print("Trouvé")
        break
time.sleep(5)
f.close()

Comparer la quantité de mémoire utilisée par chacun des deux programmes à l’aide de l’utilitaire top. Que stocke chacun des deux scripts en mémoire?

Créer des itérateurs

Générateurs: (x for x in S if p)

Combien de listes sont créées en mémoire par chacun des code suivants:

A = [2**i for i in range(100) if i % 3 == 0]
sum(A)
B = (2**i for i in range(100) if i % 3 == 0)
sum(B)

Quel est le type de B? L’objet B peut-il être indexé ou slicé? Quelles sont les opérations possibles sur B (voir help(B))?

Fonctions génératrices: yield

Le mot clé yield est utilisé à la place de return dans une fonction pour que celle-ci génère une séquence de valeurs plutôt qu’une seule valeur.

def gennumbers(n):
    for i in range(n):
        if i % 3 == 0:
            yield 2**i

for x in gennumbers(100):
    print(x)

sum(gennumbers(100))

Combien de fois la fonction gennumbers est-elle appelée? Comment la boucle sur x s’exécute-t-elle?

On souhaite pouvoir parcourir un fichier ligne à ligne, en utilisant un générateur, et sans charger le fichier entièrement en mémoire. Implémenter al fonction lines ci-dessous comme un générateur qui énumère les lignes du fichier f.

def lines(f):
    pass

if __name__ == '__main__':
    secret = 'thisisnotapassword'
    with open('db.txt') as f:
        for line in lines(f):
            if line == secret:
                print('Trouvé')
                break
Solution
def lines(f):
    line = f.readline()
    while (line != ''):
        yield line.strip()
        line = f.readline()

Écrire une fonction iter_fibonacci(n) qui génère succésivement les n premiers termes de la suite de Fibonacci sous la forme d’une séquence itérable. Afficher les 10 premiers termes de la suite en utilisant la fonction print_all ci-dessus.

Solution
def iter_fibonacci(n):
    u0 = 0
    u1 = 1
    yield u0
    yield u1
    for i in range(n-2):
        u2 = u0 + u1
        yield u2
        u0 = u1
        u1 = u2

Définir une fonction iter_IP qui génère la séquence itérable des adresses IPv4. Chaque adresse sera retournée sous la forme d’un tuple de 4 valeurs.

Solution
def iter_IP():
    for a in range(256):
        for b in range(256):
            for c in range(256):
                for d in range(256):
                    yield (a, b, c, d)

Utiliser la fonction islice du module itertools (cf. documentation) pour afficher certaines des valeurs, par exemple:

>>> import itertools as it
>>> for ip in it.islice(iter_IP(), 20, 30):
...     print(ip)
...     
(0, 0, 0, 20)
(0, 0, 0, 21)
(0, 0, 0, 22)
(0, 0, 0, 23)
(0, 0, 0, 24)
(0, 0, 0, 25)
(0, 0, 0, 26)
(0, 0, 0, 27)
(0, 0, 0, 28)
(0, 0, 0, 29)

Objets itérateurs (avancé)

Enfin, il est possible de définir un itérateur qui est un objet, qui implémente une certaine interface permettant d’énumérer les éléments d’une séquence.

La classe interval ci-dessous représente l’intervalle $[first, last]$. Son constructeur __init__ permet d’initialiser les deux bornes de l’intervalle.

class interval:
    first = 0
    last = 0

    def __init__(self, first, last):
        self.first = first
        self.last = last

L’objectif est d’implémenter un itérateur pour la classe interval qui permet d’énumérer un à un les entiers compris entre first et last. Pour cela, vous pourrez vous appuyer sur la documentation du type itérateur. On attend le résultat suivant:

>>> for i in interval(2, 7):
...     print(i)
...     
2
3
4
5
6
7
>>> for i in interval(7, 7):
...     print(i)
...     
7
>>> for i in interval(7, 5):
...     print(i)
...     
>>> 

Combiner des itérateurs ou générateurs

Le module itertools (cf. documentation) définit plusieurs fonctions utilitaires sur les itérateurs et générateurs. Les exercices ci-dessous ont pour objectif d’en programmer certains.

Composition séquentielle

Compléter la fonction iter_sequence ci-dessous qui énumère les éléments de la séquence s1, suivis de ceux de la séquence s2.

def iter_sequence(s1, s2):
    pass
Solution
def iter_sequence(s1, s2):
    for x in s1:
        yield x
    for y in s2:
        yield y

Tester votre fonction en lui passant différents conteneurs itérables (list, set, str, tuple, etc) ainsi que des fonctions génératrices:

>>> for i in iter_sequence(range(3), range(7)):
...     print(i)
...     
0
1
2
0
1
2
3
4
5
6
>>> for i in iter_sequence(range(2,6), ['a','b','c']):
...     print(i)
...     
2
3
4
5
a
b
c

Produit cartésien

Compléter la fonction iter_cartesian ci-dessous qui énumère les tuples de valeurs du produit cartésien s1 * s2.

def iter_cartesian(s1, s2):
    pass
Solution
def iter_cartesian(s1, s2):
    for x in s1:
        for y in s2:
            yield (x, y)

Tester votre fonction en lui passant différents conteneurs itérables (list, set, str, tuple, etc) ainsi que des fonctions génératrices:

>>> for x in iter_cartesian([1,2,3], {'a', 'b'}):
...     print(x)
...     
(1, 'a')
(1, 'b')
(2, 'a')
(2, 'b')
(3, 'a')
(3, 'b')

Extension à un nombre quelconque de séquences (avancé)

En utilisant les listes d’arguments et les fonctions iter_sequence et iter_cartesian ci-dessus, programmer deux fonctions qui composent un nombre quelconque de sequences, l’une séquentiellement, l’autre comme un produit cartésien.

Écrire une nouvelle version de la fonction iter_IP utilisant le produit cartesian généralisé.

Manipuler les séquences

La bibliothèque standard Python, ainsi que le module itertools fournissent des fonctions permettant de manipuler les séquences.

Énumérer les éléments d’une séquence avec leur indice: enumerate

La fonction enumerate permet d’énumérer les éléments d’une séquence, associés à leur indice dans la séquence:

>>> for i, x in enumerate(['a', 'b', 'c']):
...     print(f"{x} à l'indice {i}")
...     
a à l'indice 0
b à l'indice 1
c à l'indice 2

Écrire une fonction find(x, s) qui cherche un élément x dans une séquence s et retourne son indice si x est dans s et None sinon.

Solution
def find(x, s):
    for i, y in enumerate(s):
        if y == x:
            return i
    return None

Programmer une fonction myenumerate qui se comporte comme la fonction enumerate.

Solution
def myenumerate(s):
    i = 0
    for x in s:
        yield (i, x)
        i += 1

Appliquer une transformation à une séquence avec map

La fonction map (cf. help(map)) applique une fonction à chaque élément d’une séquence, et fournit la séquence transformée. Par exemple:

def double(x):
    return 2*x

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

for n in map(double, l):
    print(n)

Quel est le type de map(double, l)? Qu’est-ce qui est stocké en mémoire?

La fonction apliquée à l peut être définie directement à l’appel de map sous la forme d’une fonction anonyme (cf. lambdas):

for n in map(lambda x : 2*x, l):
    print(n)

Écrire une fonction to_int qui prend en paramètre une séquence s de chaînes de caractères représentant des nombres entiers (ex: '12', '-218', '348') et qui retourne la séquence des entiers correspondants (ex: 12, -218, 348).

Solution
def to_int(s):
    return map(int, s)

Proposer une fonction mymap qui se comporte comme la fonction map.

Solution
def mymap(f, s):
    for x in s:
        yield f(x)

Aggréger un calcul sur une séquence avec reduce

La fonction reduce du module functools (cf. help(functools.reduce)) permet d’aggréger les résultats d’un calcul sur une séquence.

>>> import functools as ft
>>> ft.reduce(lambda x,y: x+y, [1,2,3,4], 0)
10

À quoi correspondent chacun des paramètres de reduce?

Comment calculer le produit des éléments d’une liste plutôt que la somme?

Solution
ft.reduce(lambda x,y: x*y, [1,2,3,4], 1)

Proposer une implémentation de la fonction myreduce qui se comporte comme la fonction reduce ci-dessus.

Solution
def myreduce(f, s, i):
    current = i
    for x in s:
        current = f(current, x)
    return current

Filtrer les éléments d’une séquence avec filter

La fonction standard filter (cf. help(filter)) génère la sous-séquence des éléments d’une séquence donnée, qui satisfont un certain prédicat.

>>> print_all(filter(lambda x : x%2 == 0, [1,2,3,4,5,6,7]))
2
4
6

Écrire une fonction included qui prend deux séquences s1 et s2 en paramètres et qui génère la sous-séquence des éléments de s1 qui appartiennent à s2.

Solution
def included(s1, s2):
    return filter(lambda x: x in s2, s1)

Écrire une fonction myfilter qui se comporte comme la fonction standard filter.

Solution
def myfilter(f, s):
    for x in s:
        if f(x):
            yield x

Histogramme

On veut créer un histogramme du nombre de mots de chaque longueur dans une séquence de chaînes de caractères.

En utilisant map et reduce, programmer la fonction histogramme qui retourne le dictionnaire associant à chaque longueur le nombre des mots dans la séquence s qui ont cette longueur.

def histogramme(s):
    pass

Compter le nombre de mots de 1 à 5 caractères (inclus), de 6 à 10 caractères (inclus), de 11 à 15 caractères (inclus) et de plus de 15 caractères présents dans la séquence de caractères s. Le résultat sera renvoyé sous la forme d’un tuple de 4 entiers.