Dans le cadre de mon travail, j’ai dû réaliser une librairie OCaml. L’outil sur lequel je travaille avait déjà un système de build présent et automatisé. Cependant, en faire une librairie fut un travail laborieux que je n’avais pas imaginé au départ malgré mon expérience avec ce langage. Si j’ai rencontré des difficultés à créer une librairie, c’est en partie lié au fait qu’il manque de la documentation sur le sujet mais aussi les messages d’erreurs d’OCaml ne sont pas toujours très explicites comme on va le voir. Ce billet est le premier d’une suite de billets qui vise à expliquer la compilation en OCaml ainsi que les outils qui existent pour automatiser ce processus. L’objectif final étant de présenter le processus de compilation et d’installation d’une librairie OCaml avec les outils Ocamlbuild, Ocamlfind, Oasis et Opam.
Dans ce premier billet, on va s’intéresser aux commandes ocamlc
et ocamlopt
qui sont les outils de base qui permettent de compiler n’importe quel projet OCaml.
Ce billet demande de connaître un minimum le langage OCaml mais aussi d’avoir quelques bases sur le processus de compilation.
Compiler un programme OCaml
Hello world
OCaml est un langage compilé. Il propose deux modes de compilation :
- On peut compiler du code OCaml vers un bytecode qui sera ensuite interprété
- On peut compiler du code OCaml vers du code machine (un langage d’assemblage)
Pour ce que l’on souhaite faire, il n’y a pas vraiment de différence. Cependant, il est intéressant de savoir que ces deux modes de compilation existent, car les outils que l’on va utiliser et les extensions des fichiers diffèrent en fonction du mode voulu.
Prenons le programme OCaml suivant (que j’enregistre dans un fichier hello.ml
) :
1 2 3 | let hello = "hello world!\n" let _ = print_string hello |
On peut le compiler vers du bytecode grâce à la commande ocamlc
:
1 | ocamlc hello.ml -o hello |
que l’on peut ensuite exécuter gràce ocamlrun
1 | ocamlrun hello |
ou bien que l’on peut compiler vers du code natif grâce à la commande ocamlopt
:
1 | ocamlopt hello.ml -o hello |
que l’on peut ensuite exécuter en faisant :
1 | ./hello |
Vous avez pu remarquer que les programme générés étaient directement exécutables. Les outils ont aussi fait le linking pour nous. On peut leur demander de ne pas faire cette dernière étape en utilisant l’option -c
:
1 | ocamlopt -c hello.ml -o hello |
Ceci revient seulement à compiler le fichier et il ne peut pas être exécuté directement par la machine.
La compilation produit deux fichiers hello.cmx
et hello.cmi
. Si on avait utilisé ocamlc
à la place du hello.cmx
on aurait eu un fichier hello.cmo
. La convention de nommage veut qu’en général les fichiers contenant du code natif aient la lettre x
dans l’extension. Pour en savoir plus, vous pouvez aller voir ici. Dans le cadre de ce billet, compiler vers du bytecode ou vers du code machine revient au même, j’ai fait le choix arbitraire d’utiliser par la suite seulement ocamlc
.
Pour comprendre pourquoi la compilation génère deux fichiers, il faut comprendre que pour OCaml, chaque fichier dont l’extension est .ml
déclare un nouveau module. Un module est définit par une implémentation que l’on retrouve dans un fichier .ml
et par une interface dans un fichier .mli
qui doit porter le même nom. Ainsi, pour définir un module foo
, on crée deux fichiers foo.ml
et foo.mli
. Cependant, il arrive assez fréquement comme pour notre exemple ci-dessus que l’interface puisse être générée directement à partir du fichier .ml
et donc on laisse le soin au compilateur de générer automatiquement ce fichier à notre place. Les fichiers .ml/.mli
jouent un rôle analogue aux fichiers .c/.h
que l’on peut trouver pour le C.
Ainsi, pour chaque module OCaml, le compilateur gènère deux fichiers :
- un fichier
.cmo
qui contient le code du module compilé - un fichier
.cmi
qui contient le code de l’interface compilée.
Dans le cas où l’utilisateur crée un fichier foo.mli, il faut toujours le préciser à ocamlc avant de compiler foo.ml :
1 | ocamlc foo.mli foo.ml |
Compiler plusieurs fichiers
Notre exemple contenait qu’un seul fichier. Mais en général, on va chercher à compiler des projets découpés en plusieurs fichiers. Dans ce cas, il faudra donner dans l’ordre les fichiers à compiler. Une restriction d’OCaml fait qu’on ne peut pas définir un module A qui dépend d’un module B et vice-versa. Cela implique que le graphe généré par les dépendances des fichiers ne doit pas contenir de cycle.
Supposons que l’on a défini un module A :
1 | let a = "je suis dans le module A" |
et un module B :
1 2 3 4 5 | let b = " je suis dans le module B" let _ = print_endline A.a; print_endline b |
on peut compiler ces modules en faisant :
1 | ocamlc A.ml B.ml |
car le module B dépend du module A. Si on cherche à compiler les fichiers dans l’autre sens ou bien seulement le module B on obtient l’erreur
1 2 | File "B.ml", line 4, characters 16-19: Error: Unbound module A |
ce qui est logique car il ne connait pas l’existence du module A 1. Imaginons maintenant qu’une personne a écrit le module A avant nous et qu’on a seulement accès à sa version compilée ce que l’on obtient avec la commande :
1 | ocamlc -c A.ml |
Si on lance
1 | ocamlc B.ml |
on obtient un message d’erreur un poil différent de celui qu’on a eu précédemment :
1 2 | File "B.ml", line 1: Error: Required module `A' is unavailable |
Par contre si on lance la commande
1 | ocamlc -c B.ml |
on n’a plus de soucis. Mais que s’est-il passé ? L’explication réside dans le fait qu’ocamlc
fait à la fois le travail du compilateur et du linker, et dans ces messages d’erreurs il ne nous dit pas vraiment s’il a échoué à la compilation ou bien lors du linking.
Lorsque le compilateur rencontre le symbole A.a
dans le module B, ce dernier va chercher le fichier A.cmi
et vérifier si ce dernier déclare bien un symbole a
. Si c’est le cas il est content et il va pouvoir continuer son travail. Le point important est qu’il n’a pas besoin de connaître l’implémentation de A.a
, mais il a juste besoin de savoir qu’il est défini. Il peut donc compiler le fichier. Par contre, ce fichier compilé a un trou et donc on ne peut pas l’exécuter directement. Ce sera le travail du linker de compléter ce trou en fournissant une implémentation du symbole A.a
, implémentation qui se trouve dans le fichier compilé A.cmo
.
C’est ce qui nous est dit dans le second message d’erreur que l’on peut interpréter par : le module B a besoin du module A, mais je n’ai pas trouvé d’implémentation de ce module.
Pour corriger cette erreur, il faut donc lui fournir le module compilé (fichier .cmo
) en faisant
1 | ocamlc A.cmo B.ml |
En résumé :
- Le compilateur se sert des fichiers
.cmi
pour gérer les dépendances. Lorsqu’il repère une dépendance vers un module Foo, il va chercher implicitement le fichierfoo.cmi
. S’il ne le trouve pas il échoue avec une erreur du style Unbound module Foo - Le linker se sert des fichiers
.cmo
pour générer l´exécutable. Il faut préciser explicitement et dans le bon ordre les fichiers.cmo
.
J’ai longtemps parcouru le chemin…
Ce qu’on a vu plus haut fonctionne plutôt bien si tous les fichiers se situent dans le même répertoire, mais bien souvent ce n’est pas le cas. Que se passe-t-il si par exemple le module A se situe dans un répertoire différent ? Pour la suite de l’exemple on va supposer qu’on a l’arborescence suivante :
1 2 3 4 | . .. A/A.ml B.ml |
Contrairement à l’exemple précédent, cette fois la commande
1 | ocamlc -c B.ml |
nous renvoie qu’il n’a pas réussi a trouvé une interface pour le module A. Il faut donc préciser à ocamlc
qu’il doit aller regarder dans le dossier A
. On peut le faire en utilisant l’option -I
:
1 | ocamlc -c -I A B.ml |
De facon générale Pour chaque répertoire, il faut utiliser l’option -I
:
1 | ocamlc -c -I A1 -I A2 ... B.ml |
sinon, il est possible d’utiliser l’option -Is
en séparant les répertoires par une virgule :
1 | ocamlc -c -Is A1,A2,A3 B.ml |
Il existe toutefois une subtilité ici. Supposons qu’on souhaite compiler et linker en même temps les modules A et B comme au tout début. La commande :
1 | ocamlc -I A A.ml B.ml |
ne va pas fonctionner car il ne trouve pas le fichier A.ml. Essayons alors la commande
1 | ocamlc A/A.ml B.ml |
où on indique explicitement le chemin du fichier A.ml. On se retrouve alors encore avec une erreur comme quoi il ne trouve pas le module A. C’est normal car il a besoin du fichier A.cmi qui n’est pas dans le répertoire courant. Il faut donc lui indiquer où le trouver en utilisant l’option -I :
1 | ocamlc -I A A/A.ml B.ml |
En résumé :
- les fichiers
.ml{,i}
doivent être référencés par leur chemin relatif/absolu - Il faut préciser avec l’option -I les répertoires qui peuvent contenir tous les fichiers objets (.cmi, .cmo) qui seront éventuellement compilés et/ou qui seront utiles pour la compilation et le linking.
Graphics, Unix, Str…
Si vous utilisez la librairie standard d’OCaml, il vous est certainement arrivé d’utiliser des fonctions du module Hashtbl
par exemple :
1 2 3 4 | let _ = let h = Hashtbl.create 81 in Hashtbl.add h 5 10; print_int (Hashtbl.length h) |
Vous compilez votre programme :
1 | ocamlc test.ml -o |
et tout se passe comme prévu. Mais si on cherche à utiliser d’autres modules, comme le module Unix
les choses se compliquent :
1 | let _ = print_int (Unix.getpid ()) |
et on obtient l’erreur :
1 2 | File "test2.ml", line 1: Error: Required module `Unix' is unavailable |
donc cela veut dire qu’il a bien trouvé le fichier unix.cmi mais il n’a pas trouvé d’implémentation. C’est d’autant plus problématique que la documentation n’indique rien à ce sujet2.
Pour corriger ce problème il faut donc indiquer explicitement au linker qu’il doit linker le module Unix. Il se trouve que ce module est en fait une librairie, et donc que son extension n’est pas .cmo
.cma
mais on y reviendra plus tard. De plus, comme il se trouve dans le même répertoire que le .cmi
qui est inclu par défaut, on peut donc juste écrire :
1 | ocamlc unix.cma test2.ml |
À l’issu de ce premier billet, je pense qu’il est maintenant à peu près clair comment la compilation fonctionne en OCaml. Cependant, ce qu’on a vu jusqu’ici n’est pas forcément très pratique en général car on aimerait que le processus de compilation soit un peu plus automatique. En particulier on verra dans le prochain billet comment :
- ne pas avoir à écrire toutes ces commandes à la main
- gérer automatiquement les dépendances internes de notre projet entre les différents fichiers
- gérer automatiquement les dépendances externes du projet
Même si un Makefile peut adresser le premier problème, ce n’est pas le cas pour les deux suivants. Pour cela, on utilisera Ocamlbuild et Ocamlfind qui permettent respectivement de régler les second et troisième problèmes.