Skip to content

Texte : paradigmes des langages de programmation

Dans cette partie, nous nous concentrons sur les langages de programmation et leurs paradigmes

Pour rédiger cette partie, nous nous sommes basé sur les supports :

Nous invitons le lecteur à utiliser ces références pour compléter l'information résumée ici.

Introduction

Avant de discuter des paradigmes des langages de programmation, nous rappelons, pour que vous les ayiez sous la main brièvement, les concepts de base utilisés pour définir et étudier les langages de programmation.

La présentation et le plan de ce module ne suivent pas une démarche d'apprentissage de la programmation — c'est l'objet d'autres cours prérequis à celui-ci — ni de description progressive d'un langage de programmation, mais une organisation par thèmes et concepts de la théorie des langages de programmation.

Rappels, définitions de base, et autres notions utilisées

Algorithme vs programme

L'informatique est la science du traitement automatique de l'information — par «traitement», on entend notamment acquisition, codage, stockage, communication, modification, restitution... de « données » — par une machine. Cette machine peut être une abstraction théorique (une machine de Turing, un automate fini, une architecture de von Neumann, ...) ou un ordinateur particulier, avec ses caractéristiques matérielles et logicielles.

Ainsi, «programmer» c'est imaginer une séquence d'opérations, dites «élémentaires», codant un traitement souhaité par la machine visée.

Ceci signifie deux choses :

  • La conception d'une méthode de traitement, ce qui constitue un « algorithme ». La discipline informatique correspondante est l'algorithmique; celle-ci étudie les méthodes efficaces de résolution automatique de problèmes, ainsi que les structures de données, l'organisation de celles-ci adaptée à ce traitement.
  • Le codage fidèle et efficace — donc exact, correct — de cet algorithme en fonction du contexte matériel et logiciel particulier d'exécution du programme.

Un programme est donc une traduction particulière, concrète d'un algorithme ; il en constitue une instance contextualisée et codée. Celle-ci peut être mise à l'épreuve, testée lors d'une exécution du programme par un ordinateur traitant des données particulières, judicieusement choisies.

Définition : langage de programmation

Les premiers langages de programmation sont apparus avec les premiers exemples d'assembleurs (1954, 1955, etc.), FORTRAN (en gestation entre 1954 et 1956, mais véritablement opérationnel en 1957), puis COBOL (conçu de 1957 à 1959).

Un programme est un texte, avec ses conventions d'écriture. Il s'agit bien d'un langage écrit, au sens commun, mais il doit toujours avoir un sens univoque et non contextuel, ce qui n'est jamais le cas des langues vernaculaires. Il faut que la formulation textuelle d'un programme soit : - suffisamment proche d'un code réel, conforme à une famille d'ordinateurs particuliers ; - standardisée et générale pour permettre une adaptation immédiate et automatique — on parle de « portabilité » — à d'autres contextes similaires ; - parfaitement univoque, non ambiguë, puisque destinée à un traitement automatique ; - intelligible par un être humain.

Familles et niveaux de langages

Vu le grand nombre de langages existants, une classification s'est fait jour. On regroupe en général les langages en « familles » selon le paradigme de programmation auquel ils sont (le mieux) adaptés.

Un paradigme de programmation est un concept assez difficile à définir précisément; Wikipedia nous dit qu'un paradigme de programmation est une façon d'approcher la programmation informatique et de traiter les solutions aux problèmes et leur formulation dans un langage de programmation approprié.

Deux grandes familles occupent une place prépondérante dans ce paysage : les langages impératifs, dont sont issus les langages orientés-objet, même s'ils sont étudiés de façon spécifique, et les langages déclaratifs dont font partie les langages fonctionnels que nous aborderons brièvement.

D'autre part, les langages peuvent également être présentés en fonction de leur généalogie. En effet, la majorité d'entre eux ont été créés comme évolution ou variante d'un ou plusieurs langages qui leur préexistaient.

Nous aborderons généalogie des langages et les paradigmes des langages plus loin. Dans un premier temps, nous regarderons les langages impératifs qui définissent le comportement de base de beaucoup de langages comme Python, C, C++, Fortran, Cobol, C++, Java, ..., même si certains d'entres-eux incluent dans leur conception, le paradigme orienté-objet (c'est le cas de Python, C++, Java).

Pour ces langages, il est généralement admis que l'on travail sur des modèles d'ordinateurs qui suivent une architecture de von Neumann.

Architecture de von Neumann

L'architecture de von Neumann décompose l'ordinateur en 4 parties distinctes :

  • l'unité arithmétique et logique (UAL ou ALU en anglais) ou unité de traitement : son rôle est d'effectuer les opérations de base ;
  • l'unité de contrôle, chargée du « séquençage » des opérations ;
  • la mémoire qui contient à la fois les données et le programme qui indiquera à l'unité de contrôle quels sont les calculs à faire sur ces données. La mémoire se divise entre mémoire volatile (programmes et données en cours de fonctionnement) et mémoire permanente (programmes et données de base de la machine) ;
  • les dispositifs d'entrée-sortie, qui permettent de communiquer avec le monde extérieur.

Compilateur et interpréteur

Compilation, interprétation et runtime

Un programme constitue un code source, un fichier contenant un texte, c'est-à-dire dont les éléments sont des caractères. L'extension (ou suffixe) du nom de fichier (comme .py, .c, .cpp, .jav, ...) est traditionnellement associée à un langage particulier ou un élément de ce langage.

Un langage, peut de base être soit compilé (comme C, C++, Fortran,Cobol, Java, OCaml) soit interprété (comme Python, Basic, Ruby, PHP, Javascript).

Notons que le résultat de la traduction d'un programme en Java est, en général, du « bytecode » destiné à être interprété par une Java Virtual Machine (JVM).

Langage compilé

Dans ce cas, l'exécution d'un programme passe par trois phases séparées : la compilation, le chargement (avec l'édition des liens) et l'exécution proprement dite.

Un compilateur est un programme, un logiciel qui transforme un fichier contenant un code source, écrit dans un langage de programmation particulier, en un autre fichier contenant un code binaire (en langage machine) ou, plus généralement, un code objet, dans le format correspondant à la machine cible (architecture matérielle et logicielle).

Principe du compilateur

Notons qu'un compilateur a trois langages de programmation comme paramètres principaux :

  • Le langage source des programmes qu'il compile
  • Le langage cible dans lequel il compile les programmes, et
  • Le langage dans lequel il est lui-même écrit.

Par exemple gfortran (https://gcc.gnu.org/fortran/) est un compilateur qui traduit les codes FORTRAN en (par exemple) code machine Intel i386, et est écrit en langage C.

Il est plaisant de constater que si « compilation », dans ce sens particulier, et « compilateur » semblent provenir d'une francisation de termes anglais, c'est en fait l'inverse qui s'est produit : le verbe anglais « to compile » et ses dérivés sont issus du verbe « compiler » en vieux français (début du XIVe siècle) signifiant « recueillir, rassembler ». Il existe de nombreux termes scientifiques ou techniques qui ont une histoire similaire, car l'anglais savant est d'origine latine et francophone. À titre d'exemple, « randomiser » (donner un caractère aléatoire) semble provenir de l'anglais « randomize », de « random », mais l'origine réelle de ces termes est l'ancien mot français « randon » (promenade aléatoire, impétueuse) ou le verbe « randir », radical que l'on retrouve encore dans « randonnée », par exemple.

Phases de la compilation

Pour traduire un programme, un compilateur doit analyser la source pour y identifier les éléments du langage et leur signification. La connaissance de ce processus d'analyse est la clé de la compréhension des contraintes d'écriture d'un langage de programmation.

Les phases d'analyse incluent :

  • l'analyse lexicale (également appelé scanning en anglais), qui découpe la séquence de caractères du code source en blocs atomiques appelés jetons (tokens en anglais). Chaque jeton appartient à une unité atomique unique du langage appelée unité lexicale ou lexème (par exemple un mot-clé, un identifiant ou un symbole). Le logiciel qui effectue une analyse lexicale est appelé un analyseur lexical ou un scanner. Deux outils classiques permettent de construire plus aisément des scanners : lex et flex.

  • l'analyse syntaxique (également appelé parsing en anglais) implique l'analyse de la séquence de jetons pour identifier la structure syntaxique du programme. Cette phase s'appuie généralement sur la construction d'un arbre d'analyse (appelé communément arbre syntaxique) ; on remplace la séquence linéaire des jetons par une structure en arbre construite selon la grammaire formelle qui définit la syntaxe du langage. Par exemple, une condition est toujours suivie d'un test logique (égalité, comparaison, ...). L'arbre d'analyse est souvent modifié et amélioré au fur et à mesure de la compilation. YACC et GNU Bison sont les outils les plus utilisés pour construire des analyseurs syntaxiques.

  • l'analyse sémantique est la phase durant laquelle le compilateur ajoute des informations sémantiques (donner un « sens ») à l'arbre syntaxique. Cette phase fait un certain nombre de contrôles: vérifie le type des éléments (variables, fonctions, ...) utilisés dans le programme, leur visibilité, vérifie que toutes les variables locales utilisées sont initialisées, .... L'analyse sémantique nécessite habituellement un arbre syntaxique complet, ce qui signifie que cette phase fait suite à la phase d'analyse syntaxique, et précède logiquement la phase de génération de code ; mais il est possible de replier ces phases en une seule passe qui effectiue les trois phases en "parallèle".

les trois phases d'analyse construisent et complète la table des symboles qui centralise les informations attachées aux identificateurs du programme. Dans une table des symboles, on retrouve des informations comme : le type, l'emplacement mémoire, la portée, la visibilité, etc.

C'est au cours de ces phases que le compilateur détecte les erreurs éventuelles (par rapport à la définition du langage) et produit les messages d'erreur permettant à l'auteur du programme de poursuivre sa mise au point. La compréhension de ce processus est de nature à mieux comprendre le sens de ces messages : selon la phase pendant laquelle ils sont générés, ils décrivent des erreurs de types différents.

un prétraitement, peut également être effectué avant ou de façon entremêlée aux phases d'analyse. Un tel prétraitement est nécessaire pour certaines langues comme C où il prend en charge la substitution de macro et de la compilation conditionnelle, permettant par exemple de générer des codes différents en fonction de paramètres. 2-2-2_Compilateur_et_interpr%C3%A9teur

Phases de la compilation

Vient ensuite la génération du code proprement dite, avec ses phases d'optimisation : allocation des registres, suppression de « code mort » (qui ne pourra jamais être exécuté), sortie de boucle d'opérations indépendantes de l'itération, sortie d'alternative des codes communs, permutation d'instructions, etc.

La généraion de code inclut les trois phases :

  • la transformation du code source en code intermédiaire (appelée image) ;

  • optimisation du code intermédiaire : c'est-à-dire rendre le programme « meilleur » selon son usage;

  • la génération de code final avec l'allocation de registres et la traduction du code intermédiaire en code objet, avec éventuellement l'insertion de données de débogage et d'analyse de l'exécution.

L'analyse lexicale, syntaxique et sémantique, le passage par un langage intermédiaire et l'optimisation forment la partie frontale. La génération de code final constitue la partie finale.

Ceci permet de minimiser le travail quand on doit construire plusieurs compilateurs pour des langages différents et pour des plateformes (ou langages cibles différents) (voir figure ci-dessous)

Exemples de parties frontales et finales de compilateurs

Après la compilation suit une phase d'enrichissement avec des modules issus de bibliothèques (libraries) qui font partie de l'environnement d'exécution (runtime) pour la machine cible. L'intégration en un code complet de tous ces éléments est ce qu'on appelle l'édition des liens (link). Cette édition de liens permet non seulement de relier un programme avec certains modules de la bibliothèque, mais également de combiner plusieurs codes objet correspondant à des morceaux de programme, éventuellement écrits dans des langages différents, qui ont fait l'objet d'une compilation séparée.

Bribes d'exemple illustrant les phases de la compilation

Définition formelle d'un langage de programmation : syntaxe et sémantique

Les détails des différentes phases de la compilation et les algorithmes complexes et structures de données conçus à cette fin sont notamment le sujet d'étude de cours de « Théorie des langages et de la compilation ». Outre, le fonctionnement d'un compilateur, on y trouve l'étude des différentes classes de langages formels utilisés pour définir un langage de programmation.

En particulier la base de la théorie des langages repose sur l'étude de la hiérarchie de Chomsky décrite par Noam Chomsky en 1956.

Ainsi les unités lexicales et les lexèmes possibles sont généralement formellement définis grâce à des langages réguliers, la syntaxe d'un langage de programmation est définie grâce aux grammaires algébriques, ....

Notons que la sémantique d'un langage de programmation est beaucoup plus difficile à formaliser. Elle utilise par exemple la notion de grammaire attribuée, plus difficile à appréhender que les grammaires "simples".

Nous n'analysons pas ici les différents éléments d'un langages et en particulier du langage Python dont la connaissance de base est un prérequis à cette partie de cours, et référons le lecteur désireux d'appronfondir la matière, au manuel de référence du langage Python où la grammaire complète de Python est donnée.

Notons que dans la plupart des langages de programmation, la notation utilisée pour définir la syntaxe du langage est principalement l' Extended Backus-Naur Form (EBNF). Il est important de comprendre cette notation pour pouvoir lire ces définitions de langage.

Langage interprété

Dans ce cas, l'interpréteur suit un cycle de lecture, d'analyse et d'exécution immédiate d'une instruction avant de passer à l'instruction suivante.

Instructions des langages impératifs

Un langage est dit impératif s'il utilise le paradigme de programmation impérative où les opérations sont des séquences d'instructions exécutées par l'ordinateur pour modifier l'état du programme.

Le noyau des langages impératifs

La plupart des langages impératifs comportent cinq constructions de base (voir [Les principes des langages de programmation][Gilles Dowek, Les principes des langages de programmation, Ellipses, 2011].

  • l'assignation ou affectation
  • la déclaration de variable
  • la séquence
  • le test
  • la boucle

Notons que dans une assignation (par exemple en Python) variable = expression l'expression est appelée la source (ou dans le jargon anglophone « R-value » pour « Right »), dont l'évaluation produira la valeur sauvegardée dans la variable (« L-value » pour « Left »).

Rappelons qu'en Python, les variables ne sont pas déclarées; c'est l'affectation qui définit sa valeur et son type.

Notons également que dans les premiers langages de "haut niveau" tel que FORTRAN et COBOL, l'instruction de saut inconditionnel, souvent nommée « goto », était présent. La goto indique par son opérande l'instruction suivante à exécuter dans le programme. Celle-ci est identifiée par une étiquette, un « label » placé devant l'instruction visée, appelée « instruction étiquetée ». Cette instruction est peu recommandée, pour des raisons de "bonne pratique de programmation"; elle n'existe pas en Python.

Les entrées / sorties

Les premiers langages de programmation définis, ainsi que la plupart des langages impératifs courant possèdent des instructions d'entrée-sortie qui font partie du cœur du langage (FORTAN, , COBOL, BASIC, Python, Java, etc).

Paradoxalement, ALGOL60 ne définit aucune instruction d'entrée-sortie, manifestant ainsi son objectif purement algorithmique : on décrit toujours un algorithme sous forme d'une ou plusieurs routines ou fonctions, les « entrées-sorties » étant alors leurs paramètres. Cette position rabique est certainement une des causes du peu de succès du langage en dehors de milieux académiques, puisqu'il est impossible de concevoir de vrais programmes sans I/O, des extensions locales, donc non portables ont dû être systématiquement utilisées.

Si nous ajoutons la possibilité d'utiliser et de définir des fonctions et éventuellement des modules, nous parlons de paradigme de programmation procédurale ou plus simplement de langages procéduraux.

Programme complet en programmation procédurale

Un programme complet — c'est-à-dire compilable ou interprétable sans erreur et produisant effectivement un code ayant un effet — comporte plusieurs éléments complémentaires aux instructions élémentaires traduisant directement l'algorithme lui-même. Ainsi, le texte d'un programme comporte généralement plusieurs parties successives : 1. Un contexte: l'identité du programme (son nom), des éléments informatifs (auteur, date d'écriture, machine cible...), objet du programme, dépendances éventuelles, etc. Du point de vue syntaxique, il s'agit souvent de commentaires, mais ils sont indispensables. 2. Les importations : les importations de différents paquetages ou bibliothèques, standard ou ad hoc, ainsi que les autres fichiers sources à intégrer. 3. Les entités globales : il s'agit de données et déclarations « globales » partagées par le programme et ses différents sous-programmes (routines, etc.). 4. Le code du programme proprement dit. Celui-ci comporte les déclarations, définitions et initialisations des éléments « locaux » propres au programme et les instructions. 5. Les codes des sous-programmes éventuels.

Selon le langage et le contenu d'un programme particulier, l'ordre de ces différentes parties peut être légèrement mêlé ou permuté et ce texte peut être éclaté en plusieurs fichiers différents. Si l'on compare différents langages, la plupart des langages algorithmiques, qu'on appelle aussi « ALGOL-like », se ressemblent fort par la forme et l'effet de leurs instructions, mais ils peuvent sembler très différents si l'on examine leurs parties contextuelles et déclaratives. Pourtant, celles-ci restent très semblables d'un programme à l'autre écrit dans le même langage. Voilà pourquoi, il est habituel de reprendre un même canevas standard que l'on établit en fonction de son contexte de travail, ses goûts et ses besoins. Nous allons en présenter un exemple ci-dessous.

Exemple de programme dans différents langages

Nous allons choisir arbitrairement comme programme type, un programme lisant une suite de nombres naturels (entiers positifs) et affichant le nombre d'éléments nécessaires pour que leur somme dépasse un certain seuil. Celui-ci est un paramètre intégré au programme, une constante fixée arbitrairement à 1024.

Comme nous le verrons, la partie algorithmique reste très semblable. Mais les différences sont plus importantes pour les entrées-sorties (nom des fonctions, traitement des erreurs...) et les commentaires, ainsi que pour « l'emballage », c'est-à-dire le canevas d'un programme complet.

Programme en Python

#!/usr/bin/env python3.7
# -*- coding: utf-8 -*-
# Yves Roggeman - 2018/12 - Compare.py
# This program counts how much data are needed to reach a given level
#

import sys
level = 1024
sum=0
nb=0
while sum < level:
    try:
        data = int(input())
    if data < 0:
        raise Exception
    except Exception:
        print("Data error")
        sys.exit(-1)
    sum += data
    nb += 1
print(nb)

Il n'est pas possible de définir une constante en Python ; c'est au programmeur d'être attentif à ne pas modifier une telle valeur. Les erreurs de lecture sont capturées par le mécanisme de gestion des exceptions.

Programme en Pascal

{ Free Pascal Compiler version 3.0.4 - "fpc -Miso -Ciort -Tlinux"
  Yves Roggeman - 2018/12 - Compare.pas
  This program counts how much data are needed to reach a given level
}
program compare;
    const level = 1024;
    var data, sum, nb: integer;
begin
    sum := 0; nb := 0;
    repeat
        read(data);
        if data < 0 then
            begin writeln('Data error'); halt(-1) end;
        sum := sum + data; nb := succ(nb)
    until sum >= level;
    writeln(nb)
end.

La structure d'un programme « program déclaratives begin instructions end. » y est semblable à celle d'une fonction (mot-clé «function» ou «procedure»). Le «;» y sépare les instructions, il ne marque pas leur fin ; il n'y en a donc pas en fin de bloc.

Programme en C

En C, « tout est fonction » : le programme principal est lui-même une fonction appelée (nécessairement) « main » dont la valeur entière est renvoyée au système. Selon la convention Unix intriquée à celle de C, une valeur nulle indique une fin normale, un succès d'exécution (si l'on préfère, on peut utiliser à cet effet les constantes « EXIT_SUCCESS » ou « EXIT_FAILURE » définies dans ).

En C, comme en Pascal et dans la plupart de langages ALGOL-like, on doit déclarer toutes ses entités, à l'exception en C des fonctions. La bonne pratique veut qu'on les déclare le plus localement possible, dans le bloc où elles sont utilisées : c'est la règle de localité des variables qui doit être absolument respectée en C et dans tous ses descendants. Ceci peut également se faire en dehors de toute fonction, ce qu'on appelle le niveau espace de nommage ; ces entités (constantes, variables...) sont alors « globales », accessibles dans toute fonction au sein du même fichier source (ce qui correspond à une « unité de traduction »). Dans l'exemple ci-dessous, les directives « #inlude » ont pour effet de copier le fichier source donné en paramètre ; les entités qui y sont définies sont donc globales.

/* GNU C version 8.2.0 - "gcc -std=c18"
  * Yves Roggeman - 2018/12 - Compare.c
  * This program counts how much data are needed to reach a given level
  */
#include <stdlib.h>
#include <stdio.h>

int main (void) {
    const unsigned level = 1024;
    unsigned sum = 0, nb = 0;
    do {
        int data, err = scanf("%d", &data);
    if (err <= 0 || data < 0)
            {fputs("Data error\n", stderr); abort();}
    sum += data; ++nb;
    } while (sum < level);
    printf("%d\n", nb);
    return 0;
}

Programme en C++

Les ressemblances entre C et C++ sont nombreuses ; un programme en C peut d'ailleurs être compilé comme un programme en C++ si l'on choisit bien ses options de compilation ! On remarque évidemment l'usage des flux (stream) pour les I/O et des noms des en-têtes à inclure (ainsi que l'opérateur de résolution de portée « :: » pour désigner l'espace de nommage « std » utilisé par ces en-têtes).

/* GNU C++ version 8.2.0 - "g++ -std=c++17"
 * Yves Roggeman - 2018/12 - Compare.cpp
 * This program counts how much data are needed to reach a given level
 */
#include <cstdlib> // abort()...
#include <iostream> // cin, cout...

int main () {
    const unsigned level = 1024;
    unsigned sum = 0, nb = 0;
    do {
        int data; std::cin >> data;
    if (!std::cin || data < 0)
            {std::cerr << "Data error" << std::endl; std::abort();}
    sum += data; ++nb;
    } while (sum < level);
    std::cout << nb << std::endl;
    return 0;
}

Programme en Java

En Java, « tout est classe et méthode » ; par conséquent, le programme est (nécessairement) une méthode statique « main » — donc « presque » une fonction ordinaire — d'une classe ayant le nom du fichier (et donnant son nom au programme). Java démontre ainsi sa filiation directe à C.

Mais, contrairement à ce langage, Java ne possède pas de type «unsigned» et «main» ne renvoie aucune valeur, puisqu'elle n'est pas directement connectée au système, mais à une machine virtuelle : la JVM. Pour interagir avec le système, on utilise donc les membres de la classe « System » définie dans « java.lang » et toujours accessible.

/* OpenJDK version 11.0.1 - "javac -source 11"
 * Yves Roggeman - 2018/12 - Compare.java
 * This program counts how much data are needed to reach a given level
 */
import java.util.Scanner; // nextInt...
public class Compare {
    public static void main (String[] args) {
        final int level = 1024;
    int sum = 0, nb = 0;
    do{
        int data = 0;
    try {
            data = (new Scanner(System.in)).nextInt();
            if (data < 0) throw new Exception();
    } catch(Exception err)
            {System.out.println("Data error"); System.exit(-1);}
        sum += data; ++nb;
     } while (sum < level);
     System.out.println(nb);
    }
}

Programme en ALGOL68

Pour mieux comprendre les éléments communs des différents langages illustrés ci-dessus, voici leur ancêtre «théorique»: ALGOL68. Dans ce langage, archétype de tout langage algorithmique, un programme est un simple bloc « begin ... end ».

COMMENT Algol 68 Genie 2.8 - "a68g --strict"
    Yves Roggeman - 2018/12 - Compare.a68
    This program counts how much data are needed to reach a given level
COMMENT

BEGIN
    INT level = 1024;
    INT sum := 0, nb := 0;
    WHILE
        on value error(stand in, (REF FILE file)BOOL: err);
        on file end(stand in, (REF FILE file)BOOL: err);
        INT data; read(data);
        IF data < 0 THEN GOTO err FI;
        sum +:= data;
        nb +:= 1;
        sum < level
    DO SKIP OD;
    write(nb)
    EXIT
err:
    print("Data error")
END
On remarque, entre autres, la différence entre l'identité « = » (permettant donc de définir une constante symbolique, par exemple) et l'assignation « := » modifiant une L-value.

La construction de toutes les instructions composées permet de mettre toute une séquence dont la valeur est donnée par sa dernière expression. Ainsi le « WHILE...DO...OD » traduit bien ici une boucle à post-condition, les instructions précédant l'expression booléenne servant de condition.

Les erreurs de lecture sont traitées par les fonctions prédéfinies qui renvoient au bloc commun associé à l'étiquette « err ».

Programme en FORTRAN

Voici maintenant l'ancêtre des langages de programmation. Bien sûr, FORTRAN a beaucoup évolué et aujourd'hui, il est très semblable aux langages modernes : il est algorithmique, structuré et orienté objet, entre autres. Mais nous présentons ici un programme conforme au langage FORTRAN77 et en format fixe (instruction écrite entre les colonnes 7 et 72, une seule par ligne...) et en lettres capitales, sauf commentaires. Nous avons toutefois introduit une indentation pour marquer la structure, mais aucun programmeur ne le faisait à l'époque.

C GNU Fortran version 8.2.0 - "gfortran -std=f95 -ffixed-form"
C Yves Roggeman - 2018/12 - Compare.for
C This program counts how much data are needed to reach a given level
C
      PROGRAM COMPARE
        IMPLICIT NONE
        INTEGER LEVEL, TOT, NB, DAT
        PARAMETER (LEVEL = 1024)
        TOT=0
        NB=0
        DO WHILE (TOT .LT. LEVEL)
          READ(*, *, ERR=999, END=999) DAT
          IF (DAT .LT. 0) GOTO 999
          TOT = TOT + DAT
          NB = NB + 1
        END DO
        PRINT*, NB
      STOP
999     PRINT*, 'Data error'
        STOP 1
      END

Programme en COBOL

Enfin, nous n'aurions pas pu omettre le concurrent contemporain de FORTRAN : le COBOL. Ce langage est, dans ses premières années, essentiellement adapté au traitement de fichiers, laissant à FORTRAN les programmes calculatoires, dits « scientifiques ». Comme lui, COBOL a beaucoup évolué (c'est également devenu un langage orienté objet), mais nous illustrons ici une version en COBOL « traditionnel », c'est-à-dire antérieur à 1985 et en format fixe (instructions écrites entre les colonnes 8 ou 12 et 72) et écrit en lettres capitales.

Un programme COBOL est essentiellement composé de déclarations auxquelles trois « divisions » sur quatre sont consacrées.

 IDENTIFICATION DIVISION.
 PROGRAM-ID.     COMPARE.
 AUTHOR.         YVES ROGGEMAN.
 DATE-WRITTEN.   2018/12.
 REMARKS.        THIS PROGRAM COUNTS HOW MUCH DATA ARE NEEDED
                 TO REACH A GIVEN LEVEL.
 ENVIRONMENT DIVISION.
 CONFIGURATION SECTION.
 SOURCE-COMPUTER. UBUNTU.
 OBJECT-COMPUTER. X64-86.
*    OpenCOBOL 2.2.0 - "cobc -std=cobol85 -fixed"
 INPUT-OUTPUT SECTION.
 FILE-CONTROL.
     SELECT INP ASSIGN TO "Compare_Data". 
     SELECT OUT ASSIGN TO DISPLAY.
 DATA DIVISION.
 FILE SECTION.
 FD  INP.
 01  FILLER      PICTURE X(80).
 FD  OUT.
 01  NB-OUT      PICTURE Z(4)9.
 WORKING-STORAGE SECTION.
 77  LEVEL       PICTURE 9999, USAGE IS COMPUTATIONAL
                             , VALUE IS 1024. 
 77  TOT         PICTURE 9(5), USAGE IS COMPUTATIONAL
                             , VALUE IS ZERO.
 77  DAT         PICTURE S9(5), USAGE IS COMPUTATIONAL.
 77  NB          PICTURE 9(5), USAGE IS COMPUTATIONAL
                             , VALUE IS ZERO.
 PROCEDURE DIVISION.
 MAIN.
     OPEN INPUT INP.
     PERFORM LOOP,
         WITH TEST AFTER, UNTIL TOT IS GREATER THAN LEVEL.
     CLOSE INP.
     OPEN OUTPUT OUT.
     WRITE NB-OUT FROM NB.
     CLOSE OUT.
     STOP RUN.
 LOOP.
     READ INP INTO DAT; AT END PERFORM ERR.
     IF DAT IS LESS THAN ZERO THEN PERFORM ERR.
     ADD DAT TO TOT.
     ADD 1 TO NB.
 ERR.
     CLOSE INP.
     DISPLAY "Data error".
     STOP RUN.
C'est loin d'être concis ! Pour aider les programmeurs fatigués, il est permis d'omettre certaines ponctuations («,», «;»...) et certains mots « vides » («IS», «THAN», «AS», «WITH», «AT»...), ainsi que d'utiliser certaines abréviations (« COMP » pour « USAGE IS COMPUTATIONAL », « PIC » pour « PICTURE »...).

Systèmes de typage

Systèmes de typage et types élémentaires

Notion de type

Abordons brievement la notion de type.

Un type (de donnée) est : 1. Un ensemble de valeurs que peut prendre la donnée ; 2. Un ensemble fini (et exhaustivement énuméré) d'opérations qui peuvent lui être appliquées.

La traduction de ce concept dans un langage de programmation nécessite de compléter cette définition par :

  1. Un codage des valeurs sous forme binaire, occupant donc un certain nombre de bits.

Chaque langage offre un certain nombre de types élémentaires prédéfinis (« standard types » en Python, « fundamental types » en C/C++), à partir desquels l'on peut définir de nouveaux types composés ou dérivés.

Une donnée informatique ne peut occuper qu'un nombre fini de bits — elle est toujours limitée ne serait-ce que par la taille physique des ressources de l'ordinateur hôte — et le nombre de valeurs possible pour un type est toujours fini, quelle que soit sa représentation ou sa définition formelle. Qu'on le veuille ou non, « l'informatique est la science du fini », contrairement aux mathématiques (et certains sujets d'informatique théorique) qui se complaisent dans les infinis de tous genres.

Système de typage

Les règles déterminant la manière dont les types sont attribués aux entités (variables, constantes, objets, fonctions...) et expressions d'un langage constituent un système de typage.

Typage statique ou dynamique Cette distinction repose sur le moment où la détermination du type et la vérification de la faisabilité de l'opération, moyennant d'éventuelles conversions supplémentaires, sont effectuées : dès la compilation — vérification statique (static type-checking) — ou lors de l'exécution — vérification dynamique ou « par le runtime » (dynamic type-checking).

Dans les langages à système de type statique, un programme doit contenir une information permettant d'annoncer qu'un nom sera celui d'une entité ayant un type précis et déterminé. Pour cela, des « instructions déclaratives » sont utilisées.

Le système de typage de Python utilise un mécanisme réflexif : les types sont des objets auxquels le programme accède durant l'exécution.

Typage fort ou faible Le sens de cette distinction n'est pas universellement fixé. La majorité des auteurs qualifient de « typage fort » un système suffisamment strict et riche pour procurer une vérification sûre de la cohérence du programme écrit et l'insertion non ambiguë de conversions efficaces, ce qu'on appelle « type-safety ». Un système de typage fort devrait également garantir l'intégrité des données lors de l'exécution du programme, donc assurer un accès discrétionnaire au contenu de la mémoire (et au codage binaire des valeurs), ce qu'on appelle « memory-safety ».

Des langages comme Java, Pascal, Ada, LISP... et même COBOL sont fortement typés. Par contre, FORTRAN et C ne le sont pas. Python pourrait, par certains aspects, être qualifié de fortement typé, mais la possibilité d'agir de manière imprévue sur le type d'un objet (une simple assignation à un nouvel attribut inexistant dans la définition de la classe, par exemple) rend impossible la détermination a priori du (bon) sens des opérations de la suite du programme. Celles-ci conduiront, en cas d'erreur, à la levée d'une exception qui, si elle n'a pas été explicitement prévue par le programmeur, conduit à l'arrêt brutal du programme. Python est plutôt « typé comme un canard » (duck typing).

Nous vous référons à l'apprentissage de base de Python pour l'étude des types natifs, qui sont soit des types élémentaires : int, float, complex, bool, soit des types composés : tuple, list, dict, set, .... Notons qu'en python, le type caractère n'existe pas : en Python 'a' est de type chaîne de caractères (str).

D'une manière générale, les types composés dans les langages de programmation, se répartissent en 5 familles principales :

  1. Les pointeurs et références : le type donne un accès indirect à un type de base ;
  2. Les énumérations : le type est un sous-type d'entiers — son type de base — constitué d'une collection de noms de constantes ;
  3. Les tableaux (array) : le type correspond à une collection de valeurs toutes du même type de base — c'est un type homogène — contiguës en mémoire et identifiées par un ou plusieurs indices ;
  4. Les classes et leurs nombreuses variantes : le type correspond à une collection de valeurs diverses — c'est un type hétérogène — appelées «membres» et identifiées par un identificateur ; On distingue :
  5. Les enregistrements (record) : un « simple amalgame » de valeurs de types hétérogènes contiguës en mémoire ;
  6. Les unions : une même zone de mémoire interprétée comme plusieurs types différents ;
  7. Les champs de bits (bit-fields) : les valeurs sont des configurations de quelques bits qui sont rassemblés de façon compacte ;
  8. Les n-uplets (tuples) : les composantes sont identifiées par un indice, comme dans un tableau ;
  9. Les classes proprement dites, c'est-à-dire « avec méthodes » : le type associe non seulement des données — appelées attributs —, mais également des fonctions — appelées méthodes.
  10. Les fonctions: le type est caractérisé par une valeur produite à partir d'une suite de paramètres.

Parmi les types composés nous ne discutons dans ce qui suit que les pointeurs et références.

Références et pointeurs

Un pointeur et une référence fournisssent tous deux un accès indirect à un objet. La différence si situe dans le fait de pouvoir (et devoir) manipuler explicitement ces « adresses ».

Un pointeur est un type d'objet dont la valeur fournit l'accès indirect à un autre objet. Un pointeur est « typé » s'il ne peut pointer que vers des objets d'un seul type fixé, son type de base. L'accès à l'objet pointé s'appelle un déréférencement (dereferencing), opération inverse de la prise d'adresse (ou « calcul » d'adresse) ; ces deux opérations sont toujours explicites.

Un pointeur typé est un type composé, car il est lié au type des objets auquel il donne accès, ce qu'on nomme son type de base.

Ni Java ni Python ne comprennent ce concept de pointeur ; ils utilisent exclusivement de références. L'argument justifiant ce choix est de décharger le programmeur de la responsabilité de la gestion dynamique de données et du contrôle nécessaire associé. Par contre, la plupart des langages algorithmiques tels ALGOL68, Pascal, C, C++, etc. et les versions modernes de FORTRAN, COBOL, BASIC, etc. définissent de tels types, qu'ils implantent un ramasse-miettes (garbage collector) ou qu'ils laissent le soin au programmeur de désallouer explicitement les objets en fin de vie.

Le ramasse-miettes (garbage collector) est un algorithme sophistiqué de détection des zones mémoire devenues inaccessibles qui peuvent a priori être réutilisées à d'autres fins. Ce processus est appelé automatiquement, à intervalles réguliers ou en cas de manque de place apparent, et de façon transparente pour le programmeur. Toutefois, il est souvent possible de lancer le ramasse-miettes explicitement : c'est une des fonctions de la bibliothèque. Java et Python fonctionnent à l'aide d'un ramasse-miettes.

Une référence est un type de valeur — ce n'est donc pas un type d'objet ni un objet en soi — qui fournit l'accès indirect à une entité (objet, fonction...). Une référence est liée (bound) à une entité particulière, sauf lorsqu'elle est nulle (ce qui n'est pas possible dans tous les langages). Le déréférencement (dereferencing) donnant accès à l'objet lié est implicite.

En général, vu ce déréférencement automatique et inévitable, on utilise une référence dans les expressions et instructions « comme si » l'on utilisait directement l'objet lié ; il n'est donc pas possible d'accéder à la valeur propre de la référence.

En Python, toutes les variables sont des références ; ce sont les objets auxquels elles sont liées qui ont un type particulier. En Java aussi, à l'exception des types primitifs et des énumérations. Par contre, en C, il n'existe aucune référence (toutes les adresses sont des pointeurs), mais en C++, il s'agit d'un type composé distinct. Plus précisément, en Python, l'association, le lien ou la liaison (binding) d'un nom avec une valeur se fait principalement à l'aide de l'instruction d'assignation («=»). Ce lien est brisé à l'aide de l'instruction « del ».

Déclarations et portée

Les « instructions déclaratives » sont apparues dès le début des langages de programmation. Leurs rôles sont multiples et encore peu définis. Elles constituent, pour ces premiers langages, non des instructions stricto sensu, donc destinées à être traduites en code exécutable, mais plutôt des indications au compilateur modifiant la façon dont il traduira d'autres instructions. Parmi ces indications, la plus fréquente (et importante) est celle indiquant le type d'une variable, donc le codage binaire utilisé et le type d'opération et de conversion à lui appliquer.

Comme déjà dit, dans le langage Python, il n'y a pas lieu de déclarer une variable puisqu'un nom acquiert ce statut lorsqu'on lui assigne une valeur. Cette valeur est en fait utilisée pour la construction d'un objet qui est simplement lié au nom. Toute variable est donc une simple référence vers un objet et elle peut être liée successivement à des objets de types différents en cours d'exécution du programme.

Recherche du nom

La recherche du nom (name lookup) est la méthode — donc l'algorithme suivi par le compilateur ou l'interpréteur — qui permet d'associer un nom apparaissant dans une instruction ou tout morceau de code source à sa déclaration, ce qui en identifie les propriétés, type et autres attributs, et le sens précis de cette instruction.

Le mécanisme de recherche du nom en Python est particulièrement complexe et finalement peu intuitif. En effet, les variables n'étant pas déclarées et le mécanisme d'exécution étant conçu comme un système interprété, la recherche s'effectue non selon la logique du texte du programme source, comme dans l'écrasante majorité des langages, même interprétables, mais bien dans l'ordre et le contexte où est exécutée l'instruction qui utilise le nom, ce qui suppose de prendre en compte toutes les instructions déjà exécutées, y compris celles qui suivent dans le texte source.

Comme l'écrit le concepteur même de ce langage, Guido van Rossum : [...] This rule is subtle. Python lacks declarations and allows name binding operations to occur anywhere within a code block. The local variables of a code block can be determined by scanning the entire text of the block for name. [4.2.2]

Modèle d'exécution : les blocs

Le modèle d'exécution d'un programme Python et de recherche des noms est donc fondé sur celui de bloc. Un bloc est un morceau de code qui est interprété comme un tout. C'est l'équivalent logique d'une unité de traduction, même si ce terme est ici inapproprié ; on pourrait utiliser, par analogie, « d'unité d'exécution » ou « d'unité d'interprétation ».

Un bloc en Python est : - soit un module : un morceau de code, un fichier source, chargé directement par appel à l'interpréteur, depuis le système ou à l'aide de l'instruction «import» ou appel aux fonctions «eval» ou «exec» ; - soit un corps de fonction : c'est le sous-morceau de code introduit, « contrôlé » par l'instruction « def » qui n'est exécuté que lors d'un appel à la fonction ; - soit une définition de classe : c'est le morceau de code introduit par le mot-clé « class » dont l'exécution a pour effet de créer un objet particulier de type classe accueillant les attributs d'instance de classe, selon un mécanisme décrit plus loin.

Ces blocs peuvent être imbriqués : une fonction ou une classe dans un module, dans une fonction, dans une classe, etc. La hiérarchie de blocs dans laquelle apparaît l'instruction créant un nouveau bloc est ce qu'on appelle l'environnement du bloc.

Chaque bloc en Python dispose de son propre espace de nommage sous la forme d'un dictionnaire contenant les références aux objets locaux. Lors de l'exécution d'un bloc, l'interpréteur lui alloue un bloc d'activation — un objet de type prédéfini «frame» — sur une pile d'exécution (gérée comme une donnée propre par l'interpréteur). On y trouve, entre autres, un attribut (de type référence) lié à un objet de type dictionnaire : le dictionnaire local. Le dictionnaire du module principal d'un programme est créé dès le début de l'interprétation de celui-ci et s'appelle « main » ; celui d'une classe ou d'une fonction est son attribut « dict ».

Vie des objets en Python

Le modèle d'exécution a donc pour conséquence que toutes les variables, y compris les paramètres, sont de simples entrées dans un dictionnaire, elles-mêmes des références vers des objets et tous les objets sont placés dans une zone de mémoire de type « tas » (heap). Le mode de stockage est donc dynamique. Les objets sont créés lors de l'exécution de la première liaison : - « = » : assignation, liaison à un objet contenant la valeur du membre de droite ; - Variable de contrôle dans l'en-tête d'un « for » ; - « as » dans une instruction « with » ou « except » ; - Appel de fonction : attribution de la valeur de chaque paramètre effectif ; - « import » : import du ou des noms définis dans le module ; - « def » et « class » : définition de classe ou fonction.

Ils sont détruits par un mécanisme de ramasse-miettes fondé sur un décompte de références vers l'objet dont la fréquence d'appel et le fonctionnement sont indéfinis (ils dépendent de l'implantation de l'interpréteur).

Il est à noter qu'un objet cesse rapidement d'être référencé en Python, donc que la mémoire contient de très nombreux « zombies », des objets inutiles. En effet, comme nous l'avons vu, la plupart des types prédéfinis en Python sont immuables. Les seules exceptions sont les séquences variables (mutable sequences), c'est-à-dire «list» et «bytearray», les dictionnaires «dict», les ensembles «set» et quelques « types internes ». Par conséquent, dès qu'on souhaite modifier une variable référençant un objet immuable, cette référence est déliée, puis liée à un nouvel objet, ce qui fait que l'ancienne valeur devient un objet non référencé, donc situé ailleurs en mémoire et devenu inutile. Voyons cela sur un exemple.

x = 1
for i in range(1, 100):
    x = i * x
print(x)

Ce programme — qui calcule la factorielle 100! — remplit en fait la mémoire de 100 objets inutiles : les valeurs successives de i !, puisqu'à chaque itération un nouvel objet est créé, puis lié à x, laissant l'ancienne valeur simplement non référencée. En C/C++, toutes les valeurs successives auraient été contenues dans une seule et même variable.

Déclarations de portée

A priori, une instruction ayant pour effet de lier un objet à une variable fait référence à une variable du dictionnaire local, une référence qu'elle modifie ou crée dans ce dictionnaire. Ce comportement peut être modifié par deux instructions particulières. Elles n'ont pas d'effet sur la recherche des noms, mais toutes deux inhibent la création d'entrées dans le dictionnaire local.

La déclaration « global »

L'instruction « global » indique que les noms fournis dans la suite sont à rechercher ou à insérer dans le dictionnaire global, c'est-à-dire celui du module dans lequel le bloc est écrit, et non dans le dictionnaire local. Pour une fonction, y compris une méthode de classe, ce dictionnaire global est accessible via son attribut « globals ».

Cette déclaration est une instruction interprétée à la place où elle est rencontrée dans l'ordre d'exécution du code, mais son effet s'applique à l'ensemble du bloc où elle apparaît. Par conséquent, une variable locale (ou un paramètre formel) de même nom ne peut avoir été créée précédemment dans ce bloc.

Vu le sens de l'assignation, il est impossible de modifier une variable globale si elle n'a pas été déclarée comme telle, car l'occurrence d'un nom comme membre de gauche l'insère dans le dictionnaire local, s'il n'y figure pas déjà. Les variables créées au niveau d'un module sont à la fois locales à ce bloc et globales pour le code de ce module.

La déclaration « nonlocal »

L'instruction précédente permet d'utiliser des variables du dictionnaire global, mais si l'on veut simplement éviter la création de variables locales pour réutiliser celles existant dans l'environnement, il faut les déclarer par une instruction « nonlocal ». Cette instruction ne peut lister que des noms qui existent déjà dans l'environnement (et pas dans le dictionnaire local, bien sûr).

Comme c'est le cas pour « global », cette instruction est interprétée dans l'ordre d'exécution du code et aucune des variables ne peut avoir été créée localement dans son bloc.

Nous allons maintenant illustrer cela.

1  x = 7
2  z = -1
3  def f():
4      x = 42         # crée une variable x locale à f, masque le x global
5      def g():
6          nonlocal x
7      x+=1       # modifie la variable x de f
8      g()            # ce x vaut maintenant 43
9      global y       # crée une variable y globale
10     y = x * z      # usage de la variable z globale
11 print(x, z)        # 7 -1 ; y n'existe pas encore
12 f()
13 print(x, y, z)     # 7 -43 -1 ; x global vaut toujours 7

Recherche du nom

Comme nous venons de le voir à la ligne 10, une variable « z » non locale à un bloc peut être utilisée sans être déclarée par une instruction particulière «nonlocal» ou «global». C'est ce qu'on appelle une variable libre.

Une variable libre (free variable) est une variable (un nom) qui est utilisée dans un bloc sans y être définie.

La recherche du nom de Python se fait dans le bloc local, puis on « remonte » dans l'environnement. Mais il y a une différence notoire avec l'algorithme de C ou de C++ : cette recherche a lieu à l'exécution, donc dynamiquement, et pas statiquement selon le texte du programme. Ceci peut induire des comportements, certes conformes à la définition du langage, mais particulièrement étranges ou inattendus.

x = 7
def f():
    global y   # crée une variable y globale
    y = 2 * x  # usage de la variable x globale
print(x)       # 7 ; y n'existe pas encore
f()
print(x, y)    # 7 14 ; OK
x = "abc"      # y calculé selon la nouvelle valeur de x
f()
print(x, y)    # abc abcabc

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 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 n'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édante 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 sont "vus comme" privés sans vraiment l'être (voir variables privées).