Paramètres et décorateurs de fonction en Python

Python permet d'écrire des fonctions comportant de nombreux paramètres qui peuvent avoir des mines des plus patibulaire, surtout si elles sont par ailleurs assorties de décorateurs. Par exemple :
def g (h):
	def k (prompt, *words, **complements):
		if complements['computer'] == 'Atari':
			complements['computer'] = 'Amiga'
		h (prompt, *words, **complements)
	return k
@g
def f (prompt, *words, **complements):
	for key, value in complements.items ():
		print (prompt + ':', ' '.join (words), value)
f ('HAL speaking', 'I', 'love', 'the', computer='Atari', drink='coffee')
HAL speaking: I love the Amiga
HAL speaking: I love the coffee
Il est important de ne pas se laisser abuser par cette apparente complexité. Maîtriser les différentes catégories de paramètres qu'il est possible d'utiliser est essentiel pour exploiter le potentiel de l'écriture de fonctions en Python au quotidien. Quant aux décorateurs, il est sans doute bien moins fréquent d'y avoir recours, mais comme ils peuvent se rencontrer, il est bon de savoir commment ils fonctionnent.

Les types de paramètres

Commençons par nous caler sur le vocabulaire. Selon la terminologie de la documentation de Python :
  • un paramètre est ce qui figure dans la définition d’une fonction (sa signature, ou prototype) ;
  • un argument est une valeur qui figure dans l’appel à cette fonction.
La distinction est importante, car plusieurs arguments peuvent alimenter un même paramètre, comme ce sera expliqué ici.
La documentation de Python contient un didacticiel intéressant sur les fonctions, mais qui peut sembler un peu court au débutant. En particulier, ce passage essentiel peut être lu trop rapidement, étant assez laconique :
Quand un dernier paramètre formel est présent sous la forme **name, il reçoit un dictionnaire contenant tous les arguments nommés à l’exception de ceux correspondant à un paramètre formel. Ceci peut être combiné à un paramètre formel sous la forme *name qui lui reçoit un tuple contenant les arguments positionnés au-delà de la liste des paramètres formels (*name doit être présent avant **name).
De ce fait, il arrive qu'il y règne une certaine confusion dans les esprits entre ce qui est possible pour définir une fonction et ce qui est possible pour l'appeler.
Par ailleurs, la notion de paramètre formel peut être confondue avec celle de paramètre informel, pour deux raisons :
  • Python offre une certaine souplesse dans l'ordre dans lequel les arguments peuvent être mentionnés dans un appel ;
  • un paramètre informel peut être nommé, à la manière d’un paramètre formel prenant un argument par défaut.
Pour bien comprendre comment écrire des fonctions en Python, mieux vaut établir une distinction entre paramètre formel et informel, comme le sous-entend le passage de la documentation cité à l'instant.
Les paramètres formels
Une fonction de base ne comprend que des paramètres formels, c’est-à-dire des paramètres identifiés par des noms qui vont permettre de manipuler les valeurs transmises (les arguments) via des variables dans le corps de la fonction :
def f (subject, verb, complement):
	return (subject + ' ' + verb + ' ' + complement)
print (f ('I', 'love', 'the Amiga'))
I love the Amiga
Un argument peut être une variable ou une constante. Dans le premier cas, la variable est transmise "par valeur" quand elle est de type immuable (la variable n’est pas modifiée au retour de la fonction), et "par adresse" dans le cas contraire (la variable est modifiée au retour de la fonction) :
def f (s, a):
	s += ', world!'				# N'affecte pas la chaîne du programme principal, car une chaîne est immuable
	a.append (', world!')		# Affecte le tableau du programme principal, car un tableau n'est pas immuable
s = 'Hello'
a = ['Hello']
f (', world!', a)
print (s)
print (' '.join (a))
Hello
Hello, world!
L'argument d'un paramètre formel parvient à la fonction à la position que ce paramètre occupe dans la définition de la fonction. On parle de paramètre formel positionnel.
Tous les paramètres formels ne sont pas positionnels. En effet, un paramètre formel peut prendre un argument par défaut, ce qui permet de mentionner ou non cet argument dans l'appel. Or dans l'appel, l'argument peut être mentionné :
  • sans nommer son paramètre, mais il doit alors être mentionné à la position que le paramètre occupe dans la définition, comme l'argument de n’importe quel paramètre formel positionnel ;
  • en nommant son paramètre, ce qui permet de mentionner l'argument à n'importe quelle position pour autant que ce soit après les arguments des paramètres formels positionnels.
def f (subject='I', verb='have', complement='a computer'):
	print (subject, verb, complement)
f ()
f ('We', complement ='a supercomputer')
f (verb='bought', subject='We')
f (complement ='a megacomputer', 'They')
I have a computer
We have a supercomputer
We bought a computer
SyntaxError: positional paramètre follows keyword paramètre
L'argument par défaut d'un paramètre formel peut être dynamique. Toutefois, cet argument ne sera évaluée qu’une fois, au moment où la définition de la fonction sera rencontrée :
i = 10
def f (v = i):
	print (v)
f ()
i = 100
f ()
10
10
Cette limitation peut être contournée selon le type de l'argument. En effet, l'argument peut être un objet qui évolue :
def f (i, v = []):
	v.append (i)		# Rajouter à v la valeur i à chaque appel
	print (v)
f (10)
f (100)
[10]
[10, 100]
Les paramètres informels
En plus de paramètres formels (dont il est donc possible d'omettre l'argument dans l'appel, s'ils prennent un argument par défaut), la définition d'une fonction peut mentionner des paramètres informels, c’est-à-dire des paramètres dont la liste dépend des arguments fournis lors de l'appel.
Dans la définition de la fonction, l'existence éventuelle de tels paramètres informels est mentionnée à l'aide d’un seul paramètre précédé de *, qui figure en dernier. Cela signifie que dans l'appel, les arguments qui doivent être réunis dans le tuple doivent être fournis en dernier :
def f (subject, verb, *args):
	print (subject, verb, ' '.join (args))
f ('I', 'love', 'the', 'Amiga')
I love the Amiga
Attention à ne pas confondre l'usage de * dans la définition d'une fonction pour mentionner la présence de paramètres informels, d’une part, et dans l'appel à cette dernière pour déballer des arguments destinés à alimenter des paramètres formels et/ou informels (comme expliqué ici), d’autre part. Il est parfaitement possible d’utiliser l’une et l’autre de ces fonctionnalités simultanément :
def f (subject, verb, *args):
	print (subject, verb, ' '.join (args))
words = 'I', 'love', 'the’, ‘Amiga'		# ‘the’ et ‘Amiga’ seront informels
f (*words)
I love the Amiga
La règle concernant la position dernière des paramètres informels dans la définition de la fonction souffre une exception. En effet, comme déjà expliqué, il est possible de mentionner la présence de paramètres formels nommés. Or ces paramètres doivent toujours être mentionnés après les paramètres informels dans la définition :
def f (subject, *args, verb='love'):
	print (subject, verb, ' '.join (args))
f ('I', 'the', 'Amiga', verb='love')
I love the Amiga
Noter que cela n'offre aucune souplesse dans l'appel, car le positionnement des arguments doit respecter celui des paramètres. Autrement dit, les arguments des paramètres formels nommés doivent être fournis en dernier :
f ('I', verb='love', 'the', 'Amiga')
SyntaxError: positional paramètre follows keyword paramètre
Toutefois, comme un paramètre formel, un paramètre informel peut être nommé - s’agissant d’un paramètre informel, ce sera évidemment lors de l’appel en associant l'argument à un nom. Dans ce cas, une fonction accède aux arguments transmis non via un tuple, mais via un dictionnaire. Dans la définition de la fonction, ce dictionnaire, c'est-à-dire le paramètre qui regroupe les paramètres informels nommés, doit toujours être mentionné en dernier :
def (subject, **kwargs):
	print (subject, kwargs['verb'], kwargs['completement']);
f ('I', verb='love', complement='the Amiga')
I love the Amiga
Noter qu’il n'y a aucune confusion possible entre les arguments des paramètres formels nommés et ceux des paramètres informels nommés, car la priorité est donnée aux premiers lors de l’appel :
def f (subject, verb='love', **kwargs):
	print (subject, verb, kwargs['complement'])
f ('I', verb='love', complement='the Amiga')
I love the Amiga
D'ailleurs, cela permet de fournir les arguments des paramètres formels nommés après ceux des paramètres informels nommés :
f ('I', complement='the Amiga', verb='love')
I love the Amiga
La définition d'une fonction peut mentionner des paramètres informels nommés, et d'autres qui ne le sont pas. Lors d'un appel, les arguments des premiers parviendront à la fonction via un dictionnaire, et les seconds via un tuple :
def f (subject, *args, **kwargs):
	print (subject, ' '.join (args), kwargs['complement'])
f ('I', 'love', 'the', complement='Amiga')
I love the Amiga
Noter qu'il est impossible de mentionner alors aussi la présence de paramètres formels nommés dans la définition de la fonction. Aucun moyen de faire marcher ça :
def f (subject, verb='love', *args, **kwargs):
	print (subject, ' '.join (args), kwargs['complement'])
f ('I', verb='love', 'the', 'famous', complement='Amiga')
Faut pas pousser...

Les décorateurs

En plus de la souplesse offerte pour définir une fonction, Python permet recourir à des décorateurs pour modifier le comportement d’une fonction après qu’elle a été définie.
Un décorateur est une fonction g () qui retourne une fonction h (), dite wrapper, à la place d’une fonction f () lorsque cette dernière est appelée. En ce sens, c’est du sucre syntaxique.
Il est possible de décorer f () avec g () en précédant la définition de f () par @g. Le décorateur est appelé lorsque la définition de la fonction qu’il décore est rencontrée. La fonction h () retournée par g () accède aux paramètres transmis à f () en adoptant le même prototype :
def g (someFunction):
	def h (computer):
		someFunction ('Amiga' if computer == 'Atari' else computer)
	return h
@g
def f (computer):
	print ('I love the', computer)
f ('Atari')
I love the Amiga
Dans l’exemple précédent, le wrapper utilise la fonction décorée, mais il pourrait tout aussi bien ne pas le faire. Tout dépend de la manière dont on veut altérer l’appel à la fonction décorée.
Le décorateur peut accepter des paramètres. Il doit alors retourner une fonction qui fait le travail à sa place. Autrement dit, tout se passe comme si g () prenant des paramètres, c’est donc qu’il ne pourrait pas prendre f () en paramètre. Partant, g () devrait retourner h () qui peut prendre f () en paramètre, et qui retournerait un wrapper k (). Ce wrapper peut faire référence aux paramètres transmis à g () comme si c’était des variables locales, car il a accès à l’espace de noms de g () :
def g (model):
	def h (someFunction):
		def k (computer):
			s = 'Amiga' if computer == 'Atari' else computer
			someFunction (s + ' ' + model)
		return k
	return h
@g ('500')
def f (computer):
	print ('I love the', computer)
I love the Amiga 500
Mais si un décorateur est utilisé pour substituer h () à f (), ce n’est donc plus la même fonction qu’il faut s’attendre à utiliser. En particulier, des métadonnées sur f () seront perdues, donc son nom (__name__) et sa documentation (__doc__) :
def g (someFunction):
	def h ():
		"""This is function h () docstring"""
		pass
	return h
@g
def f ():
	"""This is function f () docstring"""
	pass
print (f.__name__, ":", f.__doc__)
h : This is function h () docstring
Le décorateur wraps () du package functools permet de l’éviter en reprenant dans h () ces métadonnées :
from functools import wraps
def g (someFunction):
	@wraps (someFunction)
	def h ():
		pass
	return h
@g
def f ():
	pass
print (f.__name__)
f : This is function f () docstring
Pourquoi utiliser wraps () ? Visiblement pour rien d’autre que pour la documentation...
Plusieurs décorateurs peuvent être chaînés :
def trashAtari (someFunction):
	def g (computer):
		someFunction ('Amiga' if computer == 'Atari' else computer)
	return g
def capitalizeComputer (someFunction):
	def h (computer):
		someFunction (computer.upper ())
	return h
@trashAtari
@capitalizeComputer
def f (computer):
	print ('I love the', computer)
f ('Atari')
I love the AMIGA
Tout comme il est possible de décorer une fonction, il est possible de décorer une méthode d’une classe.
Enfin, des décorateurs sont prédéfinis :
@staticmethod
@classmethod
@property
@showdoc
@announce
@classmethod permet de modifier une méthode de sorte qu’elle reçoive une référence sur la classe et non sur l’instance, et @staticmethod permet de modifier une méthode de sorte qu’elle ne reçoive pas de référence sur l’instance :
class Thing:
	def f (*args):
		print (args)
	@classmethod
	def g (*args):
		print (args)
	@staticmethod
	def h (*args):
		print (args)
Thing.f ()			# f () ne reçoit rien
Thing.g ()			# g () reçoit une référence sur la classe
Thing.h ()			# h () ne reçoit rien
t = Thing ()
t.f ()				# f () reçoit une référence sur l'instance
t.g ()				# g () reçoit une référence sur la classe
t.h ()				# h () ne reçoit rien
()
(<class '__main__.Thing'>,)
()
(<__main__.Thing object at 0x000000000269CFD0>,)
(<class '__main__.Thing'>,)
()
@classmethod est utilisé pour créer des constructeurs alternatifs :
class Thing:
	def __init__ (self, name):
		self.name = name

	@classmethod
	def fromAnotherThing (cls, t):
		t = cls (t.name)
		return t

t0 = Thing ('The thing')
t1 = Thing.fromAnotherThing (t0)
print (t1.name)
The thing
@staticmethod permet de regrouper des méthodes dans une classe tout en indiquant qu’elle n’est donc pas dépendante de la classe : une forme de packaging.
Les occasions d’utiliser @classmethod et @staticmethod sont certainement des plus rares.
Paramètres et décorateurs de fonction en Python