Typage#

À ce stade, nous avons déjà rencontré la plupart des types de données essentiels de Scheme  :

  • les nombres,

  • les chaînes de caractères,

  • les booléens,

  • les paires,

  • la liste vide (qui est une valeur spéciale),

  • les symboles,

  • les fonctions (également appelées « procédures »).

(Rappelez-vous que les listes sont construites de manière indirecte, en assemblant des paires et la liste vide.)

Il est maintenant temps d’examiner de plus près la manière dont les types fonctionnent. Nous allons notamment nous intéresser à cette question : que signifie exactement « Telle variable a tel type » en Scheme ?

Typage dynamique#

En matière de typage, on divise généralement les langages de programmation en deux catégories, les langages typés statiquement et typés dynamiquement. Lorsqu’un langage possède un typage statique, le type de chaque variable ou valeur est déterminé à l’avance. Non seulement cela permet en soi une exécution plus rapide, mais c’est aussi une première étape nécessaire pour pouvoir compiler le code en code machine optimisé (ce qui est par exemple le cas pour le noyau C++ de LilyPond). Cependant, cette approche a l’inconvénient, selon les langages d’être plus complexe, ou bien trop rigide, ou les deux. Dans un langage comme le C, il est difficile d’écrire certains programmes sans des transtypages qui sont hasardeux car le système de types est trop simple pour pouvoir vérifier qu’ils sont corrects. À l’inverse, en Haskell, Rust ou C++, le système de types est relativement sophistiqué et gère ce genre de cas, mais demande un certain apprentissage. Scheme, dans sa grande simplicité, est un langage au typage dynamique, ce qui signifie que le type d’une valeur n’est pas connu avant d’exécuter le programme. C’est la même approche que celle de Python, JavaScript, Lua ou encore R. Prenons un programme en Rust qui est mal typé :

fn main() {
    println!("Hello, World!");
    println!("2 + false = {}", 2 + false);
}

Le compilateur Rust renvoie une erreur :

error[E0277]: cannot add `bool` to `{integer}`
 --> tmp.rs:3:34
  |
3 |     println!("2 + false = {}", 2 + false);
  |                                  ^ no implementation for `{integer} + bool`
  |
  = help: the trait `Add<bool>` is not implemented for `{integer}`
  = help: the following other types implement trait `Add<Rhs>`:
            <&'a f32 as Add<f32>>
            <&'a f64 as Add<f64>>
            <&'a i128 as Add<i128>>
            <&'a i16 as Add<i16>>
            <&'a i32 as Add<i32>>
            <&'a i64 as Add<i64>>
            <&'a i8 as Add<i8>>
            <&'a isize as Add<isize>>
          and 48 others

error: aborting due to previous error

Remarquez qu’aucun code n’a été exécuté. En dépit de la ligne println!("Hello, World!"); au début du programme, qui est la syntaxe Rust pour afficher « Hello, World ! », nous n’avons pas vu de « Hello, World ! » dans la console. En effet, en Rust, le type de chaque valeur est déterminé pendant la compilation, à savoir le processus qui produit le code exécutable. Comparons cela au code Scheme équivalent :

(begin
  (ly:message "Hello, World!")
  (ly:message "2 + #f = ~a" (+ 2 #f)))

En exécutant ce code dans le bac à sable Scheme, on obtient :

Hello, World!ice-9/boot-9.scm:1685:16: In procedure raise-exception:
In procedure +: Wrong type: #f

Cette fois-ci, contrairement au programme Rust, le message « Hello, World ! » a été affiché. L’interpréteur Scheme n’essaie pas de déterminer le type de chaque argument à + à l’avance. Il voit un begin, donc exécute chacune des expressions qu’il contient. La première écrit « Hello, World ! », puis la seconde évalue une sous-expression (+ 2 #f), qui provoque une erreur lorsque la fonction + se rend compte qu’elle a reçu un argument invalide.

Dit de façon plus condensée, seules les valeurs de Scheme ont un type, mais pas les variables. Une variable n’a pas de type prédéterminé, mais au moment de l’exécution, les variables stockent des valeurs, et ces valeurs ont chacune un type.

Dans les langages typés statiquement, les types de données composites, c’est-à-dire formés à partir d’autres valeurs, comme les listes ou leurs équivalents, sont généralement « homogènes » : les valeurs qu’ils contiennent doivent impérativement toutes avoir le même type. À nouveau, ce programme Rust provoque une erreur de compilation :

fn main() {
  let vec = vec![1, 2, true, "Hello, World!"];
}

Et à nouveau, l’équivalent Scheme fonctionne parfaitement :

'(1 2 #t "Hello, World!")
 (1 2 #t "Hello, World!")

On retrouve le fait que Scheme est un langage au typage flexible. Demander à tous les éléments d’une liste d’avoir le même type ne permettrait pas un gain de performance (car l’interpréteur ne peut pas en tirer parti dans un langage aussi dynamique que Scheme), et cela ne correspondrait pas à un langage conçu d’abord pour la simplicité.

Grâce à cette flexibilité, rien n’empêche d’écrire des fonctions qui prennent des arguments dont le type varie d’une exécution à l’autre. Pensez à la fonction filter :

(filter (lambda (x)
          (= x 0))
        '(0 1 2 3 4))
 (0)
(filter (lambda (x)
          (eq? x 'sym))
        '(sym #t sym2 #f sym3 sym4))
 (sym)

Dans cet exemple, le second argument de filter est tantôt une liste de nombres, tantôt une liste contenant des symboles et des booléens. Dans la section précédente, nous avons vu comment réimplémenter la fonction filter :

(define (filter function lst)
  (let loop ((rest lst)
             (acc '()))
    (if (null? rest)
        (reverse acc)
        (loop (cdr rest)
              (if (function (car rest))
                  (cons (car rest)
                        acc)
                  acc)))))

Dans ce code, il n’y a besoin de rien de particulier pour expliquer à l’interpréteur le lien entre les types des arguments (à savoir que le premier argument doit être une procédure, le deuxième argument doit être une liste, et la procédure doit accepter les éléments de la liste comme arguments valides). Tant que les types sont acceptables au moment de l’exécution, l’interpréteur est satisfait.

Prédicats de type élémentaires#

Jusqu’ici, il nous a rarement fallu nous préoccuper de types. Supposons que nous ayons cette fonction :

(define (égale-deux-ou-moins-deux x)
  (= 2 (abs x)))

(abs est la fonction valeur absolue : \(abs(x) = x\) si \(x\) est positif et \(abs(x) = -x\) si \(x\) est négatif.)

Il est ici implicite que l’argument x doit nécessairement être un nombre. Nous le comprenons en tant qu’humains, et si d’aventure une valeur qui n’est pas un nombre est passée à la fonction, la fonction abs provoque une erreur. De plus, comme le résultat de abs est toujours un nombre, il n’y aura jamais d’erreur dans l’appel à =.

Prenons une deuxième fonction :

(define années-naissance
  '(("Dowland" . 1563)
    ("Mozart" . 1756)
    ("Schumann" . 1810)
    ("Hába" . 1893)
    ("Weill" . 1900)
    ("Ligeti" . 1923)))

(define (500ième-anniversaire compositeur)
  (let ((année-naissance (assoc-ref années-naissance compositeur)))
    (+ 500 année-naissance)))

(500ième-anniversaire "Mozart")  2256

Cette fonction fait ce que l’on attend d’elle sur les compositeurs de l’alist années-naissance, mais provoque une erreur pour d’autres compositeurs, car assoc-ref renvoie #f si le compositeur n’est pas dans l’alist, et + additionne des nombres, pas des booléens.

(get-500th-birthday-year "Brahms")
 In procedure +: Wrong type argument in position 1: #f

Plutôt que lever une erreur, il pourrait être souhaitable, selon les cas, de renvoyer #f. Nous savons le faire avec if :

(define birth-years
  '(("Dowland" . 1563)
    ("Mozart" . 1756)
    ("Schumann" . 1810)
    ("Hába" . 1893)
    ("Weill" . 1900)
    ("Ligeti" . 1923)))

(define (get-500th-birthday-year composer)
  (let ((birth-year (assoc-ref birth-years composer)))
    (if birth-year
        (+ 500 birth-year)
        #f)))

(get-500th-birthday-year "Brahms")
 #f

Ce code est correct car nous savons que assoc-ref renvoie ici soit un nombre, soit #f, et on peut distinguer le cas de #f avec if. Mais quid si nous voulions distinguer non pas entre nombres et booléens, mais entre, par exemple, nombres et listes ? Enrichissons notre alist pour y mettre non pas seulement l’année de naissance, mais la date de naissance complète, sous forme d’une liste (jj mm aaaa), avec toutefois un cas particulier : si la date exacte de naissance d’un compositeur n’est pas connue, il n’y aura que l’année.

(define années-naissance
  '(("Dowland" . 1563)
    ("Mozart" . (27 01 1756))
    ("Schumann" . (08 06 1810))
    ("Hába" . (21 06 1893))
    ("Weill" . (02 03 1900))
    ("Ligeti" . (28 05 1923))))

(define (500ième-anniversaire compositeur)
  (let ((année-naissance (assoc-ref années-naissance compositeur)))
    ???))

Si le typage dynamique de Scheme est une fonctionnalité plutôt classique des langages interprétés, la manière qu’a le langage de donner des informations sur les types est, elle, beaucoup moins habituelle. Dans des langages comme Python et JavaScript, il existe une fonction type ou typeof, qui renvoie un objet représentant le type d’une valeur. Par exemple, en Python, on peut écrire :

>>> type(5)
<class 'int'>
>>> type(True)
<class 'bool'>

Bien que Guile possède effectivement une fonction de ce style (la fonction class-of du module (oop goops)), elle ne fait pas partie du langage Scheme standard, et est rarement utilisée en pratique, du moins dans LilyPond. L’approche usuelle est complètement différente. Scheme fournit une panoplie de fonctions pour cela, appelées prédicats de type. Pour rappel, on appelle « prédicat » toute fonction qui renvoie un booléen (le nom d’un prédicat se termine par convention par un point d’interrogation). Parmi les prédicats, les prédicats de type sont ceux qui déterminent si une valeur donnée est d’un type donné. Voici les prédicats de type pour les types les plus courants :

  • number?, pour les nombres,

  • string?, pour les chaînes de caractères,

  • boolean?, pour les booléens,

  • pair?, pour les paires,

  • null?, pour la liste vide,

  • symbol?, pour les symboles,

  • procedure, pour les procédures (c’est-à-dire les fonctions).

La particularité de ce système, qui peut rendre perplexe au départ, est que l’on ne pose jamais à l’interpréteur de question de la forme « Quel est le type de cette valeur ? », mais seulement de la forme « Est-ce que cette valeur est du type X ? », comme « Est-ce que cette valeur est un booléen ? ».

Dans l’exemple ci-dessus, nous pouvons nous servir de pair? pour distinguer les paires des nombres :

(define dates-naissance
  '(("Dowland" . 1563)
    ("Mozart" . (27 01 1756))
    ("Schumann" . (08 06 1810))
    ("Hába" . (21 06 1893))
    ("Weill" . (02 03 1900))
    ("Ligeti" . (28 05 1923))))

(define (500ème-anniversaire compositeur)
  (let ((date-naissance (assoc-ref dates-naissance compositeur)))
    (cond
     ((pair? date-naissance)
      (list (list-ref date-naissance 0)
            (list-ref date-naissance 1)
            (+ 500 (list-ref date-naissance 2))))
     ((not date-naissance)
      #f)
     (else
      (+ date-naissance 500)))))

(500ème-anniversaire "Mozart")
 (27 1 2256)
(500ème-anniversaire "Dowland")
 2063
(500ème-anniversaire "Beethoven")
 #f

Définir des prédicats#

Jusqu’ici, nous n’avons pas parlé des listes. Comment faire pour savoir si une valeur est une liste ? Nous avons déjà étudié la définition, récursive, des listes : une valeur Scheme est une liste si c’est soit la liste vide, soit une paire dont le cdr est une liste. Cette définition en français se traduit naturellement en une définition sous forme de code :

(define (list? x)
  (or (null? x)
      (and (pair? x)
           (list? (cdr x)))))

Étant donné qu’il est fréquent d’avoir à vérifier si une valeur est une liste, cette fonction list? est en fait prédéfinie, et il n’y a pas besoin de la redéfinir[^circular-list].

Cependant, le fait qu’il ait été possible de redéfinir nous-mêmes cette fonction list? illustre l’élégance du concept des prédicats de type par rapport à une hypothétique fonction qui donnerait « le » type d’un objet. Dans certains cas, il peut être utile de définir ses propres types à partir des types de base. Pour ne citer qu’un exemple, LilyPond définit un prédicat color? pour les couleurs. (color? x) renvoie #t si son argument est soit une chaîne de caractères, soit une liste de 3 ou 4 nombres entre 0 et 1. Certains langages, comme Rust, possèdent un système d‘« interfaces » ou « traits », qui permettent, en simplifiant beaucoup, de déclarer un type X tel que toutes les valeurs de type Y ou de type Z sont aussi de type X. Dans sa recherche de minimalisme, Scheme opte pour une approche qui permet cette flexibilité sans avoir besoin de syntaxe ou de concepts supplémentaires, puisqu’il suffit de simples procédures pour représenter les types. En conclusion, bien qu’il puisse paraître bête à première vue de se restreindre ainsi aux questions « Est-ce que x est de type X ? » sans jamais poser de question « Quel est le type de x ? », il y a une bonne raison à cela. Cela permet à une même valeur d’avoir plusieurs types différents. La valeur "red" est une chaîne de caractère (qui satisfait au prédicat string?), mais aussi une couleur (prédicat color?). C’est grâce à ce principe que l’on peut définir ses propres types, qui se présentent exactement sous la même forme que les types prédéfinis.