Devenir incollable sur les callables !¶

Antoine "entwanne" Rozo
¶

¶

Devenir incollable sur les callables !¶

  • Comprendre ce que sont les callables au-delà des fonctions
  • Découvrir ce qu'il se passe lors d'un appel
  • Petit tour du côté des décorateurs
  • https://github.com/entwanne/presentation_callables

Fonctions¶

Fonctions¶

  • Une fonction est une opération qui calcule un résultat qu'elle renvoie à partir d'arguments qu'on lui donne
  • Elle est appelée à l'aide d'une paire de parenthèses
In [1]:
>>> abs(5)
Out[1]:
5
In [2]:
>>> abs(-1.2)
Out[2]:
1.2

Fonctions¶

  • Elle travaille sur tous types de valeurs
In [3]:
>>> len('Hello')
Out[3]:
5
In [4]:
>>> all([True, 'foo', 4, {'key': 0}])
Out[4]:
True

Fonctions¶

  • Une fonction ne prend pas nécessairement d'arguments
In [5]:
>>> input()
hello
Out[5]:
'hello'

Fonctions¶

  • Mais renvoie toujours une et une seule valeur
In [6]:
>>> print("abc")
abc
In [7]:
>>> ret = print("abc")
>>> print(ret)
abc
None
In [8]:
>>> divmod(7, 2)
Out[8]:
(3, 1)
In [9]:
>>> a, b = divmod(7, 2)

Fonctions¶

  • Tout appel de fonction peut ainsi être utilisé au sein d'une expression
In [10]:
>>> round(3.5) * 2 + abs(-5)
Out[10]:
13

Fonctions¶

  • Une fonction ne renvoie pas nécessairement toujours la même valeur pour les mêmes arguments
In [11]:
>>> import random
>>> random.randrange(10)
Out[11]:
8
In [12]:
>>> it = iter(range(10))
In [16]:
>>> next(it)
Out[16]:
3

Définition de fonction¶

  • On définit une fonction à l'aide du mot-clé def
  • La définition associe une fonction à un nom et lui spécifie une liste de paramètres
  • Le bloc de code suivant la ligne de définition est le corps de la fonction
  • La valeur de retour d'une fonction est renvoyée à l'aide du mot-clé return
In [17]:
def addition(a, b):
    return a + b
In [18]:
>>> addition(3, 5)
Out[18]:
8

Autres callables¶

Callables¶

  • Les fonctions sont des callables / appelables (objets que l'on peut appeler à l'aide de parenthèses)
  • Mais il n'y a pas que les fonctions qui sont appelables

Types¶

  • Les types sont appelables, pour en créer des instances
In [19]:
>>> int('123')
Out[19]:
123
In [20]:
>>> str()
Out[20]:
''
In [21]:
>>> range(10)
Out[21]:
range(0, 10)

Méthodes¶

  • Les méthodes des objets sont appelables
In [22]:
>>> "Hello".replace("l", "_")
Out[22]:
'He__o'
  • De même que les méthodes de classes
In [23]:
>>> dict.fromkeys([1, 2, 3])
Out[23]:
{1: None, 2: None, 3: None}

Lambdas¶

  • Les lambdas sont des définitions de fonctions sous forme d'expressions
In [24]:
>>> f = lambda x: x+1
>>> f(5)
Out[24]:
6
In [25]:
>>> (lambda i: i**2)(4)
Out[25]:
16
  • On parle aussi de fonctions anonymes

Autres¶

  • Les objects functools.partial sont des appels partiels de fonctions
  • Ils stockent les arguments donnés à la création pour les réutiliser lors de l'appel final
In [26]:
>>> import functools
>>> f = functools.partial(max, 0)
>>> f(1, 2)
Out[26]:
2
In [27]:
>>> f(-1, -2)
Out[27]:
0

Arguments des appelables¶

Arguments¶

  • Les arguments sont les valeurs envoyées à un callable
In [28]:
>>> addition(3, 5)
Out[28]:
8
  • Ils peuvent être positionnels ou nommés
In [29]:
>>> addition(a=3, b=5)
Out[29]:
8

Ordre de placement des arguments¶

  • Les arguments positionnels se placent toujours avant les arguments nommés
In [30]:
>>> addition(3, b=5)
Out[30]:
8
In [31]:
>>> addition(a=3, 5)
  Cell In[31], line 1
    addition(a=3, 5)
                   ^
SyntaxError: positional argument follows keyword argument

Arguments et paramètres¶

  • Ne pas confondre les arguments avec les paramètres qui sont les variables dans lesquelles sont reçus les arguments
  • Un argument ne peut correspondre qu'à un seul paramètre

Arguments et paramètres¶

  • a et b sont les paramètres de la fonction addition
    def addition(a, b):
        return a + b
    
  • 3 et 5 sont les arguments de l'appel
    addition(3, b=5)
    
  • 5 est un argument nommé associé au nom b

Arguments et paramètres¶

  • Les arguments positionnels remplissent les paramètres de la gauche vers la droite
  • Tandis que les arguments nommés remplissent les paramètres correspondant à leurs noms

Valeur par défaut¶

  • Un paramètre peut définir une valeur par défaut
  • Elle sera utilisée si aucun argument n'est fourni pour ce paramètre
In [32]:
def multiplication(a, b=1):
    return a * b
In [33]:
>>> multiplication(3, 4)
Out[33]:
12
In [34]:
>>> multiplication(5)
Out[34]:
5

Valeur par défaut¶

  • Attention : cette valeur par défaut est définie une seule fois pour la fonction
In [35]:
>>> def append(item, dest=[]):
...     dest.append(item)
...     return dest
In [36]:
>>> append(4, [1, 2, 3])
Out[36]:
[1, 2, 3, 4]
In [37]:
>>> append('hello')
Out[37]:
['hello']

Différentes sortes de paramètres¶

  • Les paramètres peuvent être de plusieurs sortes :

    • posititonal-only, qui ne peuvent recevoir que des arguments positionnels
    • keyword-only, qui ne peuvent recevoir que des arguments nommés
    • positional-or-keyword, qui peuvent recevoir à la fois des arguments positionnels ou nommés
  • Jusqu'ici nos paramètres étaient tous de type positional-or-keyword

Différentes sortes de paramètres¶

  • Il est possible à l'aide des délimiteurs / et * de spécifier la sorte de nos paramètres :
    • Les paramètres placés avant / sont positional-only
    • Les paramètres placés après * sont keyword-only
    • Les autres paramètres sont positional-or-keyword
  • Ces délimiteurs sont bien sûr optionnels

Différentes sortes de paramètres¶

In [38]:
def function(first, /, second, third, *, fourth):
    ...
  • first est positional-only
  • second et third sont positional-or-keyword
  • fourth est positional-only

Différentes sortes de paramètres¶

  • À l'appel cela signifie que first ne peut pas recevoir d'argument nommé et fourth ne peut pas recevoir d'argument positionnel
In [39]:
>>> function(1, 2, 3, fourth=4)
In [40]:
>>> function(1, second=2, third=3, fourth=4)
In [41]:
>>> function(1, 2, 3, 4)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[41], line 1
----> 1 function(1, 2, 3, 4)

TypeError: function() takes 3 positional arguments but 4 were given
In [42]:
>>> function(first=1, second=2, third=3, fourth=4)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[42], line 1
----> 1 function(first=1, second=2, third=3, fourth=4)

TypeError: function() got some positional-only arguments passed as keyword arguments: 'first'

Ordre de définition des paramètres¶

  • Les délimiteurs font qu'il y a un ordre à respecter pour définir les différents paramètres
    • D'abord positional-only, puis positional-or-keyword et enfin keyword-only
  • Les valeurs par défaut des paramètres sont aussi à prendre en compte dans l'ordre de définition

Ordre de définition des paramètres¶

  • Chez les arguments qui peuvent être positionnels (positional-only ou positional-or-keyword) un paramètre sans valeur par défaut ne peut pas suivre un paramètre qui en a une
In [43]:
def function(first, /, second, third=3):
    ...
In [44]:
def function(first, /, second=2, third):
    ...
  Cell In[44], line 1
    def function(first, /, second=2, third):
                                          ^
SyntaxError: invalid syntax
In [45]:
def function(first=1, /, second, third):
    ...
  Cell In[45], line 1
    def function(first=1, /, second, third):
                             ^
SyntaxError: non-default argument follows default argument

Ordre de définition des paramètres¶

  • Le problème ne se pose pas pour les paramètres keyword-only puisque l'ordre des arguments n'y a pas d'importance
In [46]:
def function(foo=None, *, bar=True, baz):
    return (foo, bar, baz)
In [47]:
>>> function(baz=False)
Out[47]:
(None, True, False)

Paramètres variadiques¶

  • Il existe aussi des paramètres spéciaux pour récupérer tout un ensemble d'arguments, qu'on appelle paramètres variadiques
    • Ce nom vient du fait qu'ils récupèrent un nombre variable d'arguments…
    • Autrement dit des arguments variadiques

Paramètres variadiques¶

  • Un paramètre préfixé d'un * récupère tous les arguments positionnels restants
    • Il prend alors la place du délimiteur * dans la liste des paramètres
    • C'est-à-dire qu'il se place nécessairement après tous les paramètres qui peuvent recevoir des arguments positionnels
    • Il ne peut y avoir qu'un paramètre préfixé d'un *
    • Ce paramètre contiendra alors un tuple des arguments positionnels donnés à la fonction
    • Les arguments positionnels qui correspondent à un paramètre précis ne seront pas inclus dans ce tuple
    • Il est d'usage d'appeler ce paramètre args

Paramètres variadiques¶

In [48]:
def my_sum(*args):
    total = 0
    for item in args:
        total += item
    return total
In [49]:
>>> my_sum(1, 2, 3)
Out[49]:
6
In [50]:
>>> my_sum()
Out[50]:
0

Paramètres variadiques¶

  • Ils peuvent bien sûr être combinés avec d'autres sortes de paramètres
In [51]:
def my_sum(first, /, *args):
    total = first
    for item in args:
        total += item
    return total
In [52]:
>>> my_sum(1, 2, 3)
Out[52]:
6
In [53]:
>>> my_sum('a', 'b', 'c')
Out[53]:
'abc'
In [54]:
>>> my_sum()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[54], line 1
----> 1 my_sum()

TypeError: my_sum() missing 1 required positional argument: 'first'

Paramètres variadiques¶

  • C'est aussi ce qui est utilisé par la fonction print par exemple pour accepter un nombre arbitraire d'arguments
In [55]:
>>> print(1, 'foo', ['hello', 'world'])
1 foo ['hello', 'world']

Paramètres variadiques¶

  • De manière similaire, le préfixe ** permet de définir un paramètre qui récupère tous les arguments nommés restants
    • Ce paramètre se place nécessairement après tous les autres
    • Il est unique lui aussi
    • Il contient le dictionnaire des arguments nommés passés à la fonction
    • Seuls les arguments nommés ne correspondant à aucun autre paramètre sont présents dans ce dictionnaire
    • Il est d'usage d'appeler ce paramètre kwargs

Paramètres variadiques¶

In [56]:
def make_dict(**kwargs):
    return kwargs
In [57]:
>>> make_dict(foo=1, bar=2)
Out[57]:
{'foo': 1, 'bar': 2}

Paramètres variadiques¶

  • Combinables eux-aussi avec d'autres sortes de paramètres
In [58]:
def make_obj(id, **kwargs):
    return {'id': id} | kwargs
In [59]:
>>> make_obj('#1', foo='bar')
Out[59]:
{'id': '#1', 'foo': 'bar'}
In [60]:
>>> make_obj(id='#1', foo='baz')
Out[60]:
{'id': '#1', 'foo': 'baz'}

Opérateurs splat¶

  • Ces délimiteurs * et ** ne sont pas utilisables uniquement dans les listes de paramètres
  • On les retrouve aussi dans les listes d'arguments, sous le nom d'opérateurs splat
  • Ils ont l'effet inverse de celui en place pour les paramètres

Opérateurs splat¶

  • Ainsi * appliqué à une liste (ou tout autre itérable) transforme ses éléments en arguments positionnels
In [61]:
>>> addition(*[3, 5])
Out[61]:
8
In [62]:
>>> print(*(i**2 for i in range(10)))
0 1 4 9 16 25 36 49 64 81

Opérateurs splat¶

  • Et contrairement aux paramètres, on peut appliquer le splat à plusieurs arguments
  • On peut aussi préciser d'autres arguments
In [63]:
>>> my_sum(*range(5), 10, *range(3))
Out[63]:
23

Opérateurs splat¶

  • Quant à ** il s'applique à un dictionnaire (ou similaire) et transforme les éléments en arguments nommés
  • Les clés du dictionnaires doivent alors être des chaînes de caractères
In [64]:
>>> addition(**{'a': 3, 'b': 5})
Out[64]:
8

Unpacking¶

  • On retrouve d'ailleurs aussi l'opérateur splat dans les opérations d'unpacking
  • L'unpacking consiste à extraire des valeurs depuis un itérable à l'aide d'une assignation spéciale
In [65]:
>>> first, *middle, last = *range(5), 8, *range(3)
In [66]:
>>> first
Out[66]:
0
In [67]:
>>> middle
Out[67]:
[1, 2, 3, 4, 8, 0, 1]
In [68]:
>>> last
Out[68]:
2

Signatures de fonctions¶

Signatures de fonctions¶

  • La signature d'une fonction représente son interface, décrivant comment on peut l'appeler
  • La fonction signature du module inspect permet de récupérer la signature d'une fonction
In [69]:
>>> import inspect
>>> inspect.signature(addition)
Out[69]:
<Signature (a, b)>
  • On voit ainsi que notre fonction addition attend deux paramètres a et b

Signatures¶

  • L'object renvoyé par inspect.signature permet d'explorer la signature de la fonction
In [70]:
>>> sig = inspect.signature(addition)
>>> sig.parameters
Out[70]:
mappingproxy({'a': <Parameter "a">, 'b': <Parameter "b">})
In [71]:
>>> sig.parameters.keys()
Out[71]:
odict_keys(['a', 'b'])
In [72]:
>>> sig.parameters['a']
Out[72]:
<Parameter "a">
In [73]:
>>> sig.parameters['a'].kind
Out[73]:
<_ParameterKind.POSITIONAL_OR_KEYWORD: 1>

Signatures¶

  • On peut aussi connaître la valeur par défaut d'un paramètre
In [74]:
>>> sigmul = inspect.signature(multiplication)
>>> sigmul.parameters['b']
Out[74]:
<Parameter "b=1">
In [75]:
>>> sigmul.parameters['b'].default
Out[75]:
1

Binding¶

  • On peut utiliser une signature pour réaliser un binding sur des arguments
  • C'est-à-dire faire la correspondance entre les arguments et les paramètres
In [76]:
>>> binding = sig.bind(3, b=5)
>>> binding
Out[76]:
<BoundArguments (a=3, b=5)>
In [77]:
>>> binding.arguments
Out[77]:
{'a': 3, 'b': 5}
In [78]:
>>> binding.args, binding.kwargs
Out[78]:
((3, 5), {})
  • C'est une manière de normaliser les arguments passés lors d'un appel
    • Tous ceux qui peuvent être positionnels sont stockés dans args et les autres dans kwargs

Binding¶

  • Les mêmes vérifications ont lieu que lors d'un appel
  • Il faut ainsi préciser tous les arguments nécessaires à l'appel
In [79]:
>>> sig.bind(5)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[79], line 1
----> 1 sig.bind(5)

File /usr/lib/python3.10/inspect.py:3182, in Signature.bind(self, *args, **kwargs)
   3177 def bind(self, /, *args, **kwargs):
   3178     """Get a BoundArguments object, that maps the passed `args`
   3179     and `kwargs` to the function's signature.  Raises `TypeError`
   3180     if the passed arguments can not be bound.
   3181     """
-> 3182     return self._bind(args, kwargs)

File /usr/lib/python3.10/inspect.py:3097, in Signature._bind(self, args, kwargs, partial)
   3095                 msg = 'missing a required argument: {arg!r}'
   3096                 msg = msg.format(arg=param.name)
-> 3097                 raise TypeError(msg) from None
   3098 else:
   3099     # We have a positional argument to process
   3100     try:

TypeError: missing a required argument: 'b'

Binding¶

  • Il est aussi possible d'appliquer les valeurs par défaut des paramètres sur un binding
In [80]:
>>> binding = sigmul.bind(10)
>>> binding
Out[80]:
<BoundArguments (a=10)>
In [81]:
>>> binding.apply_defaults()
>>> binding
Out[81]:
<BoundArguments (a=10, b=1)>

Modification de signature¶

  • L'objet signature en lui-même est inaltérable
  • Mais il possède une méthode replace pour en créer une copie modifiée
In [82]:
>>> sig.replace(parameters=[])
Out[82]:
<Signature ()>

Modification de signature¶

  • Il en est de même pour les objets représentant les paramètres
In [83]:
>>> from inspect import Parameter
>>> sig.parameters['a'].replace(kind=Parameter.POSITIONAL_ONLY)
Out[83]:
<Parameter "a">

Modification de signature¶

  • On peut alors dériver notre signature pour n'accepter que les arguments positionnels
In [84]:
>>> newsig = sig.replace(parameters=[p.replace(kind=Parameter.POSITIONAL_ONLY) for p in sig.parameters.values()])
>>> newsig
Out[84]:
<Signature (a, b, /)>
In [85]:
>>> newsig.bind(1, 2)
Out[85]:
<BoundArguments (a=1, b=2)>
In [86]:
>>> newsig.bind(a=1, b=2)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[86], line 1
----> 1 newsig.bind(a=1, b=2)

File /usr/lib/python3.10/inspect.py:3182, in Signature.bind(self, *args, **kwargs)
   3177 def bind(self, /, *args, **kwargs):
   3178     """Get a BoundArguments object, that maps the passed `args`
   3179     and `kwargs` to the function's signature.  Raises `TypeError`
   3180     if the passed arguments can not be bound.
   3181     """
-> 3182     return self._bind(args, kwargs)

File /usr/lib/python3.10/inspect.py:3078, in Signature._bind(self, args, kwargs, partial)
   3075     msg = '{arg!r} parameter is positional only, ' \
   3076           'but was passed as a keyword'
   3077     msg = msg.format(arg=param.name)
-> 3078     raise TypeError(msg) from None
   3079 parameters_ex = (param,)
   3080 break

TypeError: 'a' parameter is positional only, but was passed as a keyword

Modification¶

  • On peut ensuite assigner la nouvelle signature à l'attribut __signature__ de la fonction
In [87]:
>>> addition.__signature__ = newsig
  • Cela change bien la signature renvoyée par inspect.signature
In [88]:
>>> inspect.signature(addition)
Out[88]:
<Signature (a, b, /)>
  • Mais n'affecte pas le comportement réel de la fonction
In [89]:
>>> addition(a=1, b=2)
Out[89]:
3

Annotations¶

  • La signature d'une fonction comprend aussi les annotations de paramètres et de retour
  • Les annotations permettent de préciser les types attendus
In [90]:
def addition(a: int, b: int) -> int:
    return a + b
In [91]:
>>> sig = inspect.signature(addition)
>>> sig
Out[91]:
<Signature (a: int, b: int) -> int>

Annotations¶

  • Les annotations sont exposées dans les attributs de la signature
In [92]:
>>> sig.return_annotation
Out[92]:
int
In [93]:
>>> sig.parameters['a'].annotation
Out[93]:
int

Annotations¶

  • Elles sont aussi exposées dans l'attribut spécial __annotations__ de la fonction
In [94]:
>>> addition.__annotations__
Out[94]:
{'a': int, 'b': int, 'return': int}
  • Ainsi que via inspect.get_annotations
In [95]:
>>> inspect.get_annotations(addition)
Out[95]:
{'a': int, 'b': int, 'return': int}

Annotations¶

  • inspect.get_annotations est préférable
    • Elle gère les cas d'absence de __annotations__ sur l'objet et différents problèmes potentiels
    • Elle permet l'évaluation dynamique des annotations

Annotations¶

In [96]:
def addition(a: "int", b: "int") -> "int":
    return a + b
In [97]:
>>> addition.__annotations__
Out[97]:
{'a': 'int', 'b': 'int', 'return': 'int'}
In [98]:
>>> inspect.get_annotations(addition)
Out[98]:
{'a': 'int', 'b': 'int', 'return': 'int'}
In [99]:
>>> inspect.get_annotations(addition, eval_str=True)
Out[99]:
{'a': int, 'b': int, 'return': int}

Documentation¶

  • La documentation permet d'expliciter le comportement d'une fonction
  • Cela se fait en Python à l'aide de docstrings
In [100]:
def addition(a: int, b: int) -> int:
    "Return the sum of two integers"
    return a + b
  • Celle-ci n'est pas exposée dans la signature

Documentation¶

  • La docstring est exposée dans l'argument __doc__ de la fonction
In [101]:
>>> addition.__doc__
Out[101]:
'Return the sum of two integers'
  • Ou via inspect.getdoc
In [102]:
>>> inspect.getdoc(addition)
Out[102]:
'Return the sum of two integers'

Documentation¶

  • Cette dernière est préférable pour un meilleur formattage
In [103]:
def function():
    """
    Docstring of the function
    on multiple lines
    """
In [104]:
>>> function.__doc__
Out[104]:
'\n    Docstring of the function\n    on multiple lines\n    '
In [105]:
>>> inspect.getdoc(function)
Out[105]:
'Docstring of the function\non multiple lines'

Décorateurs¶

Décorateurs¶

  • Les décorateurs sont un mécanisme permettant de transformer des callables
  • Ils s'appliquent à des fonctions pour en modifier le comportement
In [106]:
import functools

@functools.cache
def addition(a, b):
    print(f'Computing {a}+{b}')
    return a + b
In [107]:
>>> addition(3, 5)
Computing 3+5
Out[107]:
8
In [109]:
>>> addition(1, 2)
Out[109]:
3

Décorateurs¶

  • functools.cache a remplacé addition par une nouvelle fonction avec un mécanisme de cache
  • La syntaxe précédente est équivalente à :
In [110]:
def addition(a, b):
    print(f'Computing {a}+{b}')
    return a + b

addition = functools.cache(addition)

Décorateurs¶

  • On peut aussi appliquer plusieurs décorateurs à la suite

    @deco1
    @deco2
    def function():
        ...
    
  • Qui est équivalent à :

    def function():
        ...
    
    function = deco1(deco2(function))
    

Écriture de décorateurs¶

  • Un décorateur est donc un callable qui reçoit un callable et renvoie un callable
  • 🤯
In [111]:
def decorator(func):
    return func
In [112]:
@decorator
def addition(a, b):
    return a + b
In [113]:
>>> addition(3, 5)
Out[113]:
8

Écriture de décorateurs¶

  • Pour changer le comportement de la fonction décorée, il faut créer une nouvelle fonction reprenant son interface
In [114]:
def decorator(func):
    def wrapper(*args, **kwargs):
        print('Calling decorated function')
        return func(*args, **kwargs)
    return wrapper
In [115]:
@decorator
def addition(a, b):
    return a + b
In [116]:
>>> addition(3, 5)
Calling decorated function
Out[116]:
8

Écriture de décorateurs¶

  • functools.cache pourrait être réécrit ainsi
In [117]:
def cache(func):
    func_cache = {}

    def wrapper(*args, **kwargs):
        # make an hashable key
        key = args, tuple(kwargs.items())
        if key not in func_cache:
            func_cache[key] = func(*args, **kwargs)
        return func_cache[key]

    return wrapper
In [118]:
@cache
def addition(a, b):
    print(f'Computing {a}+{b}')
    return a + b
In [119]:
>>> addition(3, 5)
Computing 3+5
Out[119]:
8

Décorateurs paramétrés¶

  • Un décorateur ne peut pas être paramétré à proprement parler
  • Mais on peut appeler une fonction renvoyant un décorateur
In [120]:
@functools.lru_cache(maxsize=1)
def addition(a, b):
    print(f'Computing {a}+{b}')
    return a + b
In [121]:
>>> addition(3, 5)
Computing 3+5
Out[121]:
8
In [123]:
>>> addition(1, 2)
Out[123]:
3

Décorateurs paramétrés¶

  • Un décorateur paramétré est ainsi un callable renvoyant un callable recevant un callable et renvoyant à nouveau un callable
  • 🤯 🤯 🤯
In [124]:
def param_decorator(n):
    def decorator(func):
        def wrapper(*args, **kwargs):
            print(f'Function decorated with {n}')
            return func(*args, **kwargs)
        return wrapper
    return decorator
In [125]:
@param_decorator(42)
def function():
    ...
In [126]:
>>> function()
Function decorated with 42

Outils¶

Outils¶

  • Python propose différents outils autour des callables
  • Afin de les identifier et les manipuler

Builtins¶

  • La fonction callable permet de savoir si un objet est callable
In [127]:
>>> callable(len)
Out[127]:
True
In [128]:
>>> callable(str)
Out[128]:
True
In [129]:
>>> callable(str.replace)
Out[129]:
True
In [130]:
>>> callable(lambda: True)
Out[130]:
True
In [131]:
>>> callable(5)
Out[131]:
False

Module functools¶

  • On a vu functools.partial pour l'application partielle
  • Elle gère les arguments positionnels et nommés
In [132]:
import functools

debug = functools.partial(print, '[DEBUG]', sep=' - ')
In [133]:
debug(1, 2, 3)
[DEBUG] - 1 - 2 - 3

Module functools¶

  • On a écrit plus tôt des décorateurs qui enrobaient nos fonctions
In [134]:
@decorator
def addition(a: int, b: int) -> int:
    "Return the sum of two integers"
    return a + b
  • Le problème est qu'on perd la signature et la documentation de la fonction initiale
In [135]:
>>> inspect.signature(addition)
Out[135]:
<Signature (*args, **kwargs)>
In [136]:
>>> inspect.getdoc(addition)

Module functools¶

  • On pourrait copier manuellement __doc__, __signature__ et autres
  • Mais functools fournit une fonction update_wrapper pour faire cela plus simplement
In [137]:
def decorator(func):
    def wrapper(*args, **kwargs):
        print('Calling decorated function')
        return func(*args, **kwargs)
    functools.update_wrapper(wrapper, func)
    return wrapper

Module functools¶

In [138]:
@decorator
def addition(a: int, b: int) -> int:
    "Return the sum of two integers"
    return a + b
In [139]:
>>> inspect.signature(addition)
Out[139]:
<Signature (a: int, b: int) -> int>
In [140]:
>>> inspect.getdoc(addition)
Out[140]:
'Return the sum of two integers'

Module functools¶

  • Cela fonctionne entre-autres en assignant un attribut __wrapped__ à notre fonction
In [141]:
>>> addition.__wrapped__
Out[141]:
<function __main__.addition(a: int, b: int) -> int>

Module functools¶

  • functools possède aussi un décorateur wraps pour faciliter cela
In [142]:
def decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print('Calling decorated function')
        return func(*args, **kwargs)
    return wrapper

Module operator¶

  • Le module operator expose les différents opérateurs du langage
  • operator.call permet notamment d'appeler un callable (Python 3.11)
In [ ]:
>>> import operator
>>> operator.call(addition, 1, 2)

Module operator¶

  • La fonction itemgetter renvoie un callable pour récupérer un élément d'un conteneur donné
In [144]:
>>> get_name = operator.itemgetter('name')
>>> get_name({'name': 'John'})
Out[144]:
'John'
In [145]:
>>> get_fullname = operator.itemgetter('firstname', 'lastname')
>>> get_fullname({'firstname': 'Jude', 'lastname': 'Doe'})
Out[145]:
('Jude', 'Doe')

Module operator¶

  • Ainsi que la fonction attrgetter pour récupérer un attribut
In [146]:
>>> get_module = operator.attrgetter('__module__')
>>> get_module(int)
Out[146]:
'builtins'

Module operator¶

  • Et methodcaller pour appeler une méthode sur un objet donné
In [147]:
>>> replace = operator.methodcaller('replace', 'o', 'a')
>>> replace('toto')
Out[147]:
'tata'

Module inspect¶

  • Et pour rappel le module inspect dédié à l'introspection
  • inspect.isfunction
  • inspect.getsource

Callables¶

Callables¶

  • La builtin callable permet de tester si un objet est callable
  • Celle-ci vérifie qu'un objet possède une méthode __call__
  • Un callable est ainsi un objet avec une telle méthode

Callables¶

In [148]:
class Adder:
    def __init__(self, add):
        self.add = add

    def __call__(self, x):
        return self.add + x
In [149]:
>>> add_5 = Adder(5)
>>> callable(add_5)
Out[149]:
True
In [150]:
>>> add_5(3)
Out[150]:
8

Construction de callable¶

  • On peut alors imaginer construire une fonction à partir d'objets callables
In [151]:
class Expr:
    def __call__(self, **env):
        raise NotImplementedError

Construction de callable¶

In [152]:
class Scalar(Expr):
    def __init__(self, value):
        self.value = value

    def __repr__(self):
        return repr(self.value)

    def __call__(self, **env):
        return self.value

Construction de callable¶

In [153]:
class Operation(Expr):
    def __init__(self, op_func, op_repr, *args):
        self.func = op_func
        self.repr = op_repr
        self.args = args

    def __repr__(self):
        return self.repr(*self.args)

    def __call__(self, **env):
        values = (arg(**env) for arg in self.args)
        return self.func(*values)

Construction de callable¶

In [154]:
class Symbol(Expr):
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return self.name

    def __call__(self, **env):
        if self.name in env:
            return env[self.name]
        return self

Construction de callable¶

In [155]:
def make_op(op_func, op_fmt):
    return functools.partial(Operation, op_func, op_fmt)
In [156]:
add = make_op(operator.add, '{} + {}'.format)
sub = make_op(operator.sub, '{} - {}'.format)
mul = make_op(operator.mul, '{} * {}'.format)
div = make_op(operator.truediv, '{} / {}'.format)
fdiv = make_op(operator.floordiv, '{} // {}'.format)
mod = make_op(operator.mod, '{} % {}'.format)
pow = make_op(operator.pow, '{} ** {}'.format)

Construction de callable¶

In [157]:
>>> expr = add(pow(Symbol('x'), Scalar(2)), Scalar(5))
>>> expr
Out[157]:
x ** 2 + 5
In [158]:
>>> expr(x=3)
Out[158]:
14

Construction de callable¶

In [159]:
def function(func):
    def op_repr(*args):
        return f"{func.__name__}({', '.join(repr(arg) for arg in args)})"
    return functools.partial(Operation, func, op_repr)
In [160]:
>>> import math
>>> expr = function(math.cos)(mul(Symbol('x'), Scalar(math.pi)))
>>> expr
Out[160]:
cos(x * 3.141592653589793)
In [161]:
>>> expr(x=1)
Out[161]:
-1.0

Construction de callable¶

In [162]:
def ensure_expr(x):
    if isinstance(x, Expr):
        return x
    return Scalar(x)
In [163]:
def binop(op_func):
    return lambda lhs, rhs: op_func(ensure_expr(lhs), ensure_expr(rhs))

def rev_binop(op_func):
    return lambda rhs, lhs: op_func(ensure_expr(lhs), ensure_expr(rhs))

Expr.__add__ = binop(add)
Expr.__radd__ = rev_binop(add)
Expr.__sub__ = binop(sub)
Expr.__rsub__ = rev_binop(sub)
Expr.__mul__ = binop(mul)
Expr.__rmul__ = rev_binop(mul)
Expr.__truediv__ = binop(div)
Expr.__rtruediv__ = rev_binop(div)
Expr.__floordiv__ = binop(fdiv)
Expr.__rfloordiv__ = rev_binop(fdiv)
Expr.__mod__ = binop(mod)
Expr.__rmod__ = rev_binop(mod)
Expr.__pow__ = binop(pow)
Expr.__rpow__ = rev_binop(pow)

Construction de callable¶

In [164]:
>>> expr = 3 * Symbol('x') ** 2 + 2
>>> expr
Out[164]:
3 * x ** 2 + 2
In [165]:
>>> expr(x=10)
Out[165]:
302

Construction de fonction¶

Fonctions VS callables¶

  • Si une fonction est un callable, alors elle possède une méthode __call__
  • Mais vers quoi pointe cette méthode ?
In [166]:
>>> addition.__call__
Out[166]:
<method-wrapper '__call__' of function object at 0x7f2c8ae545e0>
In [167]:
>>> addition.__call__(3, 5)
Calling decorated function
Out[167]:
8
In [168]:
>>> addition.__call__.__call__.__call__(3, 5)
Calling decorated function
Out[168]:
8
  • Un autre mécanisme est donc nécessaire

Fonctions VS callables¶

  • Les fonctions possèdent un attribut __code__
  • Celui-ci contient le code (compilé) de la fonction
In [169]:
def function():
    print('hello')
In [170]:
>>> function.__code__
Out[170]:
<code object function at 0x7f2ca010dfd0, file "/tmp/ipykernel_12684/459794336.py", line 1>
  • Ce code est un objet exécutable
In [171]:
>>> exec(function.__code__)
hello
  • Du moins pour les fonctions sans paramètres

Construction de fonctions¶

  • Une fonction est donc un enrobage autour d'un objet code
  • On peut construire une fonction en créant une instance FunctionType du module types
In [172]:
>>> import types
>>> newfunc = types.FunctionType(function.__code__, globals())
In [173]:
>>> newfunc()
hello
  • Mais comment construire un objet code ?

Construction de fonctions¶

  • La fonction compile permet cela
  • À partir d'un AST Python ou de code brut
  • Un nœud ast.FunctionDef est alors nécessaire pour construire une fonction
  • Celui-ci définit le nom, les paramètres et le corps de la fonction

Construction de fonctions¶

In [174]:
>>> import ast
>>> func_body = ast.parse("print('hello')").body
>>> func_body
Out[174]:
[<ast.Expr at 0x7f2ca03cc130>]
In [175]:
>>> fdef = ast.FunctionDef(
...     name='f',
...     args=ast.arguments(posonlyargs=[], args=[], kwonlyargs=[], kw_defaults=[], defaults=[]),
...     body=func_body,
...     lineno=0,
...     col_offset=0,
...     decorator_list=[],
... )
In [176]:
>>> code = compile(ast.Module(body=[fdef], type_ignores=[]), 'x', 'exec')
>>> func_code = code.co_consts[0]
>>> func_code
Out[176]:
<code object f at 0x7f2ca010ddc0, file "x", line 1>
In [177]:
>>> f = types.FunctionType(func_code, {})
>>> f()
hello

Construction de fonctions¶

In [178]:
def create_function(name, body, arg_names):
    function_body = ast.parse(body).body
    args = [ast.arg(arg=arg_name, lineno=0, col_offset=0) for arg_name in arg_names]

    function_def = ast.FunctionDef(
        name=name,
        args=ast.arguments(
            posonlyargs=[],
            args=args,
            kwonlyargs=[],
            defaults=[],
            kw_defaults=[]),
        body = function_body,
        decorator_list=[],
        lineno=0,
        col_offset=0,
    )
    module = compile(ast.Module(body=[function_def], type_ignores=[]), "<string>", "exec")
    function_code = next(c for c in module.co_consts if isinstance(c, types.CodeType))

    return types.FunctionType(function_code, globals())

Construction de fonctions¶

In [179]:
>>> addition = create_function('addition', 'return a + b', ('a', 'b'))
>>> addition
Out[179]:
<function __main__.addition(a, b)>
In [180]:
>>> addition(3, 5)
Out[180]:
8

Conclusion¶

Conclusion¶

In [181]:
conclusion()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[181], line 1
----> 1 conclusion()

NameError: name 'conclusion' is not defined
  • https://github.com/entwanne/presentation_callables

Questions ?¶