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 :

  1. sum ne pourra jamais avoir plus que 2 arguments;
  2. 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 :

  1. d'abord les arguments obligatoires, éventuellement avec des valeurs par défaut ;
  2. 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 ;
  3. ensuite les arguments quelconques ;
  4. 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