Skip to content

Texte : fonctions et modules.

Les « fonctions » constituent l'outil naturel — et indispensable — de la programmation procédurale (procedural programming) et, plus généralement, de programmation modulaire (modular programming), méthode de programmation qui consiste à découper le code en entités autonomes visant chacune à résoudre un sous-problème propre et permettant la réutilisation du code (code reuse) au sein d'un même programme ou, plus généralement, la constitution de bibliothèques (library) de modules.

Chaque fonction est utilisée au sein d'un programme via un processus d'appel provoquant l'exécution de son code sur des données transmises lors de cet appel, les paramètres, parfois appelés « arguments ».

En général, les fonctions associées à un même sous-problème sont rassemblées, avec les quelques autres éléments de programme nécessaires (définitions de types communs, de constantes et variables partagées, par exemple) au sein d'un module ou d'un paquetage (parfois dénommé « package », comme en anglais). Lorsque le langage choisi dispose de cette fonctionnalité, le paquetage est généralement le composant minimal pouvant faire l'objet d'une compilation séparée (c'est le cas en Java, par exemple). Dans ce cas, les codes objets produits peuvent être intégrés comme tels dans les bibliothèques et ensuite simplement récupérés lors de l'édition des liens finale.

En outre, la modularisation d'un programme permet la mise au point de chaque module ou fonction séparément, ce qui simplifie la vérification et améliore la qualité finale du code produit. Cette démarche s'appelle un « test unitaire » (Unit Testing).

Formes et propriétés

Nous utilisons le terme « fonction » pour désigner toutes les constructions des langages permettant la découpe du code en blocs de code appelables dans différents contextes, notamment par transfert de paramètres. Mais ce concept se décline en fait sous différentes formes.

• Une « sous-routine » (subroutine), encore appelée simplement « routine », « sous-programme » ou « procédure » : bloc de code appelable — par mécanisme d'instruction d'appel (« CALL ») et retour à l'instruction qui suit l'appel (« RETURN ») — échangeant ses informations via paramètres et variables globales.

• Une « fonction » (function) stricto sensu : extension du cas précédent qui évalue, en outre, une valeur de retour, une valeur renvoyée dans le contexte de l'appel, nécessairement au sein d'une expression, une procédure étant parfois présentée comme « une fonction à valeur vide » ou « sans valeur ».

  • Une « méthode » : simple fonction ou procédure définie au sein d'une classe et s'appliquant donc implicitement à un objet de cette classe, parfois à l'objet-classe lui-même (méthodes statiques).

  • Un « opérateur » : cas particulier de fonction ou de méthode dont le nom n'est pas un identificateur, mais un symbole (également appelé un opérateur) et dont la syntaxe d'appel s'apparente à celle d'une sous-expression algébrique.

  • Une « coroutine » : forme généralisée de routine qui, lors d'un appel, reprend l'exécution de son code à l'endroit du retour précédent — plutôt appelé « YIELD » que « RETURN », d'ailleurs — ou au point indiqué par l'argument de « YIELD » et doit par conséquent maintenir son état local (valeur de ses variables locales et instruction courante) d'un appel à l'autre.

  • Un « thread » (ou fil d'exécution ou tâche) : routine qui s'exécute en parallèle du bloc appelant qui, lui, poursuit donc son exécution sans attendre le retour de la routine, sauf aux points explicites de synchronisation (mécanismes de rendez-vous ou de sémaphore).

Dans ce qui suit, d'une manière générale, nous utiliserons le terme générique de « fonction » pour désigner ces différents cas.

Définition d'une fonction

Les fonctions sont des éléments de syntaxe, des syntagmes particuliers, des « clauses » du langage de programmation, généralement définis par le programmeur. De toutes les entités ainsi définissables, ce sont les seules qui correspondent non seulement à un état (leur contexte à l'exécution), mais surtout à du code (par opposition aux données), ce qui leur confère souvent un statut particulier. De ce statut découlent certaines restrictions et possibilités spécifiques.

Voyons maintenant l'écriture d'une fonction proprement dite, c'est-à-dire sa définition. Nous devrons soigneusement distinguer «définition», «déclaration» et «usage» d'une fonction, comme nous l'avons déjà pressenti au cours des chapitres précédents.

La définition d'une fonction comporte deux parties : - l'en-tête qui définit son interface (le nom, la liste des paramètres...) avec l'appelant ; - le corps qui contient ses instructions, qui définit le code associé à cette fonction.

A priori, le corps d'une fonction est essentiellement un bloc d'instructions, ce qui ne présente pas un concept nouveau en soi. La seule particularité est qu'on peut y utiliser les paramètres formels de la fonction comme s'il s'agissait de simples variables définies et initialisées juste avant de commencer ce bloc.

Par contre, c'est via l'en-tête que les caractéristiques de la fonction sont définies, et cela peut être très différent selon le langage de programmation.

Du point de vue de la syntaxe, on distingue donc celle de la définition d'une fonction qui définit entre autres ses paramètres formels, de simples identificateurs locaux, de celle de l'usage d'une fonction qui précise les paramètres effectifs, des expressions sur lesquelles agiront effectivement les instructions du corps de la fonction.

Un paramètre formel (formal parameter) d'une fonction est un nom, un identificateur (qui peut parfois être omis, si c'est un paramètre anonyme) associé à un type ; il est utilisable comme tout autre identificateur au sein du corps de la fonction dont il est un nom local. Il y « représente » formellement les paramètres effectifs qui lui seront successivement associés lors des appels successifs de la fonction.

Un paramètre effectif (actual parameter) d'une fonction est le résultat de la traduction d'une sous-expression fournie lors d'un appel particulier de la fonction. L'interprétation sémantique d'un paramètre effectif dépend du mode de transmission des paramètres utilisé. L'anglicisme « argument » est parfois utilisé pour désigner un paramètre effectif.

Une fonction étant une entité d'un programme, elle est naturellement associée à une déclaration particulière, donc à un « type ». Certains utilisent ce terme de manière plus restrictive pour désigner exclusivement le type de la valeur renvoyée par la fonction, mais nous éviterons cette confusion. Dans ce sens, nous dirons « type de la valeur de retour ».

L'arité d'une fonction (arity) est le nombre de paramètres qu'elle requiert. Une fonction à n paramètres est dite « n-aire ».

La signature d'une fonction correspond à l'information qui détermine la syntaxe et le sens des appels à cette fonction, ce qui comprend son nom, ainsi que le nombre et les types respectifs de ses paramètres.

Le prototype de fonction désigne sa signature couplée au type de sa valeur de retour.

Le type d'une fonction est son prototype, sans son nom, c'est-à-dire sa valeur de retour et la liste des types de ses paramètres.

En Python, les fonctions sont de simples références liées à un objet-fonction.

Surcharge : cette propriété est liée au sens du type d'une fonction dans un langage, donc à celui de l'usage du nom seul d'une fonction : - Soit le nom d'une fonction est un identificateur en soi, comme un autre ; si le langage respecte la règle de définition unique (ODR), une seule fonction définie visible peut porter ce nom-là. - Soit le nom seul ne suffit pas et tout le prototype — ou au moins sa signature — est requis pour identifier une fonction ; dans ce cas, plusieurs fonctions simultanément visibles peuvent être homonymes ; leur nom est surchargé.

Un langage accepte la surcharge (overloading) de fonctions s'il offre la possibilité de définir plusieurs fonctions homonymes différentes ; lors d'un appel, le choix de la fonction réellement visée parmi celles qui sont visibles est déterminé par la forme de l'appel, le nombre et type des paramètres effectifs, selon un mécanisme appelé « résolution de surcharge » (overload resolution), algorithme complexe mis en œuvre par le compilateur.

Réentrance et récursivité : Une propriété importante décrit si un même bloc de code, le corps d'une fonction dans ce cas-ci, peut être en cours d'exécution multiple simultanée ou s'il faut nécessairement le placer en mémoire sous plusieurs exemplaires.

La réentrance d'un code (reentrancy, reentrant code), d'un morceau de programme, est sa capacité d'être exécuté simultanément par plusieurs tâches (ou threads). Un code réentrant réduit les exigences en espace d'un logiciel en évitant les duplications inutiles d'information en mémoire.

Cette propriété est essentielle pour la programmation parallèle, la programmation concurrente, la gestion des interruptions ou la conception de modules de bibliothèques dynamiques, par exemple. Mais elle s'obtient au coût d'une gestion du contexte et de l'état courant d'exécution (mémoire locale, paramètres et conditions d'appel, contenu des registres et instruction courante...) plus complexe. Cet état — une donnée de gestion — doit être stocké en autant d'exemplaires qu'il y a de versions du code en cours d'exécution ; sa taille peut être réduite en utilisant, là où c'est algorithmiquement et techniquement possible, des techniques de partage de mémoire. Heureusement, les processeurs possèdent fréquemment des instructions-machine permettant de réaliser cela de manière efficace.

Un cas très particulier est celui où une fonction peut s'appeler elle-même. Dans ce cas, un autre état courant d'exécution doit aussi être créé, mais contrairement à la réentrance générale, la vie de ce dernier, du code appelé, s'achèvera avant la reprise de l'exécution de la fonction appelante. Ces contextes peuvent donc être simplement empilés sur une « pile d'exécution » (ou stack).

La récursivité (recursion) d'une fonction, dite « récursive » (recursive function), est sa capacité à s'autoappeler. Cette récursivité peut être directe, si le corps de la fonction contient un appel à elle-même, ou indirecte, s'il contient un appel à une autre fonction qui, au travers d'autres appels de fonctions imbriqués éventuels, finit dans certains cas par appeler la fonction initiale. Lorsque deux fonctions s'appellent mutuellement, on parle de récursivité croisée.

Fonction variadique : il semble naturel que la définition d'une fonction en définisse notamment l'arité et que celle-ci soit évidemment respectée lors de chaque appel, par cohérence. Mais, dès les premiers langages de programmation, le besoin s'est fait sentir d'incorporer dans les bibliothèques prédéfinies standard des fonctions admettant un nombre variable et indéterminé de paramètres effectifs.

Une fonction variadique (variadic function) est une fonction d'arité indéfinie.

Appel d'une fonction et modes de transmission

L'échange de « paramètres » entre le bloc de code appelant — celui qui contient l'instruction d'appel à la fonction et qui précise les paramètres effectifs de l'appel — et le corps de la fonction — les instructions qu'elle définit, qui mènent à l'évaluation de sa valeur éventuelle et faisant référence à des paramètres formels — est un mécanisme essentiel et caractéristique des fonctions. Ce mécanisme, appelé « passage ou transmission des paramètres », se présente sous des formes diverses. Selon le langage, seules certaines sont disponibles et correspondent à des syntaxes particulières. Ces possibilités et restrictions conduisent également à des choix d'architecture d'environnement d'exécution et de traduction en instructions-machine différentes.

Python dispose de la possibilité de transmettre les paramètres de façon positionnelle ou nommée et de définir des valeurs par défaut pour les paramètres.

Nous laissons le lecteur le soin de regarder la richesse qu'offre Python pour définir des fonctions variadiques ainsi que des paramètres positionnels ou nommés et des valeurs par défaut.

Note sur les valeurs par défaut en Python

En Python l'en-tête d'une fonction est interprété une seule fois, lors du premier examen de l'instruction « def » ; par conséquent, l'évaluation de la valeur par défaut a lieu à ce moment-là et pas à chaque appel, même si l'interprétation du corps de la fonction a lieu à chaque appel.

Voyons-le sur l'exemple ci-dessous. Le paramètre L est lié à L0 qui varie d'un appel à l'autre, mais si L0 est ensuite délié ou lié à un autre objet, cela n'a aucun impact sur L.

L0 = []
def push(a, L=L0):
    L.append(a)
    return L

a = 3
S = [2, 1, 7]
print(push(a, S)) # [2, 1, 7, 3]
print(S)          # idem, S a été modifié
a = 8
print(push(a))    # [8]
print(S)          # inchangé
a = -1
print(push(a))    # [8, -1] ; L0 a été modifié
print(L0)         # idem
a = 5
L0 = [3, 2, 1]      # L0 lié à une nouvelle liste
print(push(a))    # [8, -1, 5] ; l'autre liste est restée liée à L
print(L0)         # [3, 2, 1] ; OK

Ce comportement est identique si la valeur initiale est décrite par une notation littérale. Cette constante apparente semble donc varier ! Mais tout cela reste parfaitement logique, si si...

def push(a, L=[]):
    L.append(a)
    return L

a = 3
S = [2, 1, 7]
print(push(a, S)) # [2, 1, 7, 3]
print(S)          # idem, S a été modifié
a = 8
print(push(a))    # [8]
print(S)          # inchangé
a = -1
print(push(a))    # [8, -1] !!!

Cela ne se produirait évidemment pas si la valeur initiale était de type immuable, en particulier un type élémentaire.

Pour les types variables, une astuce consiste à fournir comme valeur pas défaut la valeur «None» et d'attribuer la « vraie » valeur par défaut dans le corps de la fonction à l'aide d'une assignation liant le paramètre formel à un nouvel objet évalué à chaque exécution du corps de la fonction. Voici la transformation de l'exemple précédent selon cette méthode.

def push(a, L=None):
    L = [] if L is None else L
    L.append(a)
    return L

a = 3
S = [2, 1, 7]
print(push(a, S)) # [2, 1, 7, 3]
print(S)          # idem, S a été modifié
a = 8
print(push(a))    # [8]
a = -1
print(push(a))    # [-1] ok !

Ce modèle est une belle illustration d'usage d'une expression conditionnelle à la ligne 2.

Mode de transmission

Il n'existe pas de classification universellement admise des différents modes de transmission qui ont été imaginés par les concepteurs des langages de programmation. Certains modes sont très répandus, mais d'autres n'ont connu qu'une seule (et parfois brève) existence. Toutefois, ne serait-ce que pour des raisons pédagogiques, il est utile de les présenter de façon organisée. Voici donc une typologie des différents modes de transmission des paramètres et le nom le plus fréquemment utilisé pour désigner chacun.

  1. Par valeur (call by value ou by copy-in) : l'expression fournie comme paramètre effectif est complètement évaluée et ce résultat est copié dans une variable, dans un objet local du corps de la fonction (via appel d'un constructeur de copie) qui correspond au paramètre formel. Cette valeur devient ainsi accessible, voire modifiable, dans le corps de la fonction de manière locale, sans impact sur les objets dans l'environnement de l'appel. Cette méthode est coûteuse en temps (évaluation, puis copie) et en espace (accueil de la valeur locale). C'est le mode le plus répandu et correspond au seul mode possible en C/C++.
  2. Par référence (call by reference) : le paramètre formel représente en fait une référence vers le paramètre effectif qui est un objet en soi dans le contexte de l'appel : l'évaluation de celui-ci doit mener à une simple variable préexistante : un nom de variable, le résultat d'un déréférencement... Par conséquent, les actions du corps de la fonction se répercutent immédiatement sur l'état du paramètre effectif. Par contre, cette référence en soi est inaccessible, donc immuable au sein du corps de la fonction. C'est un mode très répandu également et correspond au seul mode possible en FORTRAN, par exemple ; c'est un mode possible en Pascal (paramètres « var »).
  3. Par copies (call by copy-in copy-out ou by copy-restore) : le résultat de l'évaluation du paramètre effectif est toujours copié dans un objet local associé au paramètre formel (comme dans le mode par valeur), mais la valeur acquise par ce paramètre en fin d'exécution du corps de la fonction est recopiée dans l'objet correspondant au paramètre effectif (selon le sens décrit pour le mode par référence) dans le contexte de l'appel. Le paramètre effectif est ainsi modifié, mais la modification n'a lieu qu'une seule fois au retour de la fonction, pas de façon dynamique en cours d'exécution comme dans le mode par référence, ce qui garantit le cloisonnement des contextes et la cohérence des états en cas de parallélisme. C'est donc un des modes de transmission les plus sûrs ; il existe par exemple en ALGOL. Mais ce mode est très coûteux en temps (2 copies) et en espace.
  4. Par partage (call by sharing) : dans ce mode, le paramètre formel est toujours une référence qui est liée au paramètre effectif dans le contexte du corps de la fonction, non dans celui de l'appel. Elle est ainsi accessible et modifiable dans ce contexte. Toute modification éventuelle de cette référence n'a qu'un effet local, ce qui ressemble au mode par valeur (notamment si le paramètre est d'un type élémentaire), mais si le paramètre effectif est lui-même une référence, l'objet référencé peut donc effectivement être modifié au travers de cette référence. C'est le seul mode possible en Java ou en Python.
  5. Par résultat (call by result ou copy-out) : ce mode particulier ne fait aucune initialisation du paramètre formel, mais copie la valeur acquise en fin d'exécution du corps de la fonction vers l'objet correspondant au paramètre effectif. Ce mode est peu répandu comme tel (il existe des paramètres « OUT » en Ada, par exemple), mais il s'agit du mode de transmission quasi universel de la valeur de retour d'une fonction.
  6. Par nom (call by name) : le paramètre effectif n'est pas évalué, mais c'est le code d'évaluation de l'expression (considéré comme une macro-instruction) qui est associé au paramètre formel. La valeur représentée par le paramètre formel peut donc différer d'une occurrence à l'autre au sein du corps de la fonction : cela permet de programmer des situations impossibles avec les autres modes de transmission, notamment de réaliser ce qu'on appelle un « Jensen's Device ». Ainsi, si l'on associe au paramètre formel « x » l'expression « v[i] » (un calcul d'index dans un tableau), la valeur de «x» dans une expression dépend de celle acquise par un «i» local au moment de chaque usage de « x ». C'est un des modes possibles en ALGOL.
  7. À l'usage (call by need) : l'évaluation du paramètre est retardée au premier usage éventuel à l'exécution, dans le contexte du corps de la fonction et cette valeur est ensuite conservée durant la suite de l'exécution du corps de la fonction ; le paramètre formel est bien un objet local. Ce mode présente des aspects optimisés du mode par valeur, avec des similitudes au mode par nom. C'est le mode de transmission en Haskell, notamment.
  8. Comme constante (call by constant) : la valeur du paramètre effectif est évaluée comme dans les modes par valeur, par référence ou par partage, selon la forme de l'expression, mais elle ne peut en aucun cas être modifiée, même indirectement. Chaque usage se comporte comme s'il agissait sur une nouvelle copie profonde temporaire de cette valeur. Ce mode existe en Ada (paramètre « IN ») ou en PL/I (paramètre « NONASSIGNABLE »).

Il existe évidemment d'autres modes exotiques encore plus rares.

Dans cette section, nous ne détaillons pas plus l'étude des langages de programmation et invitons le lecteur intéressé à se référer aux cours de référence résumé dans ce texte ou à la nombreuse littérature sur le sujet. Projetons nous en fin de cours pour parler d'histoire et de taxonomie des langages de programmation.

Langages orientés-objet

Nous avons déjà présenté les concepts principaux des langages orientés-objet à la section précédente via le langage Python, même si Python n'est pas le meilleur langage orienté-objet que l'on pourrait désirer, Python privilégiant souvent le côté pragmatique à l'implémentation propre de concepts bien définis.

Ainsi, la notion d'attribut privé n'existe pas réellement mais tout attribut d'une classe dont le nom débute par deux caractères soulignés est "vu comme" privé sans vraiment l'être (voir variables privées).