TD3 : Fonctions

Introduction

Se familiariser avec la lecture et l'écriture de fonctions Python avec différents types de paramètres formels, explorer certaines nuances de la sémantique d'exécution des fonctions et plonger dans l'intérieur des fonctions.

Ce TD est particulièrement axé sur la sémantique Python, ce qui peut ne pas sembler passionnant au premier abord. Cependant, la maîtrise de la mécanique des fonctions Python vous donne accès à tout un tas d'outils puissants qui soit n'existent pas, soit sont peu connus ou difficiles à utiliser dans d'autres langages. Les compétences acquises dans TD vous permettront d'écrire (et de déboguer) des codes Python puissants rapidement et facilement !

Comme pour le TD2, nous ne nous attendons pas à ce que vous terminiez tout les exercices pendant la séance de TD. Si vous le faites, c'est parfait! Mais sinon, nous vous encourageons à travailler chez vous à votre propre rythme sur le reste du TD - il explore les aspects intéressants et intrigants des fonctions Python.

Révision

Comme toujours, consultez les diapositives du cours si vous avez besoin.

Explorer les arguments et les paramètres

Fonctions familières

Considérons la fonction suivante :

def print_two(a, b) :
    print("Arguments : {0} et {1} ".format(a, b))

Pour chacun des appels de fonction suivants, prévoyez si l'appel est valide ou non. S'il est valide, quel sera le résultat ? S'il n'est pas valide, quelle est la cause de l'erreur ?

*Note : faites vos prédictions avant d'exécuter le code dans l'interpréteur interactif. Vérifiez ensuite vous-même!*

# Valide ou non-valide ?
print_two()
print_two(4, 1)
print_two(41)
print_two(a=4, 1)
print_two(4, a=1)
print_two(4, 1, 1)
print_two(b=4, 1)
print_two(a=4, b=1)
print_two(b=1, a=4)
print_two(1, a=1)
print_two(4, 1, b=1)

Rédigez au moins deux autres exemples d'appels de fonction, non énumérés ci-dessus, et prévoyez leur résultat. Sont-ils valides ou non? Vérifiez votre hypothèse.

Ces problèmes d'écriture supplémentaire vous permettent de clarifier votre propre compréhension de la sémantique des appels de fonction. Vous pouvez les ignorer si vous le souhaitez, mais l'utilisation de l'interpréteur interactif pour tester vos propres hypothèses est une compétence Python cruciale qui vous permet de répondre à des questions de la forme "Mais que se passe-t-il si je... "

Arguments par défaut

Considérons la fonction suivante :

def keyword_args(a, b=1, c='X', d=None) :
    print("a :", a)
    print("b :", b)
    print("c :", c)
    print("d :", d)

Pour chacun des appels de fonction suivants, prévoyez si l'appel est valide ou non. S'il est valide, quel sera le résultat? S'il n'est pas valide, quelle est la cause de l'erreur?

keyword_args(5)
keyword_args(a=5)
keyword_args(5, 8)
keyword_args(5, 2, c=4)
keyword_args(5, 0, 1)
keyword_args(5, 2, d=8, c=4)
keyword_args(5, 2, 0, 1, "")
keyword_args(c=7, 1)
keyword_args(c=7, a=1)
keyword_args(5, 2, [], 5)
keyword_args(1, 7, e=6)
keyword_args(1, c=7)
keyword_args(5, 2, b=4)

Rédigez au moins deux autres exemples d'appels de fonction, non énumérés ci-dessus, et prévoyez leur résultat. Sont-ils valides ou non? Vérifiez votre hypothèse.

Explorer les listes d'arguments variadiques

Comme précédemment, considérez la définition de fonction suivante :

def variadic(*args, **kwargs) :
    print("Positional :", args)
    print("Keyword :", kwargs)

Pour chacun des appels de fonction suivants, prévoyez si l'appel est valide ou non. S'il est valide, quel sera le résultat? S'il n'est pas valide, quelle est la cause de l'erreur ?

variadic(2, 3, 5, 7)
variadic(1, 1, n=1)
variadic(n=1, 2, 3)
variadic()
variadic(inf="Informatique", asr="ASR")
variadic(inf="Informatique", inf="InFo", inf="INF")
variadic(5, 8, k=1, swap=2)
variadic(8, *[3, 4, 5], k=1, **{'a':5, 'b':'x'})
variadic(*[8, 3], *[4, 5], k=1, **{'a':5, 'b':'x'})
variadic(*[3, 4, 5], 8, *(4, 1), k=1, **{'a':5, 'b':'x'})
variadic({'a':5, 'b':'x'}, *{'a':5, 'b':'x'}, **{'a':5, 'b':'x'})

Rédigez au moins deux autres exemples d'appels de fonction, non énumérés ci-dessus, et prévoyez leur résultat. Sont-ils valides ou non? Vérifiez votre hypothèse.

Mettre tout cela ensemble

Souvent, cependant, nous ne voyons pas seulement des arguments de mots-clés de listes de paramètres variadiques dans des situations isolées. La définition de fonction suivante, qui comprend des paramètres de position, des paramètres de mots-clés, des paramètres de position variadiques, des paramètres par défaut de mots-clés uniquement et des paramètres de mots-clés variadiques, est un Python valide.

def all_together(x, y, z=1, *nums, indent=True, spaces=4, **options) :
    print("x :", x)
    print("y :", y)
    print("z :", z)
    print("nums :", nums)
    print("indent :", indent)
    print ("spaces :", spaces)
    print("options :", options)

Pour chacun des appels de fonction suivants, prévoyez si l'appel est valide ou non. S'il est valide, quel sera le résultat? S'il n'est pas valide, quelle est la cause de l'erreur?

all_together(2)
all_together(2, 5, 7, 8, indent=False)
all_together(2, 5, 7, 6, indent=None)
all_together()
all_together(indent=True, 3, 4, 5)
all_together(**{"indent" : False}, scope="maximum")
all_together(dict(x=0, y=1), *range(10))
all_together(**dict(x=0, y=1), *range(10))
all_together(*range(10), **dict(x=0, y=1))
all_together([1, 2], {3:4})
all_together(8, 9, 10, *[2, 4, 6], x=7, spaces=0, **{'a':5, 'b':'x'})
all_together(8, 9, 10, *[2, 4, 6], spaces=0, **{'a' :[4,5], 'b':'x'})
all_together(8, 9, *[2, 4, 6], *dict(z=1), spaces=0, **{'a' :[4,5], 'b':'x'})

Rédigez au moins deux autres exemples d'appels de fonction, non énumérés ci-dessus, et prévoyez leur résultat. Sont-ils valides ou non ? Vérifiez votre hypothèse.

Écrire des fonctions

parler_enthousiasme

Écrivez une fonction parler_enthousiasme qui prend un argument positionnel obligatoire (un message) et deux arguments optionnels de mot-clé, le premier étant un entier positif se référant au nombre de points d'exclamation à mettre à la fin du message (par défaut à 1), et le second étant un booléen indiquant s'il faut ou non mettre le message en majuscule (par défaut à False).

À quoi ressembleraient la signature et l'implémentation de cette fonction?

def parler_enthousiasme( ???):
    pass

Comment appelleriez-vous cette fonction pour produire les résultats suivants ?

"J'aime Python !"
"Les arguments des mots-clés sont géniaux !!!!"
"Je suppose que Java est bien..."
"ALLONS À EVRY !!"

average

Écrivez une fonction average qui accepte un nombre variable d'arguments positionnels entiers et calcule la moyenne. Si aucun argument n'est fourni, la fonction devrait retourner None.

À quoi ressembleraient la signature et l'implémentation de cette fonction?

def average ( ???):
    pass

Il devrait être possible d'appeler la fonction comme suit :

average() # => Aucune
average(5) # => 5,0
average(6, 8, 9, 11) # => 8,5

Supposons que nous ayons une liste l = [ ???] fournie par l'utilisateur de longueur inconnue. Comment pouvons-nous utiliser la fonction average que nous venons d'écrire pour calculer la moyenne de cette liste? Pour cette seconde moitié du problème, n'utilisez pas les fonctions intégrées sum ou len --- essayez de passer le contenu de l à average.

Nuances des fonctions

Return

Prévoyez le résultat du code suivant. Ensuite, lancez le code pour vérifier votre hypothèse.

def say_hello() :
    print("Bonjour !")

print(say_hello())  # => ?

def echo(arg=None) :
    print("arg :", arg)
    retour arg

print(echo())  # => ?
print(echo(5)) # => ?
print(echo("Hello")) # => ?

def drive(has_car) :
    if not has_car :
        # Ne jamais signaler une erreur de ce genre
        return "Ah non !
    return 100 # km

print(drive(False))  # => ?
print(drive(True))   # => ?

Paramètres et référence de l'objet

Lecture supplémentaire : Blog de Jeff Knupp

Supposons que nous ayons les deux fonctions suivantes :

def reassign(arr) :
    arr = [4, 1]
    print("Inside reassign : arr = {}".format(arr))

def append_one(arr) :
    arr.append(1) 
    print("Inside append_one : arr = {}".format(arr))

Prévoyez ce que le code suivant va produire. Quelle est la différence entre deux parties? Quelle est la cause de cette différence?

l = [4]
print("Avant la réaffectation : arr={}".format(l))  # => ?
reassign(l)
print("Après la réaffectation : arr={}".format(l))  # => ?

l = [4]
print("Avant l'append_one : arr={}".format(l))  # => ?
append_one(l)
print("Après append_one : arr={}".format(l))  # => ?

Portée

Lecture supplémentaire : Python's Execution Model, en particulier la section 4.2.2.

Prédire la sortie des deux programmes Python suivants, puis les exécuter pour confirmer ou infirmer votre hypothèse.

# Cas 1
x = 10

def foo() :
    print("(dedans foo) x :", x)
    y = 5
    print('valeur:', x * y)

print("(dehors foo) x :", x)
foo()
print("(après foo) x :", x)

et

# Cas 2
x = 10

def foo() :
    x = 8 # On a ajouté seulement cette ligne - tout le reste est identique
    print("(dedans foo) x :", x)
    y = 5
    print('valeur:', x * y)

print("(dehors foo) x :", x)
foo()
print("(après foo) x :", x)

Dessinez une image des liaisons variables à chaque portée (portée globale et portée au niveau de la fonction "foo") dans chaque cas.

UnboundLocalError

Si nous échangeons seulement deux lignes de code, quelque chose d'inhabituel se produit. Quelle est l'erreur ? Pourquoi cela pourrait-il se produire ?

x = 10

def foo() :
    print("(dedans foo) x :", x)  # Nous échangons cette ligne
    x = 8 # avec celle-ci
    y = 5
    print('valeur:', x * y)

print("(dehors foo) x :", x)
foo()
print("(après foo) x :", x)

Dans le même esprit, "foo", tel que défini dans

lst = [1,2,3]
def foo() :
    lst.append(4)
foo()

se compilera (c'est-à-dire que l'objet fonction sera compilé en binaire sans problème), mais

lst = [1,2,3]
def foo() :
    lst = lst + [4]
foo()

soulèvera une UnboundLocalError. Pourquoi? Cela n'a pas, de manière surprenante, à voir avec le fait que append est en place et + ne l'est pas.

C'est un problème si courant que la FAQ Python a une section consacrée à ce type de UnboundLocalError.

Notez que les mots-clés global et nonlocal peuvent être utilisés pour affecter une variable en dehors de la portée de la fonction actuellement active (la plus interne). Si vous êtes intéressé, vous pouvez en savoir plus sur les règles de portée dans la lecture supplémentaire, ou dans cette section de la FAQ.

Arguments mutables par défaut - un jeu dangereux

Les valeurs par défaut d'une fonction sont évaluées au point de définition dans la portée de définition. Par exemple

x = 5

def square(num=x) :
    return num * num

x = 6
square() # => 25, et non 36
square(x) # => 36

Attention : Les valeurs par défaut d'une fonction sont évaluées une seule fois, lorsque la définition de la fonction. Ceci est important lorsque la valeur par défaut est un objet mutable (modifiable) tel qu'une liste ou un dictionnaire

Prévoyez ce que fera le code suivant, puis exécutez-le pour tester votre hypothèse :

def append_twice(a, lst=[]) :
    lst.append(a)
    lst.append(a)
    return lst
   
# Marche bien lorsque le mot-clé est fourni
print(append_twice(1, lst=[4])) # => [4, 1, 1]
print(append_twice(11, lst=[2, 3, 5, 7])) # => [2, 3, 5, 7, 11, 11]

# Mais que se passe-t-il ici ?
print(append_twice(1))
print(append_twice(2))
print(append_twice(3))

Après avoir lancé le code, vous devriez voir apparaître à l'écran ce qui suit :

[1, 1]
[1, 1, 2, 2]
[1, 1, 2, 2, 3, 3]

Pourquoi?

Si vous ne souhaitez pas que la valeur par défaut soit partagée entre les appels suivants, vous pouvez utiliser une valeur sentinelle comme valeur par défaut (pour signaler qu'aucun argument de mot-clé n'a été explicitement fourni). Si c'est le cas, votre fonction peut ressembler à quelque chose comme

def append_twice(a, lst=None) :
    if lst is None :
        lst = []
    lst.append(a)
    lst.append(a)
    return lst

Parfois, cependant, ce comportement étrange d'initialisation de la valeur des mots-clés peut être souhaitable. Par exemple, il peut être utilisé comme un cache modifiable et accessible par toutes les appels d'une fonction :

def fib(n, cache={0 : 1, 1 : 1}) :
   if n in cache :  # Note : la valeur par défaut capture nos cas de base
       return cache[n]
   out = fib(n-1) + fib(n-2)
   cache[n] = out
   return out

Cool, non? Le cache suit la fonction autour, comme un attribut sur l'objet de la fonction! Malgré tout, il existe de meilleures façons pour capturer ce modèle particulier de conception du cache (voir functools.lru_cache). Néanmoins, c'est une astuce qui pourrait s'avérer utile !

Les objets de fonction

Nous allons explorer les attributs des objets de fonction plus en profondeur ici.

Habituellement, ces informations ne sont pas particulièrement utiles pour les praticiens (vous aurez rarement besoin de connaître en profondeur l'intérieur des fonctions), mais même le fait de voir que vous peut en Python est très cool.

Valeurs par défaut (__defaults__ et __kwdefaults__)

Toutes les valeurs par défaut (soit les arguments par défaut, soit les arguments de mot-clé par défaut qui suivent un paramètre d'argument positionnel variadique) sont liées à l'objet de la fonction au moment de sa définition. Considérons la fonction all_together précédemment :

def all_together(x, y, z=1, *nums, indent=True, espaces=4, **options) : passe

all_together.__defaults__ # => (1, )
all_together.__kwdefaults__ # => {'indent':True, 'spaces':4}

Pourquoi l'attribut __defaults__ serait-il un tuple, alors que l'attribut __kwdefaults__ serait un dictionnaire ?

Documentation (__doc__)

La première chaîne littérale d'une fonction, si elle précède une expression, est liée à l'attribut __doc__ de la fonction.

def ma_fonction() :
    """Ligne de résumé : ne rien faire, mais le documenter.
        
    Description : Non, vraiment, ça ne fait rien.
    """
    passer

print(ma_fonction.__doc__)
# Ligne de résumé : Ne faites rien, mais documentez-le.
#
# Description : Non, vraiment, ça ne fait rien.

Comme indiqué dans le cours, de nombreux outils utilisent ces chaînes de documentation de manière très avantageuse. Par exemple, la fonction help intégrée affiche les informations de docstring, et de nombreux outils de génération de documentation API comme Sphynx ou Epydoc utilisent les informations contenues dans docstring pour former des références et des liens sur les sites web de documentation.

En outre, le module de bibliothèque standard doctest, selon ses propres termes, "recherche les textes qui ressemblent à des sessions Python interactives, puis exécute ces sessions pour vérifier qu'elles fonctionnent exactement comme indiqué". Cool !

Code Object (__code__)

Dans CPython, l'implémentation de référence de Python utilisée par de nombreuses personnes (dont nous), les fonctions sont compilées dans une sorte de codes binaires lorsqu'elles sont définies. Cet objet de code est lié à l'attribut __code__, et possède une tonne de propriétés intéressantes, illustrées au mieux par des exemples.

def all_together(x, y, z=1, *nums, indent=True, spaces=4, **options) :
    """Un commentaire inutile""
    print(x + y * z)
    print(sum(nums))
    for k, v in options.items() :
        if indentation :
            print("{}\t{}".format(k, v))
        else :
            print("{}{}{}".format(k, " " * spaces, v))
            
code = all_together.__code__
Attribut Valeur de l'échantillon Explication
.co_argcount 3 nombre d'arguments de position (y compris les arguments avec des valeurs par défaut)
.co_cellvars () tuple contenant les noms des variables locales qui sont référencées par des fonctions imbriquées
.co_code b't\x00\x00...\x00\x00S' chaîne représentant la séquence d'instructions du bytecode
.co_consts ('A useless comment', '{}\t{}', '{}{}{}', ' ', None) tuple contenant les littéraux utilisés par le bytecode - notre 'None' provient du 'return None' implicite à la fin
.co_filename <stdin> fichier dans lequel la fonction a été définie
.co_firstlineno 1 ligne du fichier la première ligne de la fonction apparaît
.co_flags 79 ET de drapeaux binaires spécifiques au compilateur dont la signification interne est (en grande partie) non spécifiée
.co_freevars () tuple contenant les noms des variables libres
.co_kwonlyargcount 2 nombre d'arguments de mots-clés seulement
.co_lnotab b'\x00\x02\x12\x01\x10\x01\x19\x01\x06\x01\x19\x02' chaîne codant la correspondance des décalages de code d'octet aux numéros de ligne
.co_name "all_together" le nom de la fonction
.co_names ('print', 'sum', 'items', 'format') tuple contenant les noms utilisés par le bytecode
co_nlocals 9 nombre de variables locales utilisées par la fonction (y compris les arguments)
co_stacksize 6 taille requise de la pile (y compris les variables locales)
co_varnames ('x', 'y', 'z', 'indent', 'spaces', 'nums', 'options', 'k', 'v') tuple contenant les noms des variables locales (commençant par les noms des arguments)

Pour plus d'informations à ce sujet, et sur tous les types en Python, voir le site référence du modèle de données. Pour les objets de code, vous devez faire défiler vers le bas jusqu'à Internal Types.

Sécurité

L'objet de code d'une fonction donnée peut être échangé contre l'objet de code d'une autre fonction (peut-être malveillante) au moment de l'exécution !

def nice() : print("Tu es génial !")
def mean() : print("Tu n'es... pas génial. OOOOH")

# Écrasez l'objet code pour le plaisir
nice.__code__ = moyen.__code__
nice() # prints "Tu n'es... pas génial. OOOOH"
dis module

Le module dis exporte une fonction dis qui permet de dé-sassembler le code de Python (au moins, pour les distributions Python implémentées dans CPython pour les versions existantes). Le code dé-sassemblé n'est pas exactement du code assembleur normal, mais plutôt une syntaxe Python spécialisée

def gcd(a, b) :
    while b :
        a, b = b, a % b
    return a
    
import dis
dis.dis(gcd)
"""
  2 0 SETUP_LOOP 27 (à 30)
        >> 3 LOAD_FAST 1 (b)
              6 POP_JUMP_IF_FALSE 29

  3 9 LOAD_FAST 1 (b)
             12 LOAD_FAST 0 (a)
             15 LOAD_FAST 1 (b)
             18 BINARY_MODULO
             19 ROT_TWO
             20 STORE_FAST 0 (a)
             23 STORE_FAST 1 (b)
             26 JUMP_ABSOLUTE 3
        >> 29 POP_BLOCK

  4 >> 30 LOAD_FAST 0 (a)
             33 RETURN_VALUE
"""

Des détails sur les instructions elles-mêmes peuvent être trouvés ici. Vous pouvez en savoir plus sur le module dis ici.

Annotations des paramètres (__annotations__)

Comme mentionné en classe, Python nous permet de proposer des annotations de type sur les fonctions

def annotated(a : int, b : str) -> liste :
    return [a, b]

print(annotated.__annotations__)
# {'b' : <classe 'str'>, 'a' : <classe 'int'>, 'return' : <classe 'list'>}

Ces informations peuvent être utilisées pour construire des vérificateurs de types dynamiques très soignés pour Python !

Pour plus d'informations, consultez PEP 3107 sur les annotations de fonctions ou Pep 484 sur les indications de type (qui ont été introduites dans Python 3.5)

Call (__call__)

Toutes les fonctions Python ont un attribut __call__, qui est l'objet réel appelé lorsque vous utilisez des parenthèses pour "appeler" une fonction. C'est-à-dire,

def greet() : print("Hello world !")

greet() # "Hello world !"
# est juste un sucre syntaxique pour
greet.__call__() # "Hello world !"

Cela signifie que tout objet (y compris les instances de classes personnalisées) avec une méthode "call" peut utiliser la syntaxe d'appel de fonction entre parenthèses ! Nous verrons beaucoup plus en détail l'utilisation de ces soi-disant "méthodes magiques" pour exploiter les opérateurs apparents de Python (comme les appels de fonction, + (__add__) ou * (__mul__), etc).

Informations sur les noms (__module__, __name__, et __qualname__)

Les fonctions Python stockent également certaines informations sur le nom d'une fonction.

Le terme module désigne le module qui était actif au moment où la fonction a été définie. Toute fonction définie dans l'interpréteur interactif aura un __module__ == '__main__'.

__name__ est le nom de la fonction. Rien de spécial ici.

__qualname__, qui signifie "nom qualifié", ne diffère de __name__ que lorsqu'il s'agit de fonctions imbriquées, dont nous parlerons plus loin à la semaine suivante.

module inspect

En bref, tout ce fouillis avec les internes des fonctions de Python ne peut pas être bon pour notre santé. Heureusement, il existe un module de bibliothèque standard pour cela ! Le module inspect nous donne beaucoup de bons outils pour interagir non seulement avec les internes des fonctions, mais aussi avec les internes de beaucoup d'autres types. Consultez la documentation pour quelques exemples intéressants.