Quelques notions avancées en Python
Les fonctions
Les fonctions sont des éléments habituels de tout langage de programmation. En Python elles sont
généralement définies avec le mot clé defcomme montré ci-dessous.
On fait appel à une fonction (c'est-à-dire on l'exécute) en ajoutant des parenthèses derrière son nom, ainsi qu'éventuellement des arguments.
On peut également affecter le résultat d'une fonction à une variable (p.ex. r dans l'exemple ci-dessous)
def sum(x:int, y:int) -> int:
return x + y
if __name__ == "__main__":
print(sum(3,4))
r = sum(3,4)
print(sum(r,r))
1. Les arguments de fonctions
Dans l'exemple précédent, sum prend exactement 2 arguments. Comment peut-on gérer des fonctions dont on ne connaît pas le nombre d'arguments a priori ?
Les arguments par défaut
Il est possible de définir des arguments par défaut pour une fonction prenant un nombre d'arguments bien défini. Par exemple, en modifiant le code ci-dessus on peut obtenir un comportement particulier pour la fonction sum si tous les arguments ne sont pas fournis.
def sum(x:int = 1, y:int = 0) -> int:
return x + y
if __name__ == "__main__":
print(sum())
print(sum(3))
print(sum(3,4))
Il y a deux inconvénients à cette approche :
sumne pourra jamais avoir plus que 2 arguments;- on n'a pas la possibilité d'affecter une valeur au second argument tout en utilisant la valeur par défaut pour le premier.
Les arguments quelconques
Il existe pourtant des fonctions Python, qui ne sont pas restreintes en termes d'arguments. print() en est un exemple.
print()
print(1)
print(1,2)
print(1,2,3,4,5,6)
Il est possible de définir des fonctions avec un nombre d'arguments quelconque en utilisant l'opérateur unaire * (opérateur de unpacking).
def new_sum(*args) -> int:
s = 0
for v in args:
s += v
return v
L'opérateur d'unpacking transforme une liste en série d'arguments.
l = [1, 2, 3]
print(l) # affiche une liste de 3 éléments
print(*l) # affiche chaque élément comme s'il était un argument
Ainsi la fonction new_sum définie ci-dessus peut, entre autres, être utilisée des façons suivantes :
s1 = new_sum() # aucun argument n'est passé
s2 = new_sum(1,2,3) # trois arguments sont passés
l = [2, 3, 4]
s3 = new_sum(1, *l, 5) # cinq arguments sont passés, le premier et le dernier de façon explicite, les autres via l'unpacking de la liste
Les arguments nommés
En plus de permettre un nombre quelconque d'arguments, la fonction print() autorise également des arguments dits nommés, comme dans l'exemple ci-dessous :
print(1,2,3, sep='_', end='//')
1_2_3//
Ni la présence, ni l'ordre des attributs nommés ne sont obligatoires. Le code ci-dessus est donc équivalent à
print(1,2,3, end='//', sep='_')
où l'on a inversé les arguments sep et end.
À l'instar de l'opérateur d'unpacking * opérant sur des listes il existe un second opérateur d'unpacking ** qui fonctionne sur des dictionnaires.
Ainsi on peut définir une fonction de calcul qui prend en argument un opérateur et deux opérandes et calcule le résultat, mais dont il n'est pas forcément nécessaire ni de respecter l'ordre d'appel, ni la présence dans les arguments.
def calcul(**kwargs):
arg1 = kwargs['arg1'] if 'arg1' in kwargs.keys() else 0
arg2 = kwargs['arg2'] if 'arg2' in kwargs.keys() else 0
op = kwargs['operator'] if 'operator' in kwargs.keys() else '+'
if op == '+':
return arg1 + arg2
elif op == '-':
return arg1 - arg2
elif op == '*':
return arg1 * arg2
else:
return 0
if __name__ == "__main__":
a = calcul(arg1=1, arg2=2)
b = calcul(operator='*')
c = calcul(arg2=10, operator='-')
Combiner les différentes formes de passage d'arguments
Une fonction peut combiner l'ensemble des formes de passage d'arguments, à condition de strictement respecter l'ordre suivant :
- d'abord les arguments obligatoires, éventuellement avec des valeurs par défaut ;
- lorsque l'un des arguments obligatoires se voit attribuer une valeur par défaut, tous les autres arguments qui le suivent doivent également avoir une valeur par défaut ;
- ensuite les arguments quelconques ;
- finalement les arguments nommés.
Il n'est pas possible d'inverser les arguments quelconques et les arguments nommés, ni de leur affecter des valeurs par défaut.
def f(a, b=0, *args, **kwargs):
print(a,b,*args)
pass
f(1)
f(2,3)
f(2,3,4,5)
1 0
2 3
2 3 4 5
Les opérateurs d'argument * et /
Les opérateurs * et `/' peuvent être utilisés dans la signature d'une fonction pour affiner l'usage des arguments
nommés.
Leur signification est la suivante :
- tout argument suivant
*est forcément un argument nommé (les arguments précédant le*sont des arguments positionnels ou nommés) - tout argument précédant
/est forcément un argument positionnel (les argumants suivant le/sont des arguments positionnels ou nommés)
Les exemples ci-dessous clarifient leur usage.
def f_keyword(a:int, *, b:int, c:int):
"""
a pourra être positionnel ou nommé (puisque avant le `*`)
b et c sont forcément nommés (puisque après le `*`=
"""
print(f'a={a}, b={b}, c={c}')
# a est utilisé comme argument positionnel
f_keyword(1, b=2, c=3)
# on peut intervertir l'ordre de b et c
f_keyword(1, c=2, b=3)
# a peut être utilisé comme argument nommé
f_keyword(a=1, c=2, b=3)
# on peut intervertir l'ordre de tous les arguments (à condition de les nommer)
f_keyword(b=1, c=2, a=3)
# b et c doivent obligatoirement être nommés
# f_keyword(1, 2, 3) # provoque une erreur
def f_position(a:int, /, b:int, c:int):
"""
a est forcément positionnel (puisque avant le `/`)
b et c pourront être positionnels ou nommés (puisque après le `/`)
"""
print(f'a={a}, b={b}, c={c}')
# a est utilisé comme argument positionnel
f_position(1, b=2, c=3)
# on peut intervertir l'ordre de b et c
f_position(1, c=2, b=3)
# a ne peut pas être utilisé comme argument nommé
# f_position(a=1, c=2, b=3) # provoque une erreur
# b et c peuvent être utilisés de façon positionnelle
f_position(1, 2, 3)
Par défaut, toute déclaration de fonction f(a, b, ...) est équivalente à f(a, b, ..., *). C'est à dire que, par
défaut, les arguments passés peuvent être utilisés comme positionnels ou nommés, comme le montre l'exemple ci-dessous.
def f_quelconque(a:int, b:int, c:int):
print(f'a={a}, b={b}, c={c}')
# tous les arguments peuvent être positionnels
f_quelconque(1, 2, 3)
# tous les arguments peuvent être nommés
f_quelconque(a=1, b=2, c=3)
# l'ordre des arguments nommés peut être quelconque
f_quelconque(b=1, c=2, a=3)
# les arguments non nommés sont forcément positionnels
f_quelconque(1, c=2, b=3)
# les arguments positionnels doivent respecter l'ordre
# f_quelconque(1, 2, b=3) # provoque une erreur puisque `b` est défini deux fois et `c` zéro
2. Le nom d'une fonction est une variable comme les autres
Le nom des fonctions sont des variables, au même titre que les autres.
Ainsi le code ci-dessous est en tout point équivalent au code du début de cette page, à la différence de l'affectation
à une variable avec un autre nom près (ma_somme).
if __name__ == "__main__":
ma_somme = sum
print(ma_somme(3,4))
r = ma_somme(3,4)
print(ma_somme(r,r))
De fait, les variables sum et ma_somme sont de type function.
if __name__ == "__main__":
print(type(sum))
print(type(ma_somme))
Ainsi les fonctions peuvent être stockées et réutilisées.
def mult(x: int, y: int) -> int:
return x*y
if __name__ == "__main__":
# On stocke 3 fonctions dans une liste (sans les exécuter
l = [sum, ma_somme, mult]
i = 1
# On parcourt successivement tous les éléments de la liste
for f in l:
# On exécute l'élément courant de la liste
i += f(i,2)
print i
3. Une fonction peut définir des sous-fonctions locales
Et comme pour des valeurs et objets plus habituels, une fonction peut renvoyer une fonction comme résultat.
Ci-dessous, par exemple la fonction operator renvoie une fonction qui calcule une valeur en fonction de l'opérateur
fourni en entrée.
def operator(op:str):
def plus(x,y):
return x+y
def mult(x,y):
return x*y
def rien(x,y):
return 0
if op == "+":
return plus
elif op == "*":
return mult
else:
return rien
if __name__ == "__main__":
p = operator('+')
print(p(1, 2))
p = operator('*')
print(p(1, 2))
p = operator('#')
print(p(1, 2))
Les expressions lambda
Une fonction n'a pas nécessairement à être définie avec le mot clé def. On peut également construire des fonctions à partir d'une expression avec le mot clé lambda, comme ci-dessous.
if __name__ == "__main__":
# On peut construire des expressions de type fonction
somme = lambda x, y : x+y
print(type(somme))
print(somme(3,4))
Les expressions lambda sont à tout point identique à def mais permettent d'écrire du code plus concis, comme la reprise de la fonction operator définie précédemment.
def operator(op:str):
if op == "+":
return lambda x,y: x+y
elif op == "*":
return lambda x,y: x*y
else:
return lambda x,y:0
if __name__ == "__main__":
plus = operator('+')
mult = operator('*')
rien = operator('truc')
print(plus(3,4))
print(mult(3,4))
print(rien(3,4))
Les fermetures (closures)
Dans l'exemple suivant on crée deux fonctions, p1 et p2 qui respectivement ajoutent 1 et 2 à un argument fourni.
Ce qui est à remarquer est que la valeur v persiste dans la fonction plus malgré le fait que la fonction appelante a terminé son exécution.
Cet effet est appelé fermeture.
# Fermeture simple
def plus_valeur(v: int):
def plus(x):
return x+v
return plus
if __name__ == "__main__":
p1 = plus_valeur(1)
p2 = plus_valeur(2)
print(p1(5))
print(p2(5))
En combinant l'effet de fermeture avec des mutables il est possible de provoquer des effets de bord et de persistance qui deviennent rapidement complexes.
De fait, les fermetures sont une façon élégante d'encapsuler des variables globales et d'éviter une cascade de paramètres multiples dans l'appel de certaines fonctions.
# En jouant avec des mutables
def magique(l : list):
def premier():
return l.pop()
return premier
if __name__ == "__main__":
l = [1, 2, 3, 4]
m = magique(l)
print(m())
print(l)
print(m())
print(l)
print(m())
# Un générateur infini
def générateur():
l = [-1]
def suiv():
l[0] += 1
return l[0]
return suiv
if __name__ == "__main__":
i=générateur()
for _ in range(10):
print(i())
Les décorateurs
Principe général
Maintenant qu'il est possible de manipuler des fonctions comme des variables, et dans des expressions, il devient également possible de redéfinir des fonctions en cours d'exécution.
Par exemple
# La fonction `compteur` prend comme argument une fonction et crée une nouvelle
# fonction qui combine la fonction passée en argument et ajoute un affichage
# un compteur d'exécution à chaque fois qu'elle est exécutée.
def compteur(func):
l = [0]
def c():
l[0]+=1 # incrément du compteur
func() # exécution de la fonction d'origine
print(f'{l[0]} fois') # affichage du compteur
return c
def f():
print("J'exécute f")
if __name__ == "__main__":
# On exécute la fonction f
f()
# On modifie la fonction f
f = compteur(f)
# On exécute la version modifiée de f
f()
f()
Donnera comme résultat
J'exécute f
J'exécute f
1 fois
J'exécute f
2 fois
Python permet de simplifier l'écriture de f = compteur(f) par le biais de décorateurs.
Le code précédent peut donc s'écrire comme suit
# La fonction `compteur` prend comme argument une fonction et crée une nouvelle
# fonction qui combine la fonction passée en argument et ajoute un affichage
# un compteur d'exécution à chaque fois qu'elle est exécutée.
def compteur(func):
l = [0]
def c():
l[0]+=1 # incrément du compteur
func() # exécution de la fonction d'origine
print(f'{l[0]} fois') # affichage du compteur
return c
# On modifie tout de suite le comportement de f
# équivalent à f = compteur(f)
@compteur
def f():
print("J'exécute f")
if __name__ == "__main__":
# On exécute la version modifiée de f
f()
f()
f()
@property et autres accesseurs
Un des usages les plus courants des décorateurs est celui de @property. Il permet de rendre l'utilisation des attributs d'une classe plus élégante en utilisant des getters et setters tout en gardant une notation légère.
Par exemple, le code classique est relativement "lourd" à écrire et à lire du fait des appels aux fonctions de getter et de setter (on rappelle, au passage, que les attributs à double underscore sont privés dans Python).
class X:
def __init__(self):
self.__a = 10
def getA(self):
print('getter')
return self.__a
def setA(self, a):
print('setter')
self.__a = a
if __name__ == "__main__":
x = X()
x.setA(100)
print(x.getA())
getter
setter
Une façon plus élégante est la suivante. Elle utilise des décorateurs prédéfinis, permettant d'avoir des
getters et setters propres, tout en gardant la possibilité d'utiliser l'écriture x.a comme s'il s'agissait
d'attributs publics.
class X:
def __init__(self):
self.__a = 10
@property
def a(self):
print('getter')
return self.__a
@a.setter
def a(self, a):
print('setter')
self.__a = a
if __name__ == "__main__":
x = X()
x.a = 100
print(x.a)
getter
setter
A venir
- gestion des packages