Ceci est une ancienne révision du document !
Cette semaine, nous allons parler de l'instruction while, nous allons revenir sur l'instruction if, mais le gros morceau de la semaine sera le sujet des fonctions. Nous allons en particulier parler de la notion de portée de variable et nous allons voir comment modifier cette portée. Nous conclurons cette semaine en étudiant comment définir les paramètres d'une fonction et les différentes manières d'appeler une fonction.
Nous avons précédemment succinctement introduit le fonctionnement des fonctions. Dans cette vidéo, nous allons revenir sur ce comportement et voir comment définir une fonction. Ouvrons un interpréteur Python pour commencer à jouer avec les fonctions.
Regardons comment définir une fonction. Nous l'avons déjà vu, une fonction se définit avec l'instruction *def*, suivie du nom de la fonction, ici, je l'appelle simplement *f*, et je vais lui passer des arguments ; à une fonction, on peut lui passer un nombre quelconque d'arguments séparés par des virgule. Ensuite, je n'oublie pas le : qui signifie que je vais avoir un bloc de code, ça va être le bloc de code de la fonction : *def f(a, b, c):* Je fais un retour chariot, et ensuite, je peux simplement faire un *print(a, b, c)*. Donc j'ai une fonction *f* qui va simplement afficher ses trois arguments. Lorsque ce bloc de code est évalué, je vais créer un objet fonction et le nom de la fonction, *f*, va être une variable qui va référencer cet objet fonction. J'ai donc une variable *f* qui référence un objet fonction. Comme tous les objets en Python et comme toutes les variables en Python, une variable est simplement un nom qui référence un objet, je peux donc renommer mon objet fonction avec une autre variable en faisant une référence partagée. Je peux tout à fait écrire *g = f*. J'ai maintenant une variable *g* qui référence le même objet fonction, je peux donc appeler ma fonction à partir de la variable f ou alors à partir de la variable g. En Python, tout est un objet, et je vous ai expliqué, lorsqu'on a parlé des références partagées, que cela avait un coût mémoire important mais que le mécanisme de références partagées permettait de minimiser les copies des objets. En particulier, Python ne copie jamais d'objet sauf si cela est demandé de manière explicite. Lorsque vous passez des arguments à une fonction, donc lorsque vous passez des objets à une fonction, ces objets ne sont jamais copiés, ils sont toujours passés par référence. Regardons un exemple.
Je vais définir une liste *L* qui est une liste vide, et je vais définir une fonction *add1* qui prend un argument *a* et qui va simplement faire un *a.append(1)*. Donc en fait, que fait ma fonction ? Ma fonction prend un argument et sur cet argument je vais appeler la méthode *append*, donc je suppose que cet argument est une liste, et je vais lui ajouter 1. Je fais un retour chariot. J'ai donc défini une nouvelle fonction ; *add1*, c'est une variable qui référence mon objet fonction. Maintenant, appelons cette fonction sur ma liste *L*. Je vous remontre ma liste *L*, j'appelle *add1* de ma liste *L*. Et je fais un retour chariot. Maintenant, regardons la valeur de l'objet référencé par ma variable L. Je vois que maintenant c'est la liste qui contient l'entier 1. Donc on voit que à aucun moment je n'ai réaffecté L, à aucun moment je n'ai fait de retour ; pourtant, ma liste a été modifiée. En fait, elle a été modifiée par effet de bord parce que j'ai une référence partagée vers un objet mutable. Quelles sont mes références partagées ? *L* référence un objet liste, j'ai passé cet objet liste à ma fonction et ma fonction a une variable locale *a*, cette variable locale référence le même objet liste que la variable *L*. Lorsque je modifie l'objet mutable par la méthode append, je modifie l'objet partagé, donc lorsque ma fonction retourne, la variable *L* référence l'objet qui a été modifié. Alors, ce comportement par effet de bord est un comportement qui peut être tout à fait souhaitable. Quel est son intérêt ? C'est d'être extrêmement économe en terme de mémoire. L'inconvénient, c'est qu'il est fait de manière implicite. Par conséquent, comme on veut qu'en Python tout soit fait de manière explicite, on doit extrêmement bien documenter son code. Pour cela, j'aime bien vous montrer l'exemple de la méthode sort sur les listes. Regardons cela. *help(list.sort)* Je vous rappelle que la méthode sort sur les listes permet de faire un tri en place ; l'intérêt, c'est que c'est très économe au niveau mémoire, l'inconvénient, c'est que c'est fait par effet de bord. Regardons ce que nous donne l'aide de sort sur les listes. On voit que l'aide nous dit que l'on fait un tri mais que ce tri est fait en place. C'est marqué entre étoiles et écrit en majuscule pour vraiment insister sur le fait que ce tri est fait en place. Donc ici, on n'a aucune ambiguïté, on voit bien que le retour de *sort* c'est *None*, donc cette méthode nous retourne juste l'objet vide, et la liste est modifiée en place. Donc à chaque fois que vous voulez faire une modification par effet de bord, c'est quelque chose de possible, qui peut être souhaitable si vous voulez être économe en mémoire ; par contre, documentez-le extrêmement bien.
Maintenant, je pourrais tout à fait dire: je ne veux pas modifier ma liste *L*, donc je vous remontre ma liste *L* qui vaut juste [1], je ne veux pas modifier ma liste *L* donc je vais lui passer une copie, et vous vous souvenez de cette notation slice vide, qui représente une *shallow copy*. Donc je passe une shallow copy de ma liste à ma fonction. Ma fonction va donc travailler sur ce nouvel objet liste, qui est copie de ma liste *L*, mais lors du retour, regardons ma liste *L*, évidemment elle n'a pas été modifiée puisque j'ai passé une copie. Mais cette shallow copy a été perdue puisqu'à aucun moment, je n'ai récupéré une référence vers ce nouvel objet liste. En fait, si je veux pouvoir récupérer une référence vers cet objet liste, il faut que je fasse une valeur de retour, il faut que ma fonction retourne quelque chose. Regardons comment faire cela.
Je redéfinis ma fonction *add1*, et je lui passe, je définis un argument *a*. Ensuite, dans ma fonction, je vais faire une shallow copy *a = a[:]* a égale shallow copy de a et ensuite, je vais faire un a.append de 1. Jusqu'à maintenant, qu'est-ce que j'ai fait ? À l'intérieur de ma fonction, j'ai dit: je fais une shallow copy, parce que je ne veux pas que ma fonction fasse de modification en place, je modifie ma shallow copy mais maintenant, pour pouvoir la récupérer, pour pouvoir la modifier lorsque ma fonction retourne, il faut obligatoirement que je passe une valeur de retour et que je fasse un return de *a*. Maintenant, regardons ma liste *L*. Ma liste *L* contient uniquement l'entier 1. Si je fais maintenant un *add1(L)* add1 de L, je passe ma liste L à ma fonction, ma fonction fait une copie, elle modifie la copie et elle me retourne une référence vers cette copie. Je vois bien que maintenant, la valeur de retour, c'est une nouvelle liste qui vaut [1, 1] ; par contre, ma liste L originale n'a pas été modifiée. Si maintenant, je veux modifier ma liste originale, c'est très simple, je le fais de manière explicite, je vais dire L égale add1 de L, et on voit maintenant que j'ai absolument tout qui est explicite, je passe ma liste qui vaut [1] à ma fonction, ma fonction s'appelle add1 donc on suppose qu'elle va lui ajouter 1, et elle donne une valeur de retour que je réaffecte à ma liste L qui est une variable globale, donc maintenant, je vais bien avoir une modification de ma liste L globale. Regardons cela. Maintenant, la valeur de retour est bien la nouvelle liste [1, 1].
Il y a un autre point important que je voudrais aborder lors de cette présentation des fonctions, c'est que lorsque vous écrivez le code d'une fonction, par exemple dans un module et que vous importez le module, lors de l'importation du module, l'objet fonction va être créé, et le nom de la fonction va être une variable qui va référencer cet objet fonction. Par contre, le bloc de code de la fonction ne sera évalué que lors de l'appel de la fonction. Regardons ce que cela implique. Je définis une fonction f qui prend un argument a et ma fonction appelle une autre fonction qui s'appelle func. Regardons ce code. J'ai donc une variable *f* qui référence mon objet fonction ; par contre, la variable *func* n'existe pas ; je n'ai pas encore d'objet fonction. Nous voyons que lors du retour chariot, la fonction a été correctement créée ; mon objet fonction existe, ma variable f référence bien l'objet fonction, je n'ai aucun problème. Le problème, je le verrai, donc l'absence de la définition de la fonction func, je le verrai uniquement lors de l'appel de ma fonction f. Appelons maintenant la fonction f. Je vois qu'ici j'ai une exception qui me dit clairement que le nom func n'a pas été défini. Donc on voit que je peux définir une fonction qui appelle du code qui n'est pas encore défini, et que c'est possible jusqu'au moment où j'appelle effectivement ma fonction. Si maintenant, je définis ma fonction func qui prend un argument *a* et qui fait simplement un return de *a* ; maintenant, j'ai bien une fonction f, une fonction func et je peux donc appeler ma fonction f, f de 1, qui va appeler func en lui passant 1, et qui va faire un retour, voilà, de 1.
Pour finir, j'aimerais vous parler de polymorphisme. Polymorphisme, c'est un nom un petit peu étrange pour un concept très simple. Regardons ce que cela veut dire. Je vais définir une fonction qui s'appelle *my_add* et qui va prendre deux arguments a et b. Et cette fonction va simplement faire un *print* d'une *f-string* qui contient *a et b*, et ensuite ma fonction va faire un return de *a* plus *b*. Donc j'écris une fonction *my_add* qui prend deux arguments a et b, qui va les afficher et qui va faire un return de la somme de ces arguments. Vous remarquez que, vous savez qu'en Python on a du typage dynamique, donc à aucun moment, je n'ai spécifié le type. Ça veut dire que je peux donc appeler ma fonction *my_add* avec deux entiers 1 et 2 ; regardons le résultat, c'est 3. J'ai bien fait la somme. Je peux appeler *my_add* avec des floats 4.3 et 2.3, et je vais faire l'addition de ces floats. Et je peux même appeler ma fonction *my_add* avec une chaîne de caractères et une autre chaîne de caractères. Quelle va être la valeur de retour ? La concaténation des chaînes de caractères. Donc la caractéristique de cette notion de polymorphisme, c'est qu'une fois que vous avez défini une fonction, cette fonction va pouvoir s'exécuter sur n'importe quels types qui sont compatibles avec les opérations contenues dans le bloc de code de la fonction. L'intérêt de ce polymorphisme, c'est que vous réduisez énormément le code que vous avez à écrire puisque vous n'avez pas besoin d'écrire une fonction pour les entiers, une fonction pour les floats, une fonction pour les chaînes de caractères ; votre fonction va être unique et va pouvoir se comporter correctement avec n'importe quels types que vous lui passez, du moment que les opérations définies dans la fonction sont définies pour les types qui sont passés à cette fonction.
Dans cette vidéo, nous avons présenté le fonctionnement des fonctions. En fait, les fonctions sont des objets qui sont référencés par une variable qui est le nom de la fonction. Tous les arguments de la fonction sont passés par référence ; ça veut donc dire qu'on a des risques d'effets de bord qu'il faut contrôler, et nous avons vu qu'il est toujours mieux de faire des retours de fonction sauf lorsqu'on a besoin d'une grande efficacité mémoire, c'est-à-dire lorsqu'on ne veut pas dupliquer les objets. Nous avons également vu que les fonctions étaient polymorphes ; l'intérêt de ce polymorphisme c'est de simplifier l'écriture de votre code, vous écrivez une seule fois la fonction et cette fonction va pouvoir s'exécuter avec n'importe quels types du moment que les opérations définies dans la fonction sont compatibles avec ces types. Python utilise le typage dynamique, ce qui veut dire que nous n'avons jamais à définir le type des objets que nous passons aux fonctions. Cependant, Python permet de donner des indications de type, c'est ce qu'on appelle les type hints. Les type hints sont uniquement des indications qui peuvent être utilisées par exemple pour améliorer la documentation du code ou alors pour faire une validation statique du code. Cependant, l'auteur de Python a été très clair sur cette notion-là, les type hints resteront toujours optionnels et ne vous obligeront jamais à définir du typage statique dans votre code.