Découvrir OCaml : expressions, valeurs et types
Cours complet · OCaml (option informatique), chapitre 1 · prépas MPSI et MP, option informatique
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
<i class="fa-solid fa-compass mr-2" style="color:#9A563B"></i>1.1 Introduction et motivation
Le tronc commun vous a appris à programmer en Python, un langage impératif : on y donne des ordres (« affecte ceci », « répète cela »), et l'on modifie pas à pas l'état de la machine. L'option informatique vous fait découvrir un second langage, OCaml, qui repose sur une idée différente et complémentaire : programmer, c'est avant tout décrire des valeurs par des expressions, et laisser la machine les évaluer. On ne dit plus tant comment faire que ce que l'on veut calculer.
Deux traits d'OCaml structureront tout ce cours. D'abord, OCaml est fortement et statiquement typé : chaque expression possède un type, vérifié par le compilateur avant toute exécution. Une bonne part des erreurs qui, en Python, ne se révèlent qu'au pire moment (sur le troisième jeu de données, le jour du concours) sont ici interceptées dès la compilation. Ensuite, ce typage est inféré : vous écrivez rarement les types vous-même, c'est le compilateur qui les reconstitue. Le compilateur cesse d'être un simple exécuteur pour devenir un relecteur exigeant et bienveillant.
Ce premier chapitre installe le socle : la boucle d'interaction et la notion d'expression, les types de base et leurs opérateurs, la liaison de noms par let et la portée lexicale, la conditionnelle vue comme une expression, les fonctions (curryfication, ordre supérieur), l'inférence de types et l'idée naïve du polymorphisme, les n-uplets, et enfin les définitions récursives. Tout ce qui suit sera supposé acquis dans les chapitres ultérieurs.
1.2 Le toplevel : tout est expression
OCaml s'utilise de deux façons complémentaires, comme Python : en mode interactif dans une boucle d'interaction (le toplevel), et en mode compilé sur un fichier .ml. Le toplevel est l'outil d'exploration idéal : on y saisit une phrase terminée par un double point-virgule ;;, et il répond en affichant le type et la valeur du résultat.
# 2 + 3;;
- : int = 5
# 7 / 2;;
- : int = 3
Une expression est un fragment de code qui se calcule (on dit s'évalue) pour produire une valeur. À toute expression bien formée, le compilateur associe un type, qui décrit la nature de la valeur produite. En OCaml, presque tout est expression : 2 + 3, x > 0, et même la conditionnelle if ... then ... else ... sont des expressions ayant chacune une valeur et un type.
La réponse du toplevel se lit toujours sur le même patron : - : <type> = <valeur> pour une expression anonyme, et val <nom> : <type> = <valeur> lorsqu'on nomme le résultat (voir la section suivante).
Le ;; est une ponctuation du toplevel, qui marque la fin d'une phrase à évaluer. Dans un fichier compilé, il est le plus souvent inutile : les définitions s'y enchaînent sans lui. Nous l'écrirons dans les sessions interactives, et l'omettrons dans les fragments de programme.
1.3 Les types de base et leurs opérateurs
1.3.1 Entiers et flottants : deux mondes séparés
OCaml distingue strictement les entiers (int) des nombres à virgule flottante (float), et — c'est sa grande surprise pour qui vient de Python — leurs opérateurs sont distincts. Sur les entiers : +, -, , / (division entière) et mod (reste, à n'employer pour l'instant que sur des grandeurs positives). Sur les flottants, les mêmes opérateurs suivis d'un point : +., -., ., /..
# 17 mod 5;;
- : int = 2
# 3.0 *. 2.5;;
- : float = 7.5
Il n'y a en OCaml aucune conversion automatique entre int et float. L'expression 1 + 2.0 n'est pas « égale à 3.0 » : c'est une erreur de type, refusée à la compilation.
# 1 + 2.0;;
Error: This expression has type float but an expression
was expected of type int
De même, 2.0 +. 3 est refusée. On passe explicitement d'un monde à l'autre par des fonctions de conversion (vues plus loin) ; pour l'instant, on prendra l'habitude de ne pas mélanger.
Les entiers OCaml sont bornés et sujets aux dépassements de capacité : au-delà d'une certaine taille, un calcul « repasse » par les valeurs négatives au lieu de croître indéfiniment. C'est un comportement normal d'un type machine, qu'il faudra garder en tête dès qu'on manipule de grands nombres (factorielles, puissances).
Dans l'atelier du site, le code est exécuté dans le navigateur, où le type int est codé sur 32 bits (et non sur la taille native de votre machine). Les dépassements de capacité y surviennent donc plus tôt qu'avec un OCaml installé : un même calcul peut déborder dans l'atelier sans déborder sur la machine du concours. Ce n'est pas une erreur, mais une limite à connaître.
1.3.2 Booléens et évaluation paresseuse
Le type bool a pour seules valeurs true et false. On dispose de la négation not, de la conjonction && et de la disjonction ||. Ces deux derniers opérateurs sont paresseux : ils n'évaluent leur second argument que si c'est nécessaire.
# let n = 0;;
val n : int = 0
# n <> 0 && 10 / n > 2;;
- : bool = false
La conjonction && voit son membre gauche n <> 0 valoir false : elle conclut false sans même évaluer 10 / n, ce qui évite la division par zéro. On exploite couramment cette paresse pour écrire des tests sûrs : « tester que le dénominateur est non nul avant de diviser ».
1.3.3 Les comparaisons
Les opérateurs = (égalité), <> (différence), <, >, <=, >= renvoient un bool. Ils s'appliquent à beaucoup de types — entiers, flottants, booléens, et d'autres rencontrés plus tard — ce qui est notre premier indice de polymorphisme (voir § sec:inference).
L'égalité s'écrit avec un seul signe = (et non == comme en Python ou en C), et la différence <> (et non !=). En OCaml, = compare les valeurs.
1.4 Lier des noms : let et la portée lexicale
1.4.1 Définitions globales et locales
On donne un nom à une valeur avec let. Au toplevel, le compilateur confirme la liaison sous la forme val nom : type = valeur.
# let pi = 3.14159;;
val pi : float = 3.14159
# let aire_disque = pi *. 2.0 *. 2.0;;
val aire_disque : float = 12.56636
Pour une liaison locale, valable seulement le temps d'une expression, on emploie let nom = e in e' : le nom est défini dans e' et nulle part ailleurs.
let discriminant a b c =
let delta = b *. b -. 4.0 *. a *. c in
delta
L'expression let v = e in e' évalue e, lie sa valeur au nom v, puis évalue e' dans ce contexte enrichi ; la valeur de l'ensemble est celle de e'. En dehors de e', le nom v n'existe pas.
1.4.2 Portée lexicale
OCaml suit la portée lexicale : quand une définition utilise une variable globale, c'est la valeur de cette variable au moment de la définition qui est figée — pas celle qu'elle aurait plus tard. Redéfinir un nom (masquage) crée une nouvelle liaison sans modifier les définitions déjà construites.
# let x = 10;;
val x : int = 10
# let lire_x () = x;;
val lire_x : unit -> int = <fun>
# let x = 20;;
val x : int = 20
# lire_x ();;
- : int = 10
La fonction lire_x a été définie alors que x valait 10 : elle renverra toujours 10, même après que le nom x a été relié à 20. Le second let x ne « modifie » rien : il introduit un nouveau x qui masque l'ancien pour les définitions suivantes.
1.5 La conditionnelle est une expression
L'expression if c then eV else eF évalue d'abord la condition c (de type bool), puis l'une des deux branches selon le résultat. Comme c'est une expression, elle possède une valeur — et les deux branches doivent donc avoir le même type.
let valeur_absolue x =
if x >= 0 then x else -x
Les deux branches d'un if ... then ... else ... ont le même type, puisque l'expression a une valeur quel que soit le chemin pris. Ainsi if x > 0 then 1 else "non" est refusé (int contre string). Ce n'est pas une contrainte arbitraire : c'est la traduction directe du fait qu'une expression a un type bien défini.
1.6 Les fonctions : curryfication et ordre supérieur
1.6.1 Définir et appliquer
On définit une fonction par let f x = ..., et on l'applique en juxtaposant la fonction et son argument, sans parenthèses obligatoires : f 3. Les arguments sont passés par valeur : ils sont évalués avant l'appel.
# let successeur x = x + 1;;
val successeur : int -> int = <fun>
# successeur 41;;
- : int = 42
Le type int -> int se lit « fonction qui, à un int, associe un int ». On peut aussi écrire une fonction anonyme avec fun : fun x -> x + 1 dénote la même fonction que successeur ci-dessus, mais sans lui donner de nom.
1.6.2 Curryfication
En OCaml, une fonction à plusieurs arguments est en réalité une cascade de fonctions à un argument : c'est la curryfication. La définition let ajoute x y = x + y a pour type int -> int -> int, à lire de droite à gauche : « fonction qui à un int associe une fonction int -> int ».
# let ajoute x y = x + y;;
val ajoute : int -> int -> int = <fun>
# ajoute 3 4;;
- : int = 7
# let ajoute10 = ajoute 10;;
val ajoute10 : int -> int = <fun>
# ajoute10 5;;
- : int = 15
Appliquer une fonction curryfiée à moins d'arguments qu'elle n'en attend produit une nouvelle fonction, qui attend les arguments restants : c'est l'application partielle. Ainsi ajoute 10 est la fonction « ajouter 10 », de type int -> int.
La flèche -> associe à droite (int -> int -> int signifie int -> (int -> int)) tandis que l'application associe à gauche (ajoute 3 4 signifie (ajoute 3) 4). Ces deux conventions se complètent exactement et rendent la curryfication transparente à l'usage.
1.6.3 Fonctions d'ordre supérieur
Une fonction peut prendre une autre fonction en argument, ou en renvoyer une : on parle de fonction d'ordre supérieur. C'est un trait central du style fonctionnel.
# let applique_deux_fois f x = f (f x);;
val applique_deux_fois : ('a -> 'a) -> 'a -> 'a = <fun>
# applique_deux_fois successeur 5;;
- : int = 7
1.7 Typage statique et inférence ; polymorphisme
Vous n'avez écrit aucun type dans les exemples précédents, et pourtant le toplevel en affiche partout : c'est l'inférence. À partir des opérations employées, le compilateur reconstitue le type le plus général compatible. Dans let successeur x = x + 1, l'usage de + force x : int, d'où successeur : int -> int.
Lorsqu'une fonction n'impose aucune contrainte sur le type de son argument, l'inférence produit un type polymorphe, noté avec une variable de type 'a (« alpha »).
# let identite x = x;;
val identite : 'a -> 'a = <fun>
Un type contenant une variable comme 'a signifie « pour n'importe quel type ». La fonction identite fonctionne aussi bien sur un int que sur un bool ou un string : un seul code, valable pour tous les types. C'est ce qui rendait les comparaisons (§ 2.3) utilisables sur tant de types.
Le typage est vérifié statiquement, c'est-à-dire à la compilation, avant toute exécution. Un programme mal typé ne s'exécute pas du tout. C'est une garantie forte : une large classe d'erreurs (mélanger un int et un string, appeler une fonction sur le mauvais type d'argument) est éliminée avant de faire tourner le code.
1.8 Composer des valeurs : les n-uplets
Pour grouper plusieurs valeurs en une seule, on forme un n-uplet en les séparant par des virgules. Le type d'un couple (3, 4) est int int ; celui de (3, "trois", true) est int string * bool. Les composantes peuvent être de types différents.
# let point = (3, 4);;
val point : int * int = (3, 4)
Pour récupérer les composantes, nul besoin d'un filtrage élaboré : un let déstructurant suffit.
let (a, b) = point (* a vaut 3, b vaut 4 *)
let norme_carree (x, y) = (* argument : un couple *)
x * x + y * y
Une fonction ne renvoie qu'une valeur — mais cette valeur peut être un n-uplet. C'est la façon idiomatique de renvoyer plusieurs résultats à la fois, ici le quotient et le reste d'une division euclidienne :
# let division a b = (a / b, a mod b);;
val division : int -> int -> int * int = <fun>
# division 17 5;;
- : int * int = (3, 2)
1.9 Définitions récursives : let rec
Une fonction qui s'appelle elle-même doit être introduite par let rec (et non simplement let), afin que son propre nom soit visible dans son corps.
let rec factorielle n =
if n <= 1 then 1
else n * factorielle (n - 1)
Toute définition récursive doit comporter au moins un cas de base (ici n <= 1, qui renvoie 1 sans rappel) et des appels récursifs qui s'en rapprochent (ici n - 1). Sans cas de base atteignable, le calcul ne s'arrête jamais.
Deux fonctions peuvent s'appeler mutuellement : on les lie ensemble avec let rec ... and ....
let rec est_pair n =
if n = 0 then true else est_impair (n - 1)
and est_impair n =
if n = 0 then false else est_pair (n - 1)
Ici est_pair appelle est_impair et réciproquement ; le and permet à chacune de « voir » l'autre.
<i class="fa-solid fa-dumbbell mr-2" style="color:#2E7559"></i>1.10 Exercices résolus
Niveau (application directe du cours)
Sans machine, donner le type et la valeur affichés par le toplevel pour chacune des phrases suivantes.
17 / 5 ;; 17 mod 5 ;; 2.0 *. 3.0 ;; 3 <= 3 ;; not (1 = 2) ;;
Démonstration
- : int = 3 (* division entière : la partie entière de 17/5 *)
- : int = 2 (* reste de la division *)
- : float = 6. (* opérateurs flottants : type float *)
- : bool = true (* comparaison : 3 <= 3 *)
- : bool = true (* 1 = 2 vaut false, sa négation true *)
Le piège classique est la première ligne : / est la division entière, pas une division flottante.
Pour chaque phrase, dire si elle est acceptée ; sinon, expliquer l'erreur et la corriger.
(* a *) 3 + 4.0
(* b *) if 1 < 2 then 0 else "zéro"
(* c *) 5.0 /. 2
Démonstration
(a) Refusée : + attend deux int, or 4.0 est un float. Correction selon l'intention : 3 + 4 (entiers) ou 3.0 +. 4.0 (flottants).
(b) Refusée : les deux branches doivent avoir le même type, or 0 est int et "zéro" est string. Correction : choisir un type commun, p. ex. if 1 < 2 then "0" else "zéro".
(c) Refusée : /. attend deux float, or 2 est un int. Correction : 5.0 /. 2.0.
Définir une fonction est_positif qui teste si un entier est strictement positif, et une fonction milieu qui renvoie la moyenne de deux flottants. Donner le type inféré de chacune.
Démonstration
let est_positif n = n > 0 (* val est_positif : int -> bool *)
let milieu x y = (x +. y) /. 2.0 (* val milieu : float -> float -> float *)
Pour est_positif, l'usage de > avec 0 (un int) force n : int, et le résultat d'une comparaison est un bool. Pour milieu, les opérateurs flottants forcent les deux arguments à float.
Niveau (raisonnement intermédiaire)
Que renvoie la dernière phrase ? Justifier.
let a = 5
let f y = a + y
let a = 100
let resultat = f 1
Démonstration
resultat vaut 6. La fonction f a été définie alors que a valait 5 : par portée lexicale, elle capture cette valeur. La redéfinition let a = 100 crée un nouveau a pour la suite, mais ne touche pas f, qui calcule donc 5 + 1 = 6.
On définit let entre bas haut x = bas <= x && x <= haut. Donner le type de entre, puis celui de entre 0 10, et expliquer ce que dénote cette dernière.
Démonstration
entre a pour type int -> int -> int -> bool. Par application partielle, entre 0 10 a pour type int -> bool : c'est la fonction « ce nombre est-il dans l'intervalle ? ». On pourrait la nommer : let dans_dix = entre 0 10, puis dans_dix 7 renverrait true.
Écrire une fonction compose qui, à deux fonctions f et g, associe la fonction . Donner son type inféré et l'illustrer.
Démonstration
# let compose f g x = f (g x);;
val compose : ('a -> 'b) -> ('c -> 'a) -> 'c -> 'b = <fun>
# let ajoute1_puis_double = compose (fun n -> 2 * n) (fun n -> n + 1);;
val ajoute1_puis_double : int -> int = <fun>
# ajoute1_puis_double 5;;
- : int = 12
Le type est polymorphe : g produit un 'a que f consomme, d'où l'enchaînement 'c -> 'a -> 'b. La composition n'impose aucun type concret : elle vaut pour toutes les fonctions compatibles.
Écrire echange qui échange les deux composantes d'un couple, puis normalise qui, à un couple d'entiers avec , associe le couple . Donner les types.
Démonstration
let echange (x, y) = (y, x) (* 'a * 'b -> 'b * 'a *)
let normalise (a, b) = (a / b, a mod b) (* int * int -> int * int *)
echange est polymorphe (elle ne regarde pas le contenu, seulement la structure de couple) ; normalise est forcée à int par les opérateurs / et mod. La précondition relève du contrat : la fonction ne promet rien si on la viole.
Niveau (approfondissement)
Écrire une fonction récursive pgcd calculant le PGCD de deux entiers naturels, par l'algorithme d'Euclide ( et ). La tester mentalement sur pgcd 30 12.
Démonstration
let rec pgcd a b =
if b = 0 then a
else pgcd b (a mod b)
Déroulé de pgcd 30 12 : pgcd 12 6 (car ) pgcd 6 0 (car ) renvoie 6. Le cas de base b = 0 est atteint car le reste a mod b décroît strictement et reste positif : la suite des seconds arguments est une suite d'entiers naturels strictement décroissante, donc finie.
On définit deux suites par , , et pour : , . Écrire u et v (de type int -> int). Que reconnaît-on ?
Démonstration
let rec u n =
if n = 0 then 1 else u (n - 1) + v (n - 1)
and v n =
if n = 0 then 1 else u (n - 1)
Comme , on a : on reconnaît la suite de Fibonacci (décalée). Le let rec ... and ... est ici indispensable, car u et v se référencent mutuellement. (Cette définition recalcule énormément ; on apprendra plus tard à l'accélérer.)
Écrire une fonction récursive puissance x n calculant pour entier (sans l'opérateur **, réservé aux flottants). Expliquer ce qu'on observe pour de grandes valeurs, et le lien avec l'atelier en ligne.
Démonstration
let rec puissance x n =
if n = 0 then 1
else x * puissance x (n - 1)
Le cas de base renvoie 1 (convention ) ; chaque appel décrémente n, donc le calcul s'arrête. Pour de grandes valeurs (p. ex. puissance 2 64), le résultat déborde la capacité du type int et l'on obtient une valeur erronée (souvent négative) : c'est le dépassement de capacité. Dans l'atelier du navigateur, où int est sur 32 bits, ce débordement survient bien plus tôt (dès puissance 2 31 environ) que sur un OCaml natif 63 bits.
- Tout est expression : chaque fragment a une valeur et un type ; le toplevel répond
- : type = valeur(ouval nom : ...). - int et float sont disjoints : opérateurs
+ - / modd'un côté,+. -. . /.de l'autre ; aucune conversion implicite. Les entiers débordent. - Booléens :
&&et||sont paresseux ; égalité=, différence<>. let(global),let ... in(local) ; portée lexicale : une définition fige la valeur des globales au moment où elle est écrite ; redéfinir masque, ne modifie pas.if c then a else best une expression : les deux branches ont le même type.- Fonctions curryfiées :
f x y=(f x) y; l'application partielle crée de nouvelles fonctions ;fun x -> epour l'anonyme ; ordre supérieur et passage par valeur. - Typage statique inféré ; les types
'aexpriment le polymorphisme (« pour tout type »). - n-uplets
(a, b, c)de typeta tb tc; déstructuration parlet (a, b) = ...; idéal pour renvoyer plusieurs résultats. let rec(récursivité) etlet rec ... and ...(récursivité mutuelle) ; toujours un cas de base atteignable.
1.11 Exercices d'entraînement
Légende : application directe, raisonnement intermédiaire, approfondissement ; le symbole signale un classique incontournable. La numérotation prolonge celle des dix exercices résolus.
Thème A — Types, valeurs, opérateurs.
- [11.] Donner le type et la valeur de
10 - 3 2,(10 - 3) 2,10.0 /. 4.0,15 mod 4. - [12.] Pour chacune, dire si elle est bien typée :
2 *. 3,2.0 = 2.0,true && 1,not false. - [13.] Écrire
xor a b(ou exclusif de deux booléens) sans utiliser d'opérateur autre que&&,||,not, puis avec<>.
Thème B — Liaisons et portée.
- [14.] À l'aide d'un
let ... in, écrireaire_triangle base hauteuren nommant le produit intermédiaire. - [15.] Prédire la valeur de
resultat: ```ocaml
let c = 2 let g x = c * x let c = 7 let resultat = g 5 + c ```
- [16.] Expliquer la différence entre
let x = 1 in let y = x + 1 in x + yet un programme qui définiraitxpuisyau niveau global.
Thème C — Fonctions, curryfication, ordre supérieur.
- [17.] Définir
double,tripleetcarresur les entiers ; donner leurs types. - [18.] On donne
let applique f x = f x. Quel est le type deapplique? Que vautapplique successeur 9? - [19.] Écrire
applique_n f n xqui applique fois la fonctionfàx(pour ), par récursion. En déduire une autre écriture de la fonction « ajouter » à partir desuccesseur.
Thème D — n-uplets et récursivité.
- [20.] Écrire
minmax a bqui renvoie le couple (plus petit, plus grand) de deux entiers. - [21.] Écrire
somme_chiffres nqui calcule la somme des chiffres d'un entier (par récursion, à l'aide demod 10et/ 10). - [22.] Écrire
syracuse nqui renvoie le nombre d'étapes pour atteindre à partir de (étape : si pair, sinon). Pourquoi ne sait-on pas prouver que cette fonction s'arrête toujours ?