Outils pour utilisateurs

Outils du site


cours:informatique:fun_mooc:python3_uca_inria:410_fontions

Python: les fonctions

Une fonction se définit avec l'instruction *def*, suivie du nom de la fonction, dans l'exemple qui suit, on l'appelle simplement *f*, et on va lui passer des arguments ( on peut lui passer un nombre quelconque d'arguments séparés par des virgule). Le caractère ':' après les arguments signifie que l'on va introduire un bloc de code, ce sera le bloc de code de la fonction.

def f(a, b, c): 
    print(a, b, c)

Ici le bloc contient une seule instruction, appelant *print* qui va simplement afficher ses trois arguments.

Lorsque la définition est évalué par python, un objet fonction est créé et le nom de la fonction, *f*, va être une variable qui va référencer cet objet fonction.

On obtient une nouvelle 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, on peut donc renommer l' objet fonction avec une autre variable en faisant une référence partagée.

>>> f(1, 'deux', 3)                                                                             
1 deux 3
 
# création d'une référence partagée
# la variable g référence aussi l'objet référencé par f
>>> g = f                                                                                       
 
>>> print(g)                                                                                    
<function f at 0x7f2479111310>
 
>>> g(4, 'cinq', 6)                                                                             
4 cinq 6

On a bien une variable *g* qui référence le même objet fonction, on peut donc appeler la fonction à partir de la variable *f* ou alors à partir de la variable *g*.

Passage des arguments par référence

En Python, tout est objet, cela peut avoir un coût mémoire important mais le mécanisme de références partagées permet 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, cela à des conséquence qu'il faut bien appréhender.

# définiton d'une liste vide
L = []
 
# définition d'une fonction ajoutant un élément
# constant à la liste passée en paramètre
def add1(lst): 
     lst.append(1) 
 
# affiche le contenu de la liste L
>>> print(L)                                                                                    
[]
 
# appelle la fonction avec en paramètre effectif
# la liste L
>>> add1(L)                                                                                     
 
# La liste L a été modifiée
>>> print(L)                                                                                    
[1]

On a définit une liste *L* qui est une liste vide, puis une fonction *add1* qui prend un argument *lst* et qui ajoute un élément à *lst*. La fonction prend un argument et sur cet argument appele la méthode *append*, (on suppose que cet argument est une liste à l'execution). *add1* est une variable qui référence l' objet fonction. On appelle cette fonction sur la liste *L*. La liste qui contient l'entier 1. On voit qu' à aucun moment nous n'avons réaffecté L, et à aucun moment nous avons retournéune valeur. Pourtant, la liste a été modifiée. En fait, elle a été modifiée par effet de bord parce qu' on a une référence partagée vers un objet mutable.

Ici *L* référence un objet liste, nous avons passé cet objet liste à la fonction *add1* et la fonction a une variable locale *lst*, cette variable locale référence le même objet liste que la variable *L*. Lorsque on modifie l'objet mutable via la méthode append, on modifie l'objet partagé.

Ce comportement par effet de bord est un comportement qui peut être tout à fait souhaitable car il est 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, on peut prendre pour exemple la documentation de la méthode sort sur les listes. Regardons cela.

>>> help(list.sort)

On obtient la docstring:

sort(self, /, *, key=None, reverse=False)
    Sort the list in ascending order and return None.
    
    The sort is in-place (i.e. the list itself is modified) and stable (i.e. the
    order of two equal elements is maintained)
    ...

On voit que l'aide nous dit que l'on fait un tri mais 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.

Si l'on préfère ne pas modifier la liste *L*, on peut passer une copie à la fonction en utilisant par exemple la notation slice vide, qui représente une *shallow copy*.

# la valeur de la liste avant l'appel
>>> print(L)                                                                                    
[1]
 
# Appel de la fonction avec une shallow copy
# de la liste L
>>> add1(L[:])                                                                                  
 
# la liste originale n'a pas été modifiée
# cependant la copie modifiée n'est pas conservée
# car aucune variable ne la référence
>>> print(L)                                                                                    
[1]

On crée une shallow copy de la liste *L* lors de l'appel de la fonction *add1*. La fonction va donc travailler sur ce nouvel objet liste, qui est une copie de la liste *L*. Mais cette shallow copy a été perdue puisqu'à aucun moment, nous n'avons 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 passer une valeur de retour, il faut que la fonction retourne quelque chose. Regardons comment faire cela.

def add1(lst): 
     llst = lst[:] 
     llst.append(1) 
     return llst

On définit une variable *llst* au sein de la fonction, on fait une shallow copy de l'argument *lst* puis on ajoute un élément avec *append*. Nous avons fais une shallow copy, pour éviter que la fonction fasse de modification en place, on modifie la shallow copy mais maintenant, pour pouvoir la récupérer, il faut obligatoirement qu'on utilise une valeur de retour.

Maintenant, examinons le comportement du code produit:

# etat de la liste avant l'appel
print(L)                                                                                    
[1]
 
# appel de la fonction avec en paramètre effectif
# la liste L
>>> add1(L)                                                                                     
[1, 1]
 
# la fonction a retourné une liste affichée
# dans l’interpréteur
 
# En affichant la liste L on vérifie
# qu'elle n'a pas été modifiée
>>> print(L)                                                                                    
[1]

La liste *L* contient uniquement l'entier 1. On invoque la fonction *add1(L)* *add1* avec en argument effectif la liste *L*. La fonction fait une copie, elle modifie la copie et elle me retourne une référence vers cette copie. La valeur de retour est une nouvelle liste qui vaut [1, 1]; Par contre, la liste *L* originale n'a pas été modifiée. Si maintenant, je veux modifier ma liste originale, c'est très simple, je peux le faire de manière explicite, en redéfinissant *L*:

>>> L = add1(L)                                                                                 
 
>>> print(L)                                                                                    
[1, 1]

Avec cette définition de la fonction *L* tout est explicite, on passe la liste qui vaut [1] à la fonction, la fonction s'appelle *add1* donc on suppose qu'elle va lui ajouter 1, et elle donne une valeur de retour que je réaffecte à la liste *L* qui est une variable globale, donc maintenant, je vais bien avoir une modification de la liste *L* globale.

On voit donc que l'appel de fonction crée des références partagées, exactement comme l'affectation, et tout ce qui a été présenté au sujet des références partagées s'applique exactement à l'identique:

# on ne peut pas modifier un immuable dans une fonction
def increment(n):
    n += 1
 
>>> compteur = 10
>>> increment(compteur)
>>> print(compteur)
10
 
# on peut par contre ajouter dans une liste
def insert(liste, valeur):
    liste.append(valeur)
 
>>> liste = ["un"]
>>> insert(liste, "texte")
>>> print(liste)
['un', 'texte']

Pour cette raison, il est important de bien préciser, quand vous documentez une fonction, si elle fait des effets de bord sur ses arguments, c'est-à-dire qu'elle modifie ses arguments (in-place), ou si elle produit une copie.

Importation

Un autre point important concernant les fonctions, concerne leur importation. 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.

# définition d'une fonction f avec un argument a
# appellant une fonction non définie
def f(a): 
     func(a)

La fonction *f* prend un argument *a* et appelle une autre fonction qui s'appelle *func*. Ce code est accepté par l’interpréteur. 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. La fonction a tout de même été correctement créée. L' objet fonction existe, la variable *f* référence bien l'objet fonction, on ne relève aucun problème. Le problème, se révélera lors de l'appel de ma fonction *f* si *func* n'est toujours pas existante. En appelant maintenant la fonction *f* on obtient une exception qui dit clairement que le nom *func* n'a pas été défini. Donc on voit que l'on peut 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 le retourne simplement alors j'ai bien une fonction *f*, une fonction *func* et je peux donc appeler ma fonction *f* sans générer d’exception.

Polymorphisme

Pour finir, abordons la notion de polymorphisme. Polymorphisme, c'est un nom un petit peu étrange pour un concept très simple. Regardons ce que cela veut dire avec un exemple:

def my_add(a, b):
     # affiche un message 
     print(f"{a} et {b}")
     # retourne le résultat de l'opération  
     return a + b

On définit une fonction qui s'appelle *my_add* qui prend deux arguments *a* et *b*. Cette fonction va simplement afficher une f-string contenant *a et b* puis la fonction va faire un retour de l'opération *a* + *b*, somme de ces arguments.

En Python on a du typage dynamique, donc à aucun moment, nous avons spécifié le type de *a* ou *b*. Ça veut dire qu'au moment de l'appel de la fonction *my_add* on peut fournir deux entiers 1 et 2 mais également deux nombres décimaux (float) ou même deux chaînes de caractères car l'opérateur '+' existe sur les chaînes de caractères (c' est la concaténation).

>>> my_add(1, 3)                                                                                
1 et 3
4
 
>>> my_add(2.1, 3.9)                                                                            
2.1 et 3.9
6.0
 
>>> my_add("to", "to")                                                                          
to et to
'toto'

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 (sur-définition de fonctions) ; 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.

Nous avons abordé ici 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.

Documenter la fonction

Pour rappel, il est recommandé de toujours documenter les fonctions en ajoutant une chaîne comme première instruction.

def flatten(containers):
    "returns a list of the elements of the elements in containers"
    return [element for container in containers for element in container]

Cette information peut être consultée interactivement:

>>> help(flatten)

Ou récupérée par les scripts via l'attribut:

flatten.__doc__

On remarquera que dans cet exemple, le docstring ne répète pas le nom de la fonction ou des arguments (on parle alors de signature), et que ça n'empêche pas help de nous afficher cette information.

Le PEP 257 donne les conventions et recommandations autour du docstring et précise bien qu'il n'est pas une signature: “The one-line docstring should NOT be a “signature” reiterating the function/method parameters (which can be obtained by introspection).”

Il est préférable d'utiliser un docstring ainsi:

def function(a, b):
    """Do X and return a list."""

NB: Of course “Do X” should be replaced by a useful description!

Typage dynamique

Avec la fonction prédéfinie *isinstance*, qui peut être par ailleurs utile dans d'autres contextes, vous pouvez facilement:

  • Vérifier qu'un argument d'une fonction a bien le type attendu;
  • Traiter différemment les entrées selon leur type.
def factoriel(argument):
    # si on reçoit un entier
    if isinstance(argument, int):
        return 1 if argument <= 1 else argument * factoriel(argument - 1)
    # convertir en entier si on reçoit une chaîne
    elif isinstance(argument, str):
        return factoriel(int(argument))
    # la liste des résultats si on reçoit un tuple ou une liste 
    elif isinstance(argument, (tuple, list)):
        return [factoriel(i) for i in argument]
    # sinon on lève une exception
    else:
        raise TypeError(argument)

Remarquez que la fonction isinstance possède elle-même une logique de ce genre, puisqu'en ligne 3 nous lui avons passé en deuxième argument un type (int), alors qu'en ligne 11 on lui a passé un tuple de deux types. Dans ce second cas naturellement, elle vérifie si l'objet (le premier argument) est de l'un des types mentionnés dans le tuple.

>>> print("entier", factoriel(4))
entier 24
 
>>> print("chaine", factoriel("8"))
chaine 40320
 
>>> print("tuple", factoriel((4, 8)))
tuple [24, 40320]

Module types

Le module types définit un certain nombre de constantes qui peuvent être utiles dans ce contexte. Par exemple :

>>> from types import FunctionType
 
>>> isinstance(factoriel, FunctionType)
True
 
# attention au fonction built-in qui sont de type BuiltinFunctionType
>>> from types import BuiltinFunctionType
>>> isinstance(len, BuiltinFunctionType)
True
 
# alors qu'on pourrait penser que
>>> isinstance(len, FunctionType)
False

isistance vs type

Il est recommandé d'utiliser isinstance plutôt que type dès que c'est possible. Tout d'abord, cela permet, on vient de le voir, de prendre en compte plusieurs types.

Mais aussi et surtout *isinstance* supporte la notion d'héritage qui est centrale dans le cadre de la programmation orientée objet.

Avec la programmation objet, vous pouvez définir vos propres types. On peut par exemple définir une classe *Animal* qui convient pour tous les animaux, puis définir une sous-classe *Mammifere*. On dit que la classe *Mammifere* hérite de la classe *Animal*, et on l'appelle sous-classe parce qu'elle représente une partie des animaux ; et donc tout ce qu'on peut faire sur les animaux peut être fait sur les mammifères.

class Animal:
    def __init__(self, name):
        self.name = name
 
class Mammifere(Animal):
    def __init__(self, name):
        Animal.__init__(self, name)

*isinstance* permet dans ce contexte de faire des choses qu'on ne peut pas faire directement avec la fonction type, comme ceci:

# instanciation d'un objet de type Animal
>>> requin = Animal('requin')
 
# idem pour un Mammifere
>>> baleine = Mammifere('baleine')
 
# bien sûr ici la réponse est 'True'
>>> print("l'objet baleine est-il un mammifère ?", isinstance(baleine, Mammifere))
l'objet baleine est-il un mammifère ? True
 
# ici c'est moins évident, mais la réponse est 'True' aussi
>>> print("l'objet baleine est-il un animal ?", isinstance(baleine, Animal))
l'objet baleine est-il un animal ? True

Bien que l'objet baleine soit de type Mammifere, on peut le considérer comme étant aussi de type Animal. Ceci est motivé de la façon suivante : comme on l'a dit plus haut, tout ce qu'on peut faire (en matière notamment d'envoi de méthodes) sur un objet de type Animal, on peut le faire sur un objet de type Mammifere. Dit en termes ensemblistes, l'ensemble des mammifères est inclus dans l'ensemble des animaux.

Type hints

Depuis la version 3.5, Python supporte un mécanisme totalement optionnel qui vous permet d'annoter les arguments des fonctions avec des informations de typage, ce mécanisme est connu sous le nom de type hints, et se présente comme ceci:

# pour typer une variable avec les type hints
nb_items : int = 0
 
# une fonction factorielle avec des type hints
def fact(n : int) -> int:
    return 1 if n <= 1 else n * fact(n-1)

On peut entrevoir les usages suivants à ce type d'annotation :

  • Tout d'abord, et évidemment, cela peut permettre de mieux documenter le code ;
  • Les environnements de développement sont susceptibles de vous aider de manière plus effective; si quelque part vous écrivez z = fact(12), le fait de savoir que z est entier permet de fournir une complétion plus pertinente lorsque vous commencez à écrire z.[TAB];
  • on peut espérer trouver des erreurs dans les passages d'arguments à un stade plus précoce du développement.

Par contre ce qui est très très clairement annoncé également, c'est que ces informations de typage sont totalement facultatives, et que le langage les ignore totalement.

# l'interpréteur ignore totalement ces informations
def fake_fact(n : str) -> str:
    return 1 if n <= 1 else n * fake_fact(n-1)
 
# on peut appeler fake_fact avec un int alors 
# que c'est déclaré pour des str
fake_fact(12)

Le modèle préconisé est d'utiliser des outils extérieurs, qui peuvent faire une analyse statique du code pour exploiter ces informations à des fins de validation. Dans cette catégorie, le plus célèbre est sans doute mypy. Notez aussi que les IDE comme PyCharm sont également capables de tirer parti de ces annotations.

Parce qu'ils ont été introduits pour la première fois avec python-3.5 (en 2015), puis améliorés dans la 3.6 pour le typage des variables, l'usage des type hints n'est pour l'instant pas très répandu, en proportion de code en tous cas. En outre, il aura fallu un temps de latence avant que tous les outils (IDE's, producteurs de documentation, outils de test, validateurs…) ne soient améliorés pour en tirer un profit maximal.

On peut penser que cet usage va se répandre avec le temps, sans doute pas de manière systématique, mais a minima pour lever certaines ambiguïtés.

le module typing

Bref aperçu des possibilités offertes pour la construction des types dans ce contexte de type hints. N'hésitez pas à vous reporter à la documentation officielle du module typing pour un exposé plus exhaustif.

from typing import List
 
# une fonction qui 
# attend un paramètre qui soit une liste d'entiers,
# et qui retourne une liste de chaînes
def foo(x: List[int]) -> List[str]:
    pass    
Dans l'exemple ci-dessus nous avons utilisé typing.List plutôt que le type built-in list, alors que l'on a pu par contre utiliser int et str.

Les raisons pour cela sont de deux ordres :

  • Tout d'abord, si on devait utiliser list pour construire un type comme liste d'entiers, il me faudrait écrire quelque chose comme list(int) ou encore list[int], et cela serait source de confusion car ceci a déjà une signification dans le langage;
  • De manière plus profonde, il faut distinguer entre list qui est un type concret (un objet qui sert à construire des instances), de List qui dans ce contexte doit plus être vu comme un type abstrait.
  • La documentation officielle du module typing
  • Le PEP-525 sur le typage des paramètres de fonctions
  • Le PEP-526 sur le typage des variables
cours/informatique/fun_mooc/python3_uca_inria/410_fontions.txt · Dernière modification : 2021/04/28 21:36 de 93.28.24.141