1  Des calculs mathématiques aux programmes informatiques

Exercice 1.1 (Calculs mathématiques, programmes informatiques) Comment définir

  • un calcul mathématique ?
  • un programme informatique ?

La définition peut d’abord être informelle, mais la formalisation est nécessaire pour développer an aval des programmes et en amont des langages de programmation.

Un calcul est une expression contenant des opérations, des constantes et des variables : par exemple, \(5 * 3\) ou \(x + 1\). Plus formellement, c’est un terme d’une algèbre qu’il est possible de représenter formellement par un mot comme précédemment, ce qui implique de prendre des précautions pour lever toute ambiguïté :

  • des parenthèses pour l’association correcte : \((5 + 3) * 2\) pour associer le \(3\) à gauche et non à droite,
  • des conventions d’association : \(5 + 3 + 2\) étant \((5 + 3) + 2\) si l’association de \(3\) se fait à gauche,
  • des règles de priorité : \(5 + 3 * 2\) étant \(5 + (3 * 2)\), car la multiplication \(*\) est prioritaire sur l’addition \(+\).

Il existe aussi des écritures sans aucune ambiguïté, en plaçant les opérateurs en tête ou en queue, avec une notation dite préfixe ou suffixe respectivement, qui suppose que le nombre d’arguments de chaque opération est fixe.

Pour les trois termes précédents :

  • notation préfixe : \(*\ +\ 5\ 3\ 2, \quad +\ +\ 5\ 3\ 2, \quad +\ 5\ *\ 3\ 2,\)
  • notation suffixe : \(5\ 3\ +\ 2\ *, \quad 5\ 3\ +\ 2\ +, \quad 5\ 3\ 2\ *\ +.\)

Il s’avère que la notation linéaire par des mots n’apparaît pas très lisible. La solution consiste à passer en deux dimensions et de représenter les termes sous la forme d’arbres : on peut considérer que ces arbres constituent réellement l’essence mathématique des termes. La Figure 1.1 donne la représentation arborescente des trois termes ci-dessus.

Pour décrire l’ensemble des termes sous une telle forme arborescente, nous utilisons des grammaires sous une forme particulière, dite de Backus-Naur. Par exemple, la grammaire de la Figure 1.2 qui décrit les termes de l’arithmétique se lit ainsi :

  • un terme \(t\) est
    • soit une variable \(x\),
    • soit une constante \(c\), soit un entier relatif,
    • soit une addition \(t_1 + t_2\) appliquée à deux sous-termes \(t_1\) et \(t_2\), qui sont aussi des termes de la forme générique \(t\),
    • soit une multiplication \(t_1 * t_2\) appliquée à deux sous-termes \(t_1\) et \(t_2\), qui sont aussi des termes de la forme générique \(t\),
    • soit une opposition \(-t*\) appliquée à un terme de la forme générique.

Sa nature récursive apparaît lors de l’application d’opérations à d’autres termes.

Si on utilise les termes, c’est pour réaliser des calculs et ainsi produire des résultats de calculs : c’est l’évaluation des termes. Elle consiste d’abord à utiliser la définition des opérations. Par exemple, \(5 + 3\) s’évalue en \(8\), comme \(3 + 5\). Cette évaluation permet de définir l’égalité : deux termes ayant la même valeur sont égaux. Ainsi \(5 + 3 = 8\), mais aussi \(3 + 5 = 8\), et donc \(5 + 3 = 3 + 5.\) C’est en fait une loi générale, dite de commutativité : pour tout terme \(s\) et \(t\), on \(s + t = t + s\). C’est le cas notamment si \(s\) et \(t\) sont des variables \(x\) et \(y :\) on ne peut pas évaluer \(x + y\) ni \(y + x\) mais on peut cependant affirmer que \(x + y = y + x.\) On utilise alors des propriétés particulières des opérations pour produire des équations, éventuellement pour réaliser des évaluations partielles.

flowchart TD
  A[*] --> A1[+]
  A --> A2[2]
  A1 --> A11[5]
  A1 --> A12[3]
  B[+] --> B1[+]
  B --> B2[2]
  B1 --> B11[5]
  B1 --> B12[3]
  C[+] --> C1[5]
  C --> C2[*]
  C2 --> C21[3]
  C2 --> C22[2]

Figure 1.1: Les termes sous une forme arborescente

\[ \begin{array}{crcll} \textrm{terme} & t & ::= & x \mid y \ldots & \textrm{variable} \\ && \mid & c & \textrm{constante} \\ && \mid & t + t & \textrm{addition} \\ && \mid & t * t & \textrm{multiplication} \\ && \mid & -t & \textrm{opposition} \\ \textrm{constante} & c & ::= & 0 \mid -1 \mid 1 \mid -2 \mid 2 \mid \ldots & \textrm{entiers relatifs} \\ \end{array} \]

Figure 1.2: Grammaire des termes de l’arithmétique

Évidemment, un programme réalise des calculs, habituellement plusieurs calculs, et non pas un seul. Son trait distinctif est qu’il organise ces calculs de manière pratique en permettant de nommer certains calculs par l’usage de variables choisies par le programmeur, puis de se référer à leurs résultats en invoquant le nom de la variable. Voici quelques exemples en Python.

x = 5 + 3
y1 = x * 2
y1

x = 5 + 3
y2 = x + 2
y2

x = 3 * 2
y3 = 5 + x

(y1, y2, y3)
(16, 10, 11)

Certains calculs sont naturellement paramétrés. Par exemple si on calcule des puissances de nombres plusieurs fois, on peut s’apercevoir qu’on répète la même méthode de calcul, soit le même algorithme, et que ces calculs sont paramétrés par le nombre et son exposant. Aussi on préfère définir une fonction,

  • en la nommant, par \(P\) par exemple,
  • en la paramétrant par deux variables, mettons \(x\) et \(n\), et
  • en définissant une expression dépendant des paramètres \(x\) et \(n\) pour décrire le calcul réalisé.

Mathématiquement, la fonction puissance peut se définir ainsi, pour un exposant entier naturel, par récurrence :

\[ P(x, n) := \left\{ \begin{array}{ll} 1 & \textrm{si } n = 0 , \\ x * P(n-1) & \textrm{si } n > 0. \\ \end{array} \right. \]

Voici la traduction en Python. La norme en programmation est d’utiliser des noms longs, parlants pour faciliter la lecture et éviter la charge de mémorisation du sens de chaque nom : le nom doit porter le sens.

def puissance(x, n) :
    if (n == 0) :
        return 1
    return x * puissance(x, n - 1)

# tests - primitive native pour la puissance : **
(puissance(2, 10), 2**10)
(1024, 1024)

Cette pratique d’utiliser des noms intermédiaires est courante lorsqu’on réalise une démonstration en Mathématiques. En théorie, il serait possible de définir une démonstration comme une seule expression appartenant au langage formel de la logique. En pratique, on divise pour régner, en définissant progressivement les différentes composantes de la démonstration. Par exemple, on peut énoncer : soit \(P\) la fonction puissance définie par \(P(x, n) := \ldots\).

Finalement, il devient possible de définir un langage de programmation et un programme dans ce langage. Un langage de programmation permet de définir

  • des expressions pour réaliser des calculs,
  • un environnement, une suite, ou plus généralement une structure arborescente, de liaisons entre des variables (possiblement fonctionnelles, avec des paramètres) et des expressions,
  • une expression principale, qui produit le résultat du programme.

Un programme est une suite de définitions formant un environnement et une expression principale utilisant cet environnement.

Exercice 1.2 (Typage) Lorsqu’on réalise des calculs, on applique des opérations ou des fonctions à des arguments. Est-ce toujours sensé ? Répondre par analogie avec l’analyse dimensionnelle en Physique. Proposer une discipline de typage pour les expressions d’un langage de programmation, avec l’idée qu’il doit être possible d’évaluer abstraitement un programme en utilisant un type, avant son évaluation réelle.

Imaginez que vous calculez une vitesse en physique :
\[ \texttt{vitesse} = \frac{\texttt{distance}}{\texttt{temps}} \quad \color{green}{\texttt{[m/s]}}. \]
Le système d’unités (mètres, secondes) vérifie la cohérence sans calculer les valeurs.

En programmation, le typage est similaire.

  1. Chaque donnée a un type :
    • entier (ex: 42),
    • chaîne (ex: "Bonjour"),
    • liste (ex: [1, 2, 3]).
  2. Avant de réaliser l’opération, on vérifie les règles de compatibilité.
    # Valide : entier + entier → entier  
    3 + 5 # type `entier`  

    # Invalide : entier + chaîne → ERREUR  
    3 + "abc" # bloqué par le typage  
TypeError: unsupported operand type(s) for +: 'int' and 'str'

Il est possible d’interpréter un programme abstraitement, de tête pourrait-on dire, sans faire réellement les calculs, mais en calculant avec les types, comme avec les dimensions en Physique. C’est l’interprétation abstraite associée au typage.

  • On calcule avec les types (abstraits) plutôt qu’avec les valeurs (concrètes).
  • Exemple :
    \[ \frac{\color{blue}{\texttt{liste}}}{\color{red}{\texttt{entier}}} \quad \color{red}{\texttt{→ ERREUR}} \]
    Analogie : Diviser des mètres par des secondes au carré n’est pas une vitesse !

Pourquoi est-ce utile ?

  • Sûreté : Bloque des opérations absurdes avant l’exécution.
  • Clarté : Le type documente l’usage d’une donnée.
  • Optimisation : Le compilateur1 génère un code plus efficace.

En résumé : Le typage peut mener à un contrôle dimensionnel qui garantit le bon fonctionnement du programme, sans même le faire tourner.

Les langages de programmation utilisent des types2 mais réalisent des contrôles à des moments différents, avant, statiquement, ou pendant l’exécution, dynamiquement.

En Python, le contrôle se fait pendant l’exécution. Il est cependant possible non seulement de typer les fonctions à des fins de documentation mais aussi d’utiliser des interpréteurs abstraits à des fins de contrôle statique, comme l’explique la documentation.

Version sans type

    def salutation(nom):  
        return "Bonjour, " + nom;  # Valide : chaîne + chaîne  
  
    salutation(17); # Erreur à l'exécution : 17 n'est pas une chaîne !  
TypeError: can only concatenate str (not "int") to str

Version typée

    def salutation(nom: str) -> str : # documentation 
        return "Bonjour, " + nom;  # Valide : chaîne + chaîne  
  
    salutation(17); # Erreur à l'exécution : 17 n'est pas une chaîne !  
TypeError: can only concatenate str (not "int") to str

Sans outil adjoint, les erreurs n’apparaissent qu’à l’exécution.

En Java, le contrôle se fait presqu’entièrement avant l’exécution : le typage est dit statique, au sens où les types se tiennent là, pour servir avant l’exécution, la phase dynamique. Il est nécessaire de définir explicitement le typage dans le code : le typage est dit explicite. Parfois les types peuvent être omis : le typage devient implicite. Certains langages comme Ocaml réalisent la prouesse de deviner tous les types : ils utilisent une inférence de types.

public class XXX {
    public static String salutation(String nom) {
        return "Bonjour, " + nom;
    }
}

XXX.salutation(15)

Le compilateur, précisément l’interpréteur abstrait chargé de vérifier le typage, produit une erreur :

The method salutation(String) in the type XXX is not applicable for the arguments (int).

Exercice 1.3 (Type : le point de vue extérieur de la spécification) Comment spécifier un type pour pouvoir l’utiliser ? Quels services rend-il ? Pour répondre, on pourra s’appuyer sur la définition des structures mathématiques. Proposer la définition d’une interface, qui déclare les méthodes à l’interface entre le monde extérieur et les objets du type.

Développer l’exemple des entiers naturels en Java.

  • Justifier que l’interface minimale du type Nat peut être définie ainsi, suivant la définition inductive des entiers naturels : \[ \begin{array}{crcll} \textrm{naturel} & n & ::= & 0 & \textrm{zéro} \\ && \mid & \mathtt{S}\ n & \textrm{successeur de } n.\\ \end{array} \]

    public interface Nat {
      // Accesseurs
      boolean estZero(); // teste si l'entier est nul.
      Nat predecesseur(); // renvoie le prédécesseur de cet objet.
      // Fabrique
      Nat zero(); // renvoie zéro.
      Nat successeur(); // renvoie le successeur de cet objet. 
    }
  • Pour vérifier que cette interface minimale suffit, définir une classe-module3 contenant les fonctions somme, produit et egalite, calculant la somme et le produit et testant l’égalité de deux entiers. On utilisera la décomposition naturelle d’un entier naturel en soit zéro, soit le successeur du prédécesseur, et la récursion. Pour fabriquer de nouveaux entiers naturels, on utilisera zero() et successeur().

    public class ModuleNat {
      public static Nat somme(Nat n, Nat p){
        if(n.estZero())
          return ...:
        return (... n.predecesseur() ...).successeur();
      }
      public static Nat produit(Nat n, Nat p){
        if(n.estZero()){
          return n.zero();
        }
        return ...;   
      }
      public static boolean egalite(Nat n, Nat p){
        if(n.estZero())
          return ...
        if(p.estZero())
          return ...
        return egalite(...);
      }

Exercice 1.4 (Type : le point de vue intérieur de l’implémentation) On appelle implémentation4 la réalisation d’un type, soit la définition des valeurs de ce type.

Comment implémenter un type ? Définir l’état d’un objet, avec ses attributs, ses constructeurs, dont le rôle est d’initialiser les attributs de l’état, et les méthodes déclarées dans l’interface, formées des accesseurs qui observent l’état, des fabriques, qui construisent des objets du type et des services, les opérations réalisées avec le type.

Développer en Java deux implémentations des entiers naturels, l’une inductive exploitant la définition par récurrence des entiers naturels, l’autre par restriction des entiers de type int (des entiers relatifs). Les tester en calculant la somme des dix premiers entiers par une itération et par une formule explicite (pour tester l’égalité, on pourra prendre le double de ces quantités car on ne dispose pas sur Nat de la division).

  • Définition inductive – Définir deux classes implémentant l’interface Nat.

    class Zero implements Nat {
      public final static Nat singleton = new Zero();
      // Aucun attribut
      ...
    }
    class Successeur implements Nat {
      // État : un attribut
      private Nat pred;
      // Constructeur
      public Successeur(Nat pred){
          this.pred = pred;
      }
      ...
    }   
  • Restriction des entiers relatifs int – Définir une classe implémentant l’interface Nat par restriction du type primitif int. Son état est formé d’un unique attribut de type int vérifiant une propriété permanente, un invariant,

    class IntRestreint implements Nat {
      // État : un attribut vérifiant toujours this.val >= 0
      private int val;
      // Constructeur
      public IntRestreint(int val){
        this.val = val < 0 ? -val : val;
      }
      // Méthodes
      ...
    } 

Exercice 1.5 (Objets ou modules : deux approches de l’interopérabilité à l’intra-opérabilité) AFFAIRE(sans doute leçon 2)

Exercice 1.6 (Fonctions pures ou impures - Des changements d’état) AFFAIRE(sans doute leçon 3)


  1. Compilateur : traducteur dans le langage propre la machine, virtuelle ou réelle, qui va exécuter le code.↩︎

  2. À l’extrême limite, certains langages théoriques utilisent un seul type : autrement dit ils ne sont pas véritablement typés. En pratique, ce n’est pas le cas : il existe au moins un système minimal de types. ↩︎

  3. Une classe est qualifiée de classe-module si elle ne contient que des éléments statiques (qualifiés de static) : elle ne sert donc pas de modèle pour des objets.↩︎

  4. C’est un anglicisme qui est adopté en français et doit être utilisé tant il décrit bien à la fois l’action et le son produit.↩︎