Callables

Nous allons maintenant nous intéresser à un « nouveau » type d’objets : les callables. Je place des guillemets autour de nouveau car vous les fréquentez en réalité depuis que vous faites du Python, les fonctions étant des callables.

Qu’est-ce qu’un callable me demanderez-vous ? C’est un objet que l’on peut appeler. Appeler un objet consiste à utiliser l’opérateur (), en lui précisant un certain nombre d’arguments, de façon à recevoir une valeur de retour.

1
2
3
4
5
>>> print('Hello', 'world', end='!\n') # Appel d'une fonction avec différents arguments
Hello world!
>>> x = pow(2, 3) # Valeur de retour
>>> x
8

Fonctions, classes et lambdas

L’ensemble des callables contient donc les fonctions, mais pas seulement. Les classes en sont, les méthodes, les lambdas, etc. Sont callables tous les objets derrière lesquels on peut placer une paire de parenthèses, pour les appeler.

En Python, on peut vérifier qu’un objet est appelable à l’aide de la fonction callable.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
>>> callable(print)
True
>>> callable(lambda: None)
True
>>> callable(callable)
True
>>> callable('')
False
>>> callable(''.join)
True
>>> callable(str)
True
>>> class A: pass
...
>>> callable(A)
True
>>> callable(A())
False

Paramètres de fonctions

Paramètres et arguments

Parlons un peu des paramètres de fonctions (et plus généralement de callables). Les paramètres sont décrits lors de la définition de la fonction, ils possèdent un nom et potentiellement une valeur par défaut.

Il faut les distinguer des arguments : les arguments sont les valeurs passées lors de l’appel.

Paramètres

1
2
def function(a, b, c, d=1, e=2):
    return

a, b, c, d et e sont les paramètres de la fonction function. a, b et c n’ont pas de valeur par défaut, il faut donc préciser explicitement une valeur lors de l’appel, pour que celui-ci soit valide. Les paramètres avec valeur par défaut se placent obligatoirement après les autres.

Arguments

1
function(3, 4, d=5, c=3)

Nous sommes là dans un appel de fonction, donc les valeurs sont des arguments. 3 et 4 sont des arguments positionnels, car ils sont repérés par leur position, et seront donc associés aux deux premiers paramètres de la fonction (a et b). d=5 et c=3 sont des arguments nommés, car la valeur est précédée du nom du paramètre associé. Ils peuvent ainsi être placés dans n’importe quel ordre (pour peu qu’ils soient placés après les arguments positionnels).

Des paramètres avec valeur par défaut peuvent recevoir des arguments positionnels, et des paramètres sans valeur par défaut peuvent recevoir des arguments nommés. Les deux notions, même si elles partagent une notation commune, sont distinctes.

Voici enfin différents cas d’appels posant problème :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
>>> function(1) # Pas assez d'arguments
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: function() missing 2 required positional arguments: 'b' and 'c'
>>> function(1, 2, 3, 4, 5, 6) # Trop d'arguments
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: function() takes from 3 to 5 positional arguments but 6 were given
>>> function(1, b=2, 3) # Mélange d'arguments positionnels et nommés
  File "<stdin>", line 1
SyntaxError: non-keyword arg after keyword arg
>>> function(1, 2, b=3, c=4) # b est à la fois positionnel et nommé
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: function() got multiple values for argument 'b'

Opérateur splat, le retour

On retrouve l’opérateur splat que nous avions vu lors des assignations dans le chapitre sur les Itérables. Il nous permet ici de récupérer la liste (ou plus précisément le tuple) des arguments positionnels passés lors d’un appel, on appelle cela le packing.

1
2
3
4
5
>>> def func(*args): # Il est conventionnel d'appeler args la liste ainsi récupérée
...     print(args)
...
>>> func(1, 2, 3, 'a', 'b', None)
(1, 2, 3, 'a', 'b', None)

La présence d’*args n’est pas incompatible avec celle d’autres paramètres.

1
2
3
4
5
6
7
8
9
>>> def func(foo, bar, *args):
...     print(foo)
...     print(bar)
...     print(args)
...
>>> func(1, 2, 3, 'a', 'b', None)
1
2
(3, 'a', 'b', None)

Les paramètres placés avant *args pourront toujours recevoir des arguments positionnels comme nommés. Mais ceux placés après ne seront éligibles qu’aux arguments nommés (puisqu’*args aura récupéré le reste des positionnels).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> def func(foo, *args, bar):
...     print(foo)
...     print(args)
...     print(bar)
...
>>> func(1, 2, 3, 'a', 'b', None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: func() missing 1 required keyword-only argument: 'bar'
>>> func(1, 2, 3, 'a', 'b', None, bar='c')
1
(2, 3, 'a', 'b', None)
c

On notera aussi que splat peut s’utiliser sans nom de paramètre, pour marquer une distinction entre les types d’arguments.

1
2
3
def func(foo, *, bar):
    print(foo)
    print(bar)

Ici, aucune récupération de la liste des arguments nommés n’est opérée. Mais foo, placé à gauche de *, peut prendre un argument positionnel ou nommé, alors que bar placé à droite ne peut recevoir qu’un argument nommé.

1
2
3
4
5
6
7
8
9
>>> func(1, bar=2)
1
2
>>> func(foo=1, bar=2)
1
2
>>> func(bar=2, foo=1)
1
2

Je disais plus haut que les paramètres avec valeur par défaut se plaçaient obligatoirement après ceux sans. Ceci est à nuancer avec l’opérateur splat, qui sépare en deux parties la liste des paramètres : cela n’est vrai qu’à gauche du splat. À droite, cela n’importe pas puisque tous les paramètres recevront des arguments nommés (donc sans notion d’ordre).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> def f(a, b=1, *, d): pass
...
>>> def f(a, b=1, *, d=2): pass
...
>>> def f(a, b=1, *, d=2, e): pass
...
>>> def f(a, b=1, *, d=2, e, f=3): pass
...
>>> def f(a, b=1, *, d, e=2, f, g=3): pass
...

Le double-splat

Enfin, outre l’opérateur splat que nous connaissions déjà, on découvre ici le double-splat (**). Cet opérateur sert à récupérer le dictionnaire des arguments nommés. Celui-ci doit se placer après tous les paramètres, et se nomme habituellement kwargs.

1
2
3
4
5
6
7
8
9
>>> def func(a, b=1, **kwargs):
...     print(kwargs)
...
>>> func(0)
{}
>>> func(0, b=2)
{}
>>> func(0, c=3)
{'c': 3}

En combinant ces deux opérateurs, une fonction est donc en mesure de récupérer l’ensemble de ses arguments (positionnels et nommés).

1
2
def func(*args, **kwargs):
    pass

On notera que contrairement au simple splat qui servait aussi pour les assignations, le double n’a aucune signification en dehors des arguments de fonctions.

L’appel du splat

Mais ces opérateurs servent aussi lors de l’appel à une fonction, via l’unpacking. Comme pour les assignations étudiées dans le chapitre des itérables, il est possible de transformer un itérable en arguments positionnels avec l’opérateur splat.

Le double-splat nous permet aussi ici de transformer un dictionnaire (ou autre mapping) en arguments nommés.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
>>> def addition_3(a, b, c):
...     return a + b + c
...
>>> addition_3(*[1, 2, 3])
6
>>> addition_3(1, *[2, 3])
6
>>> addition_3(**{'b': 2, 'a': 1, 'c': 3})
6
>>> addition_3(1, **{'b': 2, 'c': 3})
6
>>> addition_3(1, *[2], **{'c': 3})
6
>>> addition_3(*range(3)) # Splat est valable avec tous les itérables
3

Ainsi, il est possible de relayer les paramètres reçus par une fonction à une autre fonction, sans les préciser explicitement.

1
2
3
4
5
6
7
>>> def proxy_addition_3(*args, **kwargs):
...     return addition_3(*args, **kwargs)
...
>>> proxy_addition_3(1, 2, 3)
6
>>> proxy_addition_3(1, c=3, b=2)
6

Avant Python 3.5, chaque opérateur splat ne pouvait être utilisé qu’une fois dans un appel, et * devait être placé après tous les arguments positionnels.

Ces règles ont depuis disparu, comme relaté dans cet article sur la sortie de Python 3.5.

1
2
3
4
5
6
>>> addition_3(*[1, 2], 3)
6
>>> addition_3(*[1], 2, *[3])
6
>>> addition_3(*[1], **{'b': 2}, **{'c': 3})
6

Call-me maybe

Je vous le disais, plusieurs types d’objets peuvent être appelés. Que cache donc un callable ? Encore une fois, il s’agit d’un objet qui possède une méthode spéciale. La méthode est ici __call__, dont les paramètres seront les arguments passés lors de l’appel. La valeur renvoyée par __call__ sera le retour de l’appel.

Ainsi, testons avec divers objets :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> def func(arg): return arg
...
>>> func(1)
1
>>> func.__call__(1)
1
>>> (lambda x: x + 1)(1)
2
>>> (lambda x: x + 1).__call__(1)
2

Mais, vous devez vous dire, si on peut appeler func.__call__, c’est que func.__call__ est un callable, qui possède donc sa propre méthode __call__ ? C’est le cas, et l’on peut continuer ainsi indéfiniment.

1
2
3
4
>>> func.__call__.__call__(1)
1
>>> func.__call__.__call__.__call__.__call__.__call__.__call__(1)
1

Cela s’explique par le fait que __call__ est une méthode, donc un callable. En interne, Python est capable d’identifier qu’il s’agit d’une fonction et d’en exécuter le code, pour ne pas avoir à appeler indéfiniment des __call__.

Maintenant, implémentons __call__ dans un objet de notre création :

1
2
3
4
5
6
class MyCallable:
    def __init__(self, a):
        self.a = a

    def __call__(self, b):
        return self.a + b

Nous avons là une classe MyCallable, dont les instances sont des callables, réalisant la somme du paramètre reçu à la construction avec celui reçu lors de l’appel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> add_3 = MyCallable(3)
>>> add_3(5)
8
>>> add_3(10)
13
>>> add_100 = MyCallable(100)
>>> add_100(2)
102
>>> MyCallable(6)(3)
9

Il y a différents intérêts à créer un type callable. Le premier serait simplement de rendre compatible notre objet à l’interface utilisée par de nombreuses fonctions Python que nous verrons dans la section suivante. Aussi, utiliser une classe pour cela est un moyen simple de sauvegarder un état, permettant d’avoir un comportement différent à chaque appel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
>>> class Increment:
...     def __init__(self):
...         self.n = 0
...     def __call__(self):
...         self.n += 1
...         return self.n
...
>>> incr = Increment()
>>> incr()
1
>>> incr()
2
>>> Increment()() # Les deux objets sont bien indépendants
1
>>> incr()
3

Utilisation des callables

De même que pour les itérables, les callables sont au cœur de Python en pouvant être utilisés avec un grand nombre de builtins.

Par exemple, la fonction max évoquée dans un précédent chapitre : en plus de prendre un itérable sur lequel trouver le maximum, elle peut aussi prendre un paramètre key. Ce paramètre est un callable expliquant comment extraire le maximum depuis les arguments passés à max.

Si l’itérable ne contient que des entiers, il est plutôt simple de déterminer le maximum, mais si nous avons une liste de points 2D par exemple ? Le maximum pourrait être le point avec la plus grande abscisse, la plus grande ordonnée, le point le plus éloigné de l’origine du repère, ou encore bien d’autres choses.

Le callable key est donc chargé de calculer une valeur numérique pour chacun des paramètres, et pouvoir ainsi les comparer entre-eux. Le paramètre ayant la plus grande valeur sera le maximum.

Nous représenterons ici nos points par des tuples de deux valeurs.

1
2
3
4
5
6
7
8
9
>>> points = [(0, 0), (1, 4), (3, 3), (4, 0)]
>>> max(points) # par défaut, Python sélectionne suivant le premier élément, soit l'abscisse
(4, 0)
>>> max(points, key=lambda p: p[0]) # Nous précisons ici explicitement la sélection par l'abscisse
(4, 0)
>>> max(points, key=lambda p: p[1]) # Par ordonnée
(1, 4)
>>> max(points, key=lambda p: p[0]**2 + p[1]**2) # Par distance de l'origine
(3, 3)

En dehors de max, d’autres fonctions Python prennent un tel paramètre key, comme min ou encore sorted :

1
2
3
4
>>> sorted(points, key=lambda p: p[1])
[(0, 0), (4, 0), (3, 3), (1, 4)]
>>> sorted(points, key=lambda p: p[0]**2 + p[1]**2)
[(0, 0), (4, 0), (1, 4), (3, 3)]

map, que nous avons déjà vu, prend lui aussi un callable de n’importe quel type.

1
2
3
4
>>> list(map(lambda p: p[0], points))
[0, 1, 3, 4]
>>> list(map(lambda p: p[1], points))
[0, 4, 3, 0]

Je vous invite une nouvelle fois à jeter un œil aux builtins Python, ainsi qu’au module itertools, et de voir lesquels peuvent vous faire tirer profit des callables.

Modules operator et functools

Passons maintenant à la présentation de deux modules, contenant deux collections de callables.

operator

Ce premier module, operator regroupe l’ensemble des opérateurs Python sous forme de fonctions. Ainsi, une addition pourrait se formuler par :

1
2
3
>>> import operator
>>> operator.add(3, 4)
7

Outre les opérateurs habituels, nous en trouvons d’autres sur lesquels nous allons nous intéresser plus longuement, dont la particularité est de retourner des callables.

itemgetter

itemgetter permet de récupérer un élément précis depuis un indexable, à la manière de l’opérateur [].

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> get_second = operator.itemgetter(1) # Récupère le 2ème élément de l'indexable donné en argument
>>> get_second([5, 8, 0, 3, 1])
8
>>> get_second('abcdef')
b
>>> get_second(range(3,10))
4
>>> get_x = operator.itemgetter('x')
>>> get_x({'x': 5, 'y': 1})
5

methodcaller

methodcaller permet d’appeler une méthode prédéterminée d’un objet, avec ses arguments.

1
2
3
4
5
6
7
8
>>> dash_spliter = operator.methodcaller('split', '-')
>>> dash_spliter('a-b-c-d')
['a', 'b', 'c', 'd']
>>> append_b = operator.methodcaller('append', 'b')
>>> l = [0, 'a', 4]
>>> append_b(l)
>>> l
[0, 'a', 4, 'b']

functools

Je tenais ensuite à évoquer le module functools, et plus particulièrement la fonction partial : celle-ci permet de réaliser un appel partiel de fonction.

Imaginons que nous ayons une fonction prenant divers paramètres, mais que nous voudrions fixer le premier : l’application partielle de la fonction nous créera un nouveau callable qui, quand il sera appelé avec de nouveaux arguments, nous renverra le résultat de la première fonction avec l’ensemble des arguments.

Par exemple, prenons une fonction de journalisation log prenant quatre paramètres, un niveau de gravité, un type, un object, et un message descriptif :

1
2
def log(level, type, item, message):
    print('[{}]<{}>({}): {}'.format(level.upper(), type, item, message))

Une application partielle reviendrait à avoir une fonction warning tel que chaque appel warning('foo', 'bar', 'baz') équivaudrait à log('warning', 'foo', 'bar', 'baz'). Ou encore une fonction warning_foo avec warning_foo('bar', 'baz') équivalent à l’appel précédent.

Nous allons la tester avec une fonction du module operator : la fonction de multiplication. En appliquant partiellement 5 à la fonction operator.mul, partial nous retourne une fonction réalisant la multiplication par 5 de l’objet passé en paramètre.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
>>> from functools import partial
>>> mul_5 = partial(operator.mul, 5)
>>> mul_5(3)
15
>>> mul_5('z')
'zzzzz'
>>> warning = partial(log, 'warning')
>>> warning('overflow', 100, 'number is too large')
[WARNING]<overflow>(100): number is too large
>>> overflow = partial(log, 'warning', 'overflow')
>>> overflow(-1, 'number is too low')
[WARNING]<overflow>(-1): number is too low

Le module functools comprend aussi la fonction reduce, un outil tiré du fonctionnel permettant de transformer un itérable en une valeur unique. Pour cela, elle itère sur l’ensemble et applique une fonction à chaque valeur, en lui précisant la valeur courante et le résultat précédent.

Imaginons par exemple que nous disposions d’une liste de nombres [5, 8, 0, 3, 1] et que nous voulions en calculer la somme. Nous savons faire la somme de deux nombres, il s’agit d’une addition, donc de la fonction operator.add.

reduce va nous permettre d’appliquer operator.add sur les deux premiers éléments (5 + 8 = 13), réappliquer la fonction avec ce premier résultat et le prochain élément de la liste (13 + 0 = 13), puis avec ce résultat et l’élément suivant (13 + 3 = 16), et enfin, sur ce résultat et le dernier élément (16 + 1 = 17).

Ce processus se résume en :

1
2
3
>>> from functools import reduce
>>> reduce(operator.add, [5, 8, 0, 3, 1])
17

qui revient donc à faire

1
2
>>> operator.add(operator.add(operator.add(operator.add(5, 8), 0), 3), 1)
17

Bien sûr, en pratique, sum est déjà là pour répondre à ce problème.

TP : itemgetter

Dans ce nouveau TP, nous allons réaliser itemgetter à l’aide d’une classe formant des objets callables.

La clef à récupérer est passée à l’instanciation de l’objet itemgetter, et donc à son constructeur. L’objet depuis lequel nous voulons récupérer la clef sera passé lors de l’appel (dans la méthode __call__). Il nous suffit alors, dans cette méthode, d’appeler l’opérateur [] sur l’objet avec la clef enregistrée au moment de la construction.

1
2
3
4
5
6
class itemgetter:
    def __init__(self, key):
        self.key = key

    def __call__(self, obj):
        return obj[self.key]

C’est aussi simple que cela, et nous pouvons le tester :

1
2
3
4
5
>>> points = [(0, 0), (1, 4), (3, 3), (4, 0)]
>>> sorted(points, key=itemgetter(0))
[(0, 0), (1, 4), (3, 3), (4, 0)]
>>> sorted(points, key=itemgetter(1))
[(0, 0), (4, 0), (3, 3), (1, 4)]

Nous aurions aussi pu profiter des fermetures (closures) de Python pour réaliser itemgetter sous la forme d’une fonction retournant une fonction.

1
2
3
4
def itemgetter(key):
    def function(obj):
        return obj[key]
    return function

Si vous vous êtes intéressés de plus près à operator.itemgetter, vous avez aussi pu remarquer que celle-ci pouvait prendre plus d’un paramètre :

1
2
3
4
5
6
7
>>> from operator import itemgetter
>>> get_x = itemgetter('x')
>>> get_x_y = itemgetter('x', 'y')
>>> get_x({'x': 9, 'y': 6})
9
>>> get_x_y({'x': 9, 'y': 6})
(9, 6)

Je vous propose, pour aller un peu plus loin, d’ajouter cette fonctionnalité à notre classe, et donc d’utiliser les listes d’arguments positionnels. Vous trouverez la solution dans la documentation du module operator.


Voici donc quelques liens relatifs aux callables.