Le langage OCaml
Cours complet · informatique (MP2I/MPI), chapitre 2 · MP2I et MPI
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
2.1 Le contraire de C, et c'est voulu
Le chapitre précédent s'achevait sur cinq points où C montre la machine : types déclarés, entiers qui débordent, accès non vérifiés, passage par valeur, mémoire rendue à la main. OCaml prend l'autre chemin sur quatre d'entre eux, et le programme officiel dit pourquoi : OCaml « permet en particulier aux étudiants de recourir rapidement à un niveau d'abstraction supérieur et de manipuler facilement des structures de données récursives ».
Deux langages, donc, et à parité : « on veille à développer de façon parallèle les compétences de programmation dans ces deux langages ». Ce n'est pas de la redondance. Un même algorithme écrit deux fois, dans deux langages qui ne font pas les mêmes choix, sépare ce qui appartient à l'algorithme de ce qui n'appartient qu'à sa mise en œuvre. C'est exactement ce qu'un concours vous demandera de savoir distinguer.
| C | OCaml | |
|---|---|---|
| Types | déclarés par le programmeur | inférés par le compilateur |
| Vérification | statique, mais faible | statique et forte |
| Mémoire | `malloc` / `free` | automatique |
| Structure de base | le tableau | la liste et le type somme |
| Style dominant | impératif | fonctionnel, l'impératif restant possible |
Elle porte sur OCaml « version 4 ou supérieure », et distingue là encore ce qui est su sans rappel de ce qui l'est après rappel (section sec:ocaml-apres-rappel). Les modules au sens de module M = struct ... end, les foncteurs, les objets ne sont pas au programme : on utilise des modules (List.length), on n'en écrit pas.
2.2 Tout est expression
En C, if est une instruction : elle fait quelque chose. En OCaml, if est une expression : elle vaut quelque chose.
let m = if a > b then a else b (* m reçoit la VALEUR de l'expression *)
Conséquence immédiate : le else n'est pas facultatif quand la valeur est utilisée — une expression doit valoir quelque chose dans tous les cas. Et les deux branches doivent avoir le même type, car l'expression entière n'en a qu'un.
let x = 3 (* définition globale *)
let y = x + 1 in y * 2 (* définition locale : y n'existe que dans « y * 2 » *)
let carre x = x * x (* une fonction : pas de parenthèses, pas de return *)
let rec fact n = (* rec : la fonction peut s'appeler elle-même *)
if n = 0 then 1 else n * fact (n - 1)
let ... in ... est lui-même une expression : il vaut son second membre.
let x = 3 ne crée pas une variable modifiable : il lie le nom x à la valeur 3, définitivement. Écrire ensuite let x = 4 ne modifie pas la première liaison, il en crée une seconde qui masque la première. La distinction est visible dès qu'une fonction entre en jeu :
let a = 10
let f y = y + a (* f capture la valeur de a AU MOMENT DE LA DÉFINITION *)
let a = 100
let z = f 1 (* z vaut 11, et non 101 *)
C'est la portée lexicale, que l'annexe nomme explicitement : « lorsqu'une définition utilise une variable globale, c'est la valeur de cette variable au moment de la définition qui est prise en compte ».
2.3 Le typage : inféré, fort, polymorphe
Vous n'écrivez pas les types ; le compilateur les trouve.
let double x = 2 * x
(* le compilateur répond : val double : int -> int = <fun> *)
Il a raisonné ainsi : * est la multiplication des entiers, donc x est un int, donc le résultat aussi. La notation int -> int se lit « d'int vers int ».
Quand rien ne contraint le type, il reste variable :
let identite x = x (* val identite : 'a -> 'a *)
let premier (a, b) = a (* val premier : 'a * 'b -> 'a *)
'a se lit « alpha » et désigne n'importe quel type. Une seule fonction sert alors pour tous les types — c'est le polymorphisme, dont l'annexe demande une « idée naïve ».
1 + 2.5 (* ERREUR de compilation : + attend deux int *)
1.0 +. 2.5 (* correct : les flottants ont LEURS opérateurs, suffixés d'un point *)
Les opérateurs flottants sont +., -., *., /.. C'est déroutant deux jours, puis c'est un filet : en C, 7 / 2 valait sans un mot. En OCaml, une division entière ne peut pas se déguiser en division réelle.
« Entiers et flottants sont sujets aux dépassements de capacité », dit l'annexe. Le typage fort protège des mélanges de types, pas de l'arithmétique. Un int OCaml est sur 63 bits, donc plus large qu'un int32_t, mais il déborde tout de même.
2.4 Filtrage par motif
C'est le trait central du langage, et celui qui n'a aucun équivalent en C.
let rec longueur liste =
match liste with
| [] -> 0 (* motif : la liste vide *)
| _ :: reste -> 1 + longueur reste (* motif : une tête, et un reste *)
Chaque motif décrit une forme possible de la valeur, et le premier qui correspond l'emporte. Le motif _ désigne « n'importe quoi, dont je n'ai pas besoin ».
Bonne pratique (Le compilateur vérifie l'exhaustivité, et il faut l'écouter)
Si vous oubliez un cas, OCaml vous avertit : this pattern-matching is not exhaustive. C'est un service que C ne rend pas. Un filtrage incomplet est presque toujours un bogue en attente.
L'annexe pose deux règles sur les motifs : ils « ne doivent pas comporter de variable utilisée antérieurement ni deux fois la même variable », et l'ordre compte « quand ils ont des instances communes ». Le motif le plus général se place donc en dernier.
2.5 Les types structurés
2.5.1 n-uplets et listes
let p = (3, "trois", 3.0) (* type : int * string * float *)
let (a, b, c) = p (* on récupère les composantes SANS match *)
Une liste OCaml est immuable et simplement chaînée. Deux constructeurs seulement : [] la liste vide, et :: qui ajoute une tête.
let l = 1 :: 2 :: 3 :: [] (* équivaut à [1; 2; 3] *)
List.length l (* 3 *)
l @ [4; 5] (* concaténation *)
L'annexe demande de connaître « l'opérateur @ (y compris sa complexité) ». Concaténer l1 @ l2 recopie toute l1 : c'est . Ajouter un élément en fin de liste dans une boucle coûte donc au total. On ajoute en tête (), quitte à renverser à la fin.
2.5.2 Tableaux, et le retour de la mutabilité
Là où la liste est immuable et chaînée, le tableau est mutable et à accès direct.
let t = [| 3; 1; 4 |] (* type : int array *)
let u = Array.make 10 0 (* 10 cases, toutes à 0 *)
t.(0) (* lecture : 3 *)
t.(0) <- 7 (* écriture : la flèche gauche MODIFIE *)
Array.length t (* 3 *)
L'annexe insiste : Array.copy copie « y compris le caractère superficiel de cette copie ». Copier un int array array duplique le tableau extérieur, mais les lignes restent partagées : modifier une ligne de la copie modifie l'original.
let t = [| 1; 2; 3 |] in t.(7)
(* Exception: Invalid_argument "index out of bounds". *)
Comparez au chapitre précédent : le même accès en C écrivait en silence dans une mémoire étrangère. OCaml vérifie, à un petit coût en temps, et vous arrête à l'endroit exact de la faute.
2.5.3 Types sommes et types récursifs
type couleur = Trefle | Carreau | Coeur | Pique (* type énuméré *)
type forme = (* type somme : chaque cas
porte ses propres données *)
| Cercle of float
| Rectangle of float * float
let aire f =
match f with
| Cercle r -> 3.14159 *. r *. r
| Rectangle (l, h) -> l *. h
Les constructeurs commencent par une majuscule, les identifiants par une minuscule.
type 'a arbre =
| Vide
| Noeud of 'a arbre * 'a * 'a arbre
let rec taille a =
match a with
| Vide -> 0
| Noeud (g, _, d) -> 1 + taille g + taille d
Quatre lignes pour le type, quatre pour la fonction. En C, il fallait une structure, un pointeur, un malloc par nœud et un free pour chacun. C'est ce que le programme appelle « manipuler facilement des structures de données récursives », et c'est la raison pour laquelle toute la partie « arbres » du programme se lit mieux en OCaml.
type 'a option = None | Some of 'a (* prédéfini *)
(* Renvoie Some v si la clé figure dans l'association, None sinon. *)
let rec cherche cle assoc =
match assoc with
| [] -> None
| (c, v) :: reste -> if c = cle then Some v else cherche cle reste
option rend l'absence de résultat visible dans le type. Là où C rendait NULL — que rien n'oblige à tester —, OCaml rend une valeur que le filtrage oblige à décomposer.
2.5.4 Ce que l'annexe exige encore
Quatre traits de l'annexe B.1 restent à nommer. Ils sont exigibles sans rappel, et ce sont ceux qu'on emploie sans y penser.
Une fonction OCaml à plusieurs arguments est en réalité une fonction à un argument qui rend une fonction. C'est la curryfication, et elle se lit dans le type :
let ajoute x y = x + y (* val ajoute : int -> int -> int *)
let incr = ajoute 1 (* val incr : int -> int — application PARTIELLE *)
Une fonction qui prend ou rend une fonction est dite d'ordre supérieur. List.map en est une : son type ('a -> 'b) -> 'a list -> 'b list annonce qu'elle attend une fonction en premier argument.
let double = fun x -> 2 * x (* fonction anonyme *)
List.map (fun x y -> x + y) [1; 2] (* deux arguments, sans nom *)
let rec pair n = n = 0 || impair (n - 1) (* let rec … and … : *)
and impair n = n <> 0 && pair (n - 1) (* les deux se voient l'une l'autre *)
String.length "chien" (* 5 *)
"chi" ^ "en" (* concaténation *)
"chien".[0] (* le caractère 'c' *)
Les chaînes sont immuables : on ne peut pas écrire dans s.[i]. Et char porte un ordre total, donc les comparaisons < et > y ont un sens.
String.length "été" vaut 5 et non : en utf-8, chaque « é » occupe deux octets. Et "été".[1] rend le second octet du premier « é », qui n'est pas un caractère affichable. Le programme ne demande rien sur l'utf-8 ; il faut simplement savoir que String.length ne compte pas ce qu'on croit dès qu'on sort de l'ascii.
print_int 42; print_string "\n"; print_float 3.14
let n = read_int () (* aussi read_float, read_line *)
if c then begin (* begin … end groupe plusieurs expressions *)
print_string "oui"; (* là où la syntaxe n'accepterait qu'une seule *)
incr compteur
end
begin ... end est un synonyme exact de la parenthèse ; on l'emploie quand le bloc tient sur plusieurs lignes, où il se lit mieux.
2.6 Programmer impérativement en OCaml
Le programme le demande : OCaml n'est pas cantonné au style fonctionnel.
let compteur = ref 0 (* une CASE modifiable, de type int ref *)
compteur := !compteur + 1 (* := écrit, ! lit *)
let somme t =
let s = ref 0 in
for i = 0 to Array.length t - 1 do
s := !s + t.(i)
done;
!s
let attendre c = (* pas de « rec » : rien ne s'appelle soi-même *)
while !c > 0 do c := !c - 1 done
Le point-virgule sépare deux expressions : il évalue la première, jette sa valeur, et rend la seconde. Le type unit, dont l'unique valeur s'écrit (), est celui des expressions qu'on évalue pour leur effet et non pour leur valeur.
Bonne pratique (Les références « doivent être utilisées à bon escient »)
C'est la formule de l'annexe, et elle mérite d'être prise au mot. Une accumulation dans une ref au sein d'une boucle est souvent la traduction littérale d'un programme C, là où un parcours récursif ou un Array.fold dirait la même chose plus clairement. On garde les références pour ce qu'elles font le mieux : compter, mémoriser un état qui change vraiment.
let division a b =
if b = 0 then raise Division_by_zero else a / b
let sur a b =
try Some (division a b) with Division_by_zero -> None
let jamais () = failwith "cas impossible" (* pour l'irrattrapable *)
Le programme met en garde, et c'est une remarque de fond : « on veille à ne pas laisser penser que les exceptions servent uniquement à gérer des erreurs ». Une exception est aussi une sortie anticipée légitime — trouver un élément et quitter la boucle sur-le-champ, par exemple.
2.7 Le même algorithme, deux fois
/* Renvoie le plus grand des n premiers termes de t. Précondition : n >= 1. */
int maximum(const int t[], int n) {
assert(n >= 1);
int m = t[0];
for (int i = 1; i < n; i = i + 1) {
if (t[i] > m) { m = t[i]; }
}
return m;
}
(* Renvoie le plus grand élément de t. Précondition : t est non vide. *)
let maximum t =
assert (Array.length t >= 1);
let m = ref t.(0) in
for i = 1 to Array.length t - 1 do
if t.(i) > !m then m := t.(i)
done;
!m
(* La même chose sur une liste, sans aucune case modifiable. *)
let rec maximum_liste = function
| [] -> failwith "liste vide"
| [x] -> x
| x :: reste -> max x (maximum_liste reste)
Les deux premières versions sont le même algorithme : un accumulateur, un parcours. La troisième est un autre algorithme, récursif, que la liste appelle naturellement. Savoir laquelle écrire — et pourquoi — est l'objet de tout le reste de ce livre.
2.8 Éléments utilisables après rappel
- Enregistrements :
type point = {x : float; mutable y : float}, valeurs{x = 1.0; y = 2.0}, accèsp.x, écriture d'un champ mutablep.y <- 3.0. - Listes :
List.mem,exists,for_all,filter,map,iter. - Tableaux :
Array.make_matrix,init,mem,exists,for_all,map,iter. - Piles et files mutables : modules
StacketQueue—create,is_empty,push,pop, exceptionEmpty. Chapitre chap:sequentielles. - Tables de hachage : module
Hashtbl—create,add,remove,mem,find(qui lèveNot_found),find_opt,iter, sans liaison multiple ni randomisation. - Types mutuellement récursifs,
function,for ... downto,Sys.argv, compilation parocamlcouocamlopt. - Fichiers :
open_in,open_out,close_in,close_out,input_line,output_string. Chapitre chap:memoire. - Fils d'exécution :
Thread.create,Thread.join; exclusion mutuelle :Mutex.create,Mutex.lock,Mutex.unlock. Chapitre chap:concurrence.
2.9 Ce qu'il faut retenir
| C s'impose quand… | OCaml s'impose quand… |
|---|---|
| la structure est un tableau que l'on parcourt et modifie | la structure est récursive : arbre, formule, expression |
| la mémoire et son coût sont le sujet | le raisonnement par cas est le sujet |
| l'on veut voir ce que fait la machine | l'on veut que le compilateur vérifie les cas |
Aucun des deux n'est « le bon ». Un même chapitre de ce livre les emploiera souvent tous les deux : le programme le demande, et surtout, un algorithme qui ne survit pas au changement de langage n'était pas un algorithme — c'était une astuce.