Exploration de Python : traitement des séquences avec un style fonctionnel — reduce

Christophe Vaudry
norsys-octogone
Published in
10 min readJul 5, 2024

Contexte

Ce billet s’inscrit dans une série de billets sur le traitement des séquences avec un style fonctionnel en Python dont le premier portait sur la fonction filter et le second sur la fonction map.

Ce billet est donc le troisième dans la série et traite de la fonction reduce.

Les exemples de ce billet ont été testés avec la version 3.12 de Python.

Principe

Comme filter et map, la fonction reduce transforme une séquence qui lui est fournie en paramètre grâce à une fonction qui lui est également fournie en paramètre en parcourant la séquence de la gauche vers la droite.

A la différence de filter qui modifie la séquence originale en supprimant ses éléments qui sont faux pour un prédicat pour produire une nouvelle séquence ou à la différence de map qui transforme individuellement chaque élément de la séquence pour produire une nouvelle séquence, reduce parcours la séquence de la gauche vers la droite en combinant les éléments afin de transformer la séquence elle-même en un nouvel objet (une valeur scalaire, une nouvelle séquence, etc.).

Nous allons éclairer tout cela par plusieurs exemples, mais il faut noter qu’en Python contrairement à filter et à map, reduce n'est pas (en fait n'est plus) une fonction native : elle se trouve dans le module functools (c’est à priori pour des questions de performance et de lisibilité).

Reductio ad exemplum

L’idée avec la fonction reduce, comme son nom le rappel, est de réduire une séquence à un résultat.

Juste faire une réduction en somme !

L’exemple classique est la somme d’une liste de nombres : je souhaite réduire une liste de nombres à la somme de ces nombres. Prenons la liste des nombres de 1 à 10 qui peuvent nous être donnés en Python avec range(1,11) (rappelons que pour les intervalles en Python la valeur de fin est non incluse) et calculons leur somme.

Commençons par le faire de manière impérative avec for.

sum_of_numbers_1 = 0
for number in range(1, 11):
sum_of_numbers_1 = sum_of_numbers_1 + number
print("Somme des nombres de 1 à 10 avec une boucle for :", sum_of_numbers_1)

la variable sum_of_numbers_1 initialisée à 0, sert d'accumulateur : dans la boucle on ajoute successivement chaque élément de la liste à cet accumulateur.

>>> sum_of_numbers_1 = 0
>>> for number in range(1, 11):
... sum_of_numbers_1 = sum_of_numbers_1 + number
...
>>> print("Somme des nombres de 1 à 10 avec une boucle for :", sum_of_numbers_1)
Somme des nombres de 1 à 10 avec une boucle for : 55

C’est très exactement ce que fait reduce :

>>> import functools
>>> functools.reduce(lambda a, b: a + b, range(1, 11))
55

La fonction reduce prend en paramètre une fonction et un iterator. La fonction passée à reduce prend elle-même 2 paramètres en entrée. Elle produit une valeur en sortie qui n'est pas nécessairement du même type que celui des éléments en entrée (même si c'est le cas ici avec l'addition).

Que se passe-t-il la séquence en paramètre n’a qu’un seul élément ?

>>> import functools
>>> functools.reduce(lambda a, b: a + b, [1])
1

Et bien, cet élément est retourné. Et que se passe-t-il avec la liste vide ?

>>> import functools
>>> functools.reduce(lambda a, b: a + b, [])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: reduce() of empty iterable with no initial value

Et bien dans ce cas on a une exception. Cela semble normal, il n’y a aucune valeur cohérente que reduce puisse retourner dans ce cas.

En fait reduce prend un troisième paramètre optionnel qui correspond à une valeur initiale pour démarrer la réduction. Avec ce troisième paramètre, on peut lui passer une séquence vide, c'est cette valeur initiale qui est alors retournée.

>>> import functools
>>> functools.reduce(lambda a, b: a + b, [], 0)
0
>>> functools.reduce(lambda a, b: a + b, range(1, 11), 0)
55
>>> functools.reduce(lambda a, b: a + b, range(1, 11), 5)
60

Cela n’a pas à voir directement avec reduce mais au passage on peut noter que dans le cas précis de l'addition, nous avons une écriture plus compacte que la lambda en utilisant le module operator et la fonction add qui y est définie (entre autre chose) :

>>> import functools
>>> import operator
>>> functools.reduce(operator.add, range(1, 11), 5)
60

Donc reduce permet de combiner l'ensemble des éléments d'une séquence en prenant en compte éventuellement une valeur initiale pour produire un résultat.

Cependant il est peu probable que vous utilisiez reduce pour calculer une somme de nombres en Python. Pourquoi ? Parce qu'il y a une fonction native sum qui est une version spécialisée de reduce et qui calcule la somme des valeurs numériques d'un iterator.

>>> import functools
>>> import operator
>>> numbers_1_to_10 = range(1, 11)
>>> functools.reduce(operator.add, numbers_1_to_10)
55
>>> sum(numbers_1_to_10)
55

La fonction sum prend également en paramètre optionnel une valeur initiale qui vaut par défaut 0. Il faut noter que pour les nombres flottants il vaut mieux utiliser la fonction fsum du module math.

>>> import math
>>> import decimal
>>> decimal.Decimal.from_float(sum([1.234556e-20, 1.2e-10, math.pi, math.e, math.sqrt(2), math.tau, math.sqrt(3)]))
Decimal('15.2893241592903965653249542810954153537750244140625')
>>> decimal.Decimal.from_float(math.fsum([1.234556e-20, 1.2e-10, math.pi, math.e, math.sqrt(2), math.tau, math.sqrt(3)]))
Decimal('15.2893241592903965653249542810954153537750244140625')

Jusqu’à au moins Python 3.9, j’avais pu constater que le calcul n’avait pas la même précision en forçant l’affichage de décimal. Néanmoins cela ne semble plus être le cas en Python 3.12. La différence est que fsum renvoie toujours un float alors que le type de retour pour sum dépend du type des éléments de la liste. De plus fsum doit être importé alors que sum est une fonction native utilisable directement.

Ne trouvez-vous pas cocasse qu’une somme de nombres soit au fond une réduction ?

On donne le max !

Après tout, la fonction reduce peut être utilisée à autre chose que le calcul de la somme des nombres d'un itérable. On peut par exemple l'utiliser pour déterminer la maximum et le minimum d'une séquence d'élément.

>>> import functools
>>> numbers_1_to_10 = range(1, 11)
>>> functools.reduce(lambda a, b: a if a > b else b, numbers_1_to_10)
10
>>> functools.reduce(lambda a, b: a if a < b else b, numbers_1_to_10)
1

Mais comme pour la fonction sum, en Python, il existe des fonctions natives max et min dont on préférera l'utilisation à celles de reduce.

>>> numbers_1_to_10 = range(1, 11)
>>> max(numbers_1_to_10)
10
>>> min(numbers_1_to_10)
1

C’est sûr en présentant les choses comme cela, on ne perçoit probablement pas grand intérêt à reduce pour l'instant : les réductions les plus communes le sont tellement qu’elles ont des fonctions dédiées explicites !

House of the Dragon

La fonction reduce est souvent utilisée pour réduire une liste d'éléments à une valeur scalaire mais rien n'oblige à ce que cela soit le cas. On peut utiliser reduce pour combiner les éléments pour produire n'importe quel type de valeur.

Ainsi imaginons que j’ai une liste de noms et que je veuille créer un dictionnaire qui a comme clé ce nom avec comme valeur associée la longueur de ce nom. Je peux m’y prendre comme suit avec reduce.

import functools

houses = {"tyrell", "stark", "lannister", "tarly", "baratheon", "targaryen"}
print("Houses :", houses)


def aggregate(accumulator, current_item):
accumulator.update({current_item: len(current_item)})
return accumulator


length_by_house = functools.reduce(aggregate, houses, {})
print("Longueur du nom par nom de maison avec reduce:", length_by_house)

J’ai une fonction qui combine les éléments de mon iterator en les agrégeant les uns après les autres dans un dictionnaire avec sa taille. On part de l'hypothèse que la séquence n'a pas de doublon pour rester simple. La valeur d'initialisation est le dictionnaire vide. Le premier élément est ajouté dans ce dictionnaire vide et la valeur retournée est le dictionnaire avec ce premier élément et sa taille. Cette valeur retournée est passée comme paramètre accumulator pour le deuxième élément et ainsi de suite. Arrivée en fin de liste, la valeur retournée par reduce est le dictionnaire avec le dernier élément ajouté.

Houses  : {'baratheon', 'stark', 'tyrell', 'targaryen', 'tarly', 'lannister'}
Longueur du nom par nom de maison avec reduce: {'baratheon': 9, 'stark': 5, 'tyrell': 6, 'targaryen': 9, 'tarly': 5, 'lannister': 9}

Bien sûr en Python, vous pouvez obtenir le même résultat avec une compréhension, ce qui donne un code plus compact et probablement plus lisible.

>>> houses = {"tyrell", "stark", "lannister", "tarly", "baratheon", "targaryen"}
>>> {house: len(house) for house in houses}
{'lannister': 9, 'baratheon': 9, 'tarly': 5, 'stark': 5, 'targaryen': 9, 'tyrell': 6}

Il faut reconnaitre qu’encore une fois, il existe une solution en Python plus intéressante que celle basée sur l’utilisation de la fonction reduce. Cependant, une compréhension pourra être utilisée à la place de reduce si le résultat que l'on souhaite obtenir est une list, un tuple, un set, un dict ou un iterator. Si on souhaite réduire à un objet par exemple on ne pourra pas le faire directement avec une compréhension. Prenons un exemple un peu artificiel basé sur l’exemple précédent.

import functools

houses = {"tyrell", "stark", "lannister", "tarly", "baratheon", "targaryen"}


def aggregate(accumulator, current_item):
accumulator.update({current_item: len(current_item)})
return accumulator


class HousesOfWesteros:
def __init__(self):
self.name_length_by_name = {}

def update(self, dictionary):
self.name_length_by_name.update(dictionary)

def __str__(self):
return str(self.name_length_by_name)


print(
"Longueur du nom par nom de maison avec reduce sur un objet :",
functools.reduce(aggregate, houses, HousesOfWesteros()),
)

La classe HousesOfWesteros est juste une enveloppe autour d'un dictionnaire. En soit elle n'a pas grand intérêt, si ce n'est pour les besoins de la démonstration ici. En effet, ce qui est intéressant avec reduce c'est qu'on peut réduire une séquence a à peu près n'importe quoi. On notera au passage, l'utilisation du Duck Typing et la réutilisation de la fonction aggregate définie précédemment, qui est utilisée aussi bien sur un dict que sur la classe HousesOfWesteros, ce qui est important est que l'on puisse appliquer sur l'un et l'autre la fonction update.

Longueur du nom par nom de maison avec reduce sur un objet : {'baratheon': 9, 'stark': 5, 'tarly': 5, 'tyrell': 6, 'targaryen': 9, 'lannister': 9}

Le point est que la fonction reduce est très générique mais qu'en Python pour la plupart des besoins courants, on n'en n'a pas besoin car on a généralement une solution plus simple à mettre en œuvre.

La fonction reduce est vraiment générique

Comme cela vient d’être écrit la fonction reduce est très générique, à tel point que l'on peut exprimer les fonctions filter et map avec reduce. D'une certaine manière filter et map sont des versions spécialisées de reduce. Commençons par filter.

import functools


def my_filter(predicate, seq):
def filter_and_aggregate_as_list(accumulator, current_elt):
if predicate(current_elt):
accumulator.append(current_elt)
return accumulator

return functools.reduce(filter_and_aggregate_as_list, seq, [])


print(
"Nombres pairs entre 1 à 9 avec my_filter:",
my_filter(lambda x: x % 2 == 0, range(1, 10)),
)
print(
"Nombres pairs entre 1 à 9 avec filter:",
list(filter(lambda x: x % 2 == 0, range(1, 10))),
)

On peut constater que cela nous donne bien le même résultat qu’avec le filter natif.

Nombres pairs entre 1 à 9 avec my_filter: [2, 4, 6, 8]
Nombres pairs entre 1 à 9 avec filter: [2, 4, 6, 8]

L’implémentation repose sur la fonction de réduction qui réalise le filtrage de la liste originale. On notera quand même que dans l’ implémentation que je propose c’est une liste qui est retournée et non un iterator. L'objectif est ici bien sûr de montrer le principe de fonctionnement.

Si nous passons à map, on pourrait avoir une implémentation de la forme :

import functools


def my_map(fun, seq):
def apply_function_and_aggregate_as_list(accumulator, current_elt):
accumulator.append(fun(current_elt))
return accumulator

return functools.reduce(apply_function_and_aggregate_as_list, seq, [])


print(
"Puissances de 2 des nombres entre 1 à 9 avec my_map:",
my_map(lambda x: x**2, range(1, 10)),
)
print(
"Puissances de 2 des nombres entre 1 à 9 avec map:",
list(map(lambda x: x**2, range(1, 10))),
)

On obtient des résultats similaires, au détail prêt encore une fois que l’implémentation proposée retourne une list et non un iterator.

Puissances de 2 des nombres entre 1 à 9 avec my_map: [1, 4, 9, 16, 25, 36, 49, 64, 81]
Puissances de 2 des nombres entre 1 à 9 avec map: [1, 4, 9, 16, 25, 36, 49, 64, 81]

Le principe est le même que pour filter, la fonction de réduction crée la liste d'éléments transformés par l'application de la fonction passée à map.

Synthèse

La fonction reduce est une fonction générique qui transforme une séquence d'éléments grâce à la fonction qui lui est passée en paramètre et qui sert à combiner les éléments de cette séquence 2 à 2 en la parcourant de la gauche vers la droite :

  • cette fonction doit prendre en paramètre 2 éléments du même type que les éléments de la séquence ;
  • la séquence est réduite en combinant ainsi successivement chaque élément de la séquence avec la fonction.
  • la fonction reduce prend éventuellement un troisième paramètre optionnel qui sert de valeur de départ ;
  • le résultat n’est pas nécessairement une séquence.

La fonction reduce est extrêmement puissante et est une fonction clé aux traitements des séquences dans les langages fonctionnels. Néanmoins en Python, si elle est présente, elle n'est souvent pas la solution privilégiée par rapport à des solutions plus spécialisées mais plus lisibles.

Il y a bien sûr un gist avec les sources des exemples.

J’avais publié une première version de ce billet sur mon blog personnel.

Remerciement

Je remercie mon collègue Anthony Tenneriello pour sa relecture attentive et ses remarques.

--

--

Christophe Vaudry
norsys-octogone

Developer working for Norsys. Programming languages explorer. Know nothing.