TD4 : Programmation fonctionnelle

Dans ce TD, nous allons explorer les outils puissants tels que map, filter, iterators, generators et decorators (ce qui font partie de la programmation fonctionnelle) en Python.

Outils fonctionnels

Lambdas

Rappelons que les fonctions lambda sont des objets de fonction anonymes, sans nom, créés à la volée, généralement pour accomplir une petite transformation. Par exemple, les fonctions lambda Considérons la fonction suivante :

(lambda val: val ** 2)(5)  # => 25
(lambda x, y: x * y)(3, 8)  # => 24
(lambda s: s.strip().lower()[:2])('  PyTHon')  # => 'py'

En soi, les lambdas ne sont pas particulièrement utiles, comme démontré ci-dessus. Habituellement, les lambdas sont utilisés pour éviter de créer une définition de fonction formelle pour de petites fonctions jetables, non seulement parce qu'elles impliquent moins de codes (pas besoin de déclaration def ou return) mais aussi, et peut-être plus important, parce que ces petites fonctions ne pollueront pas l'espace de noms environnant.

Les lambdas sont aussi fréquemment utilisés comme arguments ou pour retourner des valeurs de fonctions d'ordre supérieur, comme map et filter.

Map

Rappelez-vous de la classe que map(func, iterable) applique une fonction sur les éléments d'un iterable.

Pour chacune des lignes suivantes, écrivez une instruction en utilisant map qui convertit la colonne de gauche en colonne de droite :

Entrées Sorties
['12', '-2', '0'] [12, -2, 0]
['hello', 'world'] [5, 5]
['hello', 'world'] ['olleh', 'dlrow']
range(2, 6) [(2, 4, 8), (3, 9, 27), (4, 16, 64), (5, 25, 125)]
zip(range(2, 5), range(3, 9, 2)) [6, 15, 28]

Conseil : vous pouvez avoir besoin d'envelopper la sortie dans un constructeur list() pour la voir affichée - c'est-à-dire, list(map(..., ...))

Utilisation d'Itérables multiples

La fonction map peut accepter un nombre variable d'itérables comme arguments. Ainsi, map(func, iterA, iterB, iterC) est équivalent à map(func, zip(iterA, iterB, iterC)). Cela peut être utilisé comme suit :

map(int, ('10110', '0xCAFE', '42'), (2, 16, 10))  # generates 22, 51966, 42

Cela fonctionne parce que int prend un second argument optionnel spécifiant la base de conversion

Filter

Rappelez-vous que filter(pred, iterable) ne conserve que les éléments d'un iterable qui satisfont une fonction de prédicat.

Écrivez des instructions en utilisant filter qui convertissent les séquences suivantes de la colonne de gauche à la colonne de droite :

Entrées Sorties
['12', '-2', '0'] ['12', '0']
['hello', 'world'] ['world']
['Stanford', 'Cal', 'UCLA'] ['Stanford']
range(20) [0, 3, 5, 6, 9, 10, 12, 15, 18]

Itérateurs

Le rappel de classe qu'un itérateur est un objet qui représente un flux de données renvoyé une valeur à la fois.

Consommation d'itérateur

Supposons que les deux lignes de code suivantes aient été exécutées :

it = iter(range(100))
67 in it  # => True

Quel est le résultat de chacune des lignes de code suivantes ?

next(it)  # => ??
37 in it  # => ??
next(it)  # => ??

Module : itertools

Python est équipé d'un module spectaculaire pour la manipulation d'itérateurs appelés itertools. Prenez un moment pour lire la page de documentation des itertools.

Prévoyez la sortie des morceaux de code suivants :

import itertools
import operator

for el in itertools.permutations('XKCD', 2):
    print(el, end=', ')

for el in itertools.cycle('LO'):
    print(el, end='')  # N'execute pas, pourquoi?

itertools.starmap(operator.mul, itertools.zip_longest([3,5,7],[2,3], fillvalue=1))

Expressions du générateur

Rappelons que les expressions génératrices sont un moyen de calculer paresseusement des valeurs à la volée, sans mettre en mémoire tampon tout le contenu de la liste en place.

Pour chacun des scénarios suivants, voyez s'il serait plus approprié d'utiliser une expression génératrice ou une compréhension de liste :

  1. Recherche d'une entité donnée dans les entrées d'une base de données de 1 To.
  2. Calculer un tarif aérien bon marché en utilisant de nombreuses informations sur les vols de voyage à destination.
  3. Trouver le premier nombre de Fibonacci palindromique supérieur à 1.000.000.
  4. Déterminer tous les anagrammes multimots de chaînes de 1 000 caractères ou plus fournies par l'utilisateur (très coûteux à réaliser).
  5. Générer une liste de noms d'étudiants d'Evry dont le numéro d'étudiant est inférieur à 2019.
  6. Renvoyer une liste de toutes les start-ups situées dans un rayon de 80 km autour d'Evry.

Générateurs

Ecrire un générateur infini qui donne successivement les nombres du triangle 0, 1, 3, 6, 10, ....

def generer_triangles():
    pass  # Votre code ici

Utilisez votre générateur pour écrire une fonction triangles_inf(n) qui affiche tous les nombres de triangles strictement inférieurs au paramètre n.

Fonctions dans les structures de données

Qu'est-ce fait le code suivant? Est-ell efficace?

def primes_under(n):
    tests = []
    for i in range(2, n):
        if not any(map(lambda test: test(i), tests)):
            tests.append(make_divisibility_test(i))
            yield i

Comment modifieriez-vous le code ci-dessus pour obtenir tous les nombres composés, plutôt que tous les nombres premiers ? Testez votre solution. Qu'est-ce que le 1000e nombre composite ?

Fonctions imbriquées et fermetures

En classe, nous avons vu que les fonctions peuvent être définies dans le cadre d'une autre fonction (rappel de la semaine précédente que les fonctions introduisent de nouvelles portées via un nouveau tableau de symboles locaux). Une fonction interne n'a de portée qu'à l'intérieur de la fonction externe, donc ce type de définition de fonction n'est généralement utilisé que lorsque la fonction interne est renvoyée au monde extérieur.

def outer():
    def inner(a):
        return a
    return inner

f = outer()
print(f)  # <function outer.<locals>.inner at 0x1044b61e0>
f(10)  # => 10

f2 = outer()
print(f2)  # <function outer.<locals>.inner at 0x1044b6268> (Différent au précédent)
f2(11)  # => 11

Pourquoi les adresses mémoire sont-elles différentes pour f et f2 ?

Fermeture

Comme nous l'avons vu plus haut, la définition de la fonction interne intervient pendant l'exécution de la fonction externe. Cela implique qu'une fonction imbriquée a accès à l'environnement dans lequel elle a été définie. Par conséquent, il est possible de renvoyer une fonction interne qui se souvient de l'état de la fonction externe, même après que la fonction externe a terminé son exécution. Ce modèle est appelé une fermeture.

def make_adder(n):
    def add_n(m):  # Capturer la variable externe n dans une fermeture
        return m + n
    return add_n

add1 = make_adder(1)
print(add1)  # <function make_adder.<locals>.add_n at 0x103edf8c8>
add1(4)  # => 4
add1(5)  # => 6
add2 = make_adder(2)
print(add2)  # <function make_adder.<locals>.add_n at 0x103ecbf28>
add2(4)  # => 6
add2(5)  # => 7

Les informations contenues dans une fermeture sont disponibles dans l'attribut __closure__ de la fonction. Par exemple :

closure = add1.__closure__
cell0 = closure[0]
cell0.cell_contents  # => 1 (ici n = 1 est passé dans make_adder)

Autre exemple :

def foo(a, b, c=-1, *d, e=-2, f=-3, **g):
    def wraps():
        print(a, c, e, g)        

L'appel print induit une fermeture des wraps sur a, c, e, g du champ d'application de foo. Ou bien, vous pouvez imaginer que wraps "sait" qu'il aura besoin de a, c, e, et g de la portée englobante, donc au moment où wraps est défini, Python prend une "capture d'écran" de ces variables de la portée englobante et stocke les références aux objets sous-jacents dans l'attribut __closure__ de la fonction wraps.

w = foo(1, 2, 3, 4, 5, e=6, f=7, y=2, z=3)
list(map(lambda cell: cell.cell_contents, w.__closure__))
# = > [1, 3, 6, {'y': 2, 'z': 3}]

Que se passe-t-il dans la situation suivante ? Pourquoi ?

def outer(l):
    def inner(n):
        return l * n
    return inner
    
l = [1, 2, 3]
f = outer(l)
print(f(3))  # => ??

l.append(4)
print(f(3))  # => ??

Decorators

Rappelons qu'un décorateur est un type spécial de fonction qui accepte une fonction comme argument et (généralement) renvoie une version modifiée de cette fonction. En classe, nous avons vu le "débogueur" décorateur - revoyez les diapositives si vous vous sentez toujours mal à l'aise avec l'idée d'un décorateur.

De plus, rappelez-vous que la syntaxe @decorator:

@decorator
def fn():
    pass

est équivalent à

def fn():
    pass
fn = decorator(fn)

Révision

Dans le cours, nous avons mis en place le décorateur debug.

def debug(function):
    def wrapper(*args, **kwargs):
        print("Arguments:", args, kwargs)
        return function(*args, **kwargs)
    return wrapper

Assurez-vous de bien comprendre ce qui se passe dans les lignes ci-dessus. Pourquoi les arguments sur la deuxième ligne sont-ils *args et **kwargs au lieu d'autre chose ? Que se passerait-il si nous ne faisions pas return wrapper à la fin de la fonction ?

Mise en cache automatique

Ecrivez une fonction de décorateur cache qui mettra automatiquement en cache tout appel à la fonction décorée.

def cache(function):
    pass  # Votre code ici

Par exemple,

@cache
def fib(n):
    return fib(n-1) + fib(n-2) if n > 2 else 1

fib(10)  # 55 (prendre un moment avant d'exécution)
fib(10)  # 55 (retourner immédiatement)
fib(100) # ne boucle pas infiniment
fib(400) # ne retourne pas RuntimeError

Conseil : Vous pouvez définir des attributs arbitraires sur une fonction (par exemple fn._cache). Lorsque vous faites cela, la paire attribut-valeur est également insérée dans fn.__dict__. Regardez par vous-même. Les attributs supplémentaires et .__dict__ sont-ils toujours synchrones ?

print_args

La fonction de décorateur debug que nous avons écrit en classe n'est pas très bon. Il ne nous dit pas quelle fonction est appelée, et il nous donne juste un tas d'arguments de position et un dictionnaire d'arguments de mots-clés - il ne sait même pas quels sont les noms des arguments de position ! Si les arguments par défaut ne sont pas écrasés, il ne nous indique pas non plus leur valeur.

Utilisez les attributs de fonction pour améliorer notre décorateur debug en un décorateur print_args qui est aussi bon que vous pouvez le faire.

def print_args(function):
    def wrapper(*args, **kwargs):
        # (1) Vous pouvez faire qqc ici
        retval = function(*args, **kwargs)
        # (2)  Vous pouvez faire qqc ici
        return retval
    return wrapper

Conseil : pensez à utiliser les attributs fn.__name__ et fn.__code__. Vous devrez étudier ces attributs, mais je dirai que l'objet code fn.__code__ contient un certain nombre d'attributs utiles - par exemple, fn.__code__.co_varnames. Regardez ça ! Plus d'informations sont disponibles dans la seconde moitié de TD3.