Objective Caml/Introduction

Qu'est-ce qu'OCaml ?

modifier

OCaml est un langage de programmation, c'est-à-dire une manière de donner des instructions à l'ordinateur pour obtenir des résultats ou des effets.

Tout comme Java, C# ou Python, OCaml est un langage de haut niveau conçu pour écrire des applications et des bibliothèques de programmation évoluées, sans avoir à se préoccuper de questions de bas niveau, telles que la gestion de la mémoire, et conçu pour favoriser la réutilisation de code ou de composants. Tout comme ces langages, OCaml dispose de nombreuses bibliothèques spécialisées qui permettent de manipuler des interfaces graphiques, des images 3D, de services web, des sons et musiques, des objets mathématiques...

Par opposition à Java, C# ou Python, qui sont des langages impératifs, OCaml entre dans la catégorie des langages fonctionnels. Là où un programme impératif sera constitué d'une suite de modifications de la mémoire vive et de l'état de l'ordinateur, un programme fonctionnel sera constitué d'un ensemble de fonctions mathématiques, qui permettent de déduire un résultat à partir d'entrées. OCaml est aussi un langage multi-paradigme, c'est-à-dire que, lorsque la programmation fonctionnelle ne suffit pas, il reste possible d'utiliser la programmation impérative ou/et la programmation orientée-objets, c'est-à-dire à se ramener à une programmation qui ressemblera aux langages précédents.

Les concepteurs de Java ou C# affirment que ces langages sont statiquement et fortement typé, ce qui n'est que partiellement vrai. Par opposition, OCaml est effectivement statiquement fortement typé, ce qui signifie que le langage de programmation procède automatiquement à de nombreuses vérifications et rejette les programmes considérés comme erronés ou programmés avec insuffisamment de rigueur. De plus, le typage d'OCaml se fait par inférence, ce qui signifie que l'essentiel des vérifications se fait de manière transparente, sans avoir à donner d'informations supplémentaire à OCaml, contrairement à ce qui se passe en Java ou en C# dans lequel il faut préciser le type de chaque variable, de chaque argument, chaque méthode... Là où les langages dynamiquement typés tels que Python ne contrôlent la cohérence des opérations que durant l'exécution du programme, OCaml analyse celle-ci intégralement lors de la phase de compilation, ce qui impose une rigueur supplémentaire lors de l'écriture du programme mais améliore la fiabilité et simplifie considérablement la phase de tests. De plus comme la vérification de type s'effectue à la compilation et non durant l'exécution, cette dernière n'est pas ralentie par des tests permanents de cohérence.

Par opposition à Java, C# ou Python, OCaml essaye le plus possible d'être un langage déclaratif, c'est-à-dire un langage dans lequel on va décrire la solution à un problème, plutôt que de construire cette solution étape par étape. Pour ce faire, OCaml est extensible, ce qui signifie que, lorsqu'une application a besoin d'une opération complexe et répétitive, il est possible d'ajouter cette opération sous la forme d'une nouvelle primitive du langage lui-même -- ou même d'ajouter à l'intérieur d'OCaml un langage de programmation spécialisé dans la résolution de votre problème. Ainsi, certaines bibliothèques, au lieu d'ajouter de nouvelles fonctionnalités au langage, modifient ce dernier.

Par opposition à Java, C# ou Python, OCaml essaye le plus possible d'être un langage de haute performance. Ainsi, un programme OCaml démarre beaucoup plus vite, s'exécutera fréquemment plus vite et consommera fréquemment 4 fois moins de mémoire vive qu'un programme Java ou C#. Un programme OCaml utilisera généralement un peu plus de mémoire vive qu'un programme Python mais s'exécutera souvent 10 fois plus vite. Dans certains cas, heureusement assez rares, ce gain de performances se fait au détriment du confort de programmation.

Ah, encore une chose : par opposition à Java ou C#, et tout comme Python, OCaml est et reste un langage d'expérimentation, ce qui signifie qu'il gagne régulièrement de nouvelles fonctionnalités inédites. Il existe ainsi des versions d'OCaml spécialisées dans la programmation distribuée (JoCaml, Acute), dans la manipulation d'arbres XML (OCamlDuce), dans l'écriture de compilateurs (MetaOCaml), dans l'écriture de scripts shell (Cash), etc.

Avec un peu d'expérience d'OCaml, vous constaterez qu'OCaml est un langage dans lequel les programmes écrits sont beaucoup plus concis qu'en Java ou C#, grâce à de puissantes primitives d'abstraction, grâce à l'extensibilité du langage, grâce à l'inférence de types et, tout simplement, grâce à une syntaxe plus concise. Cette concision est souvent un avantage, même si elle rend le code plus dense et donc parfois plus difficile à lire.

Pourquoi apprendre OCaml ?

modifier

Cette questions a plusieurs réponses.

Commençons par l'aspect technique et pécuniaire : OCaml est gratuit, tous les outils dont vous pouvez avoir besoin pour programmer sont gratuits (sauf l'ordinateur), toutes les extensions d'OCaml qui vous serviront lors de vos développements sont gratuites et même ce livre est gratuit.

Et en tant que langage, que m'offre OCaml ?

Pour les mathématiciens, physiciens, statisticiens ou autres utilisateurs de programmes scientifiques, OCaml permet de développer des programmes rapides et fiables, d'une part parce que le code source du programme ressemblera à la description mathématique du problème et sera donc plus simple à vérifier et d'autre part parce que les analyses permises par OCaml éviteront de nombreuses erreurs. De plus, la programmation fonctionnelle de haut niveau simplifie la constitution des bibliothèques génériques et aisément réutilisables.

Les programmeurs Java, C#, C, C++ ou autres utilisateurs de langages ou librairies système ou orientés-objets découvriront avec OCaml une autre manière de voir le monde et d'écrire des programmes dépendant moins du mode d'exécution de la machine et plus des résultats voulus, de modifier le langage de programmation pour l'adapter au problème au fur et à mesure de la progression du projet, ou encore d'autres techniques de factorisation et de réutilisation de code. Même si vous ne deviez pas utiliser OCaml dans votre carrière, les concepts vous permettront d'analyser un grand nombre de problème sous des angles que vous n'aviez pas prévus jusqu'à présent.

Les programmeurs Python, Ruby, JavaScript découvriront un langage confortable, dans lesquels ils retrouveront un grand nombre d'idées connues, sous une forme plus rigoureuse et plus adaptée au développement de grands projets critiques. À l'inverse, les utilisateurs de langages industriels -- dont l'essentiel des concepts datent des années 70 -- découvriront des idées plus récentes en provenance du monde de la Recherche. Pour information un grand nombre de concepts considérés comme novateurs dans l'industrie sont tirés directement des langages fonctionnels : les exceptions, la vérification de débordement dans les tableaux, le ramasse-miettes, l'inférence de types, les types génériques, les mini-langages spécialisés, les énumérations sûres, les clôtures...

Mise en bouche

modifier

Avant d'entrer dans l'enseignement, commençons par une mise en bouche.

Note: Tout ce livre utilise OCaml Batteries Included, une distribution de OCaml qui étend aussi bien les bibliothèques standard que le langage de programmation lui-même. Cette bibliothèque est gratuite et disponible au téléchargement, mais il est important de souligner que le langage présenté ici n'est pas (encore) du OCaml standard, seulement l'une de ses variantes.

Bonjour, le monde

modifier

L'extrait suivant est un programme complet d'une ligne, qui a pour effet d'afficher le texte "Bonjour, le monde" :

print_endline ("Bonjour, le monde.")

On pourrait d'ailleurs simplifier légèrement le programme en supprimant les parenthèses, qui ne sont ici pas nécessaires, de manière à obtenir l'extrait suivant :

print_endline "Bonjour, le monde."

Dans ce qui précède, print_endline est une fonction, dont l'effet est d'afficher du texte et de passer à la ligne, et qui n'a pas de résultat. En Java, ce serait l'équivalent de

public class Main
{
  public static final void main(String args[])
  {
    System.out.println("Bonjour, le monde");
  }
}

Pour définir une valeur, il faut utiliser le mot clé let, mot anglais que l'on peut traduire par le mot soit précédant les définitions en mathématiques.

Pour définir la valeur de pi, il suffit d'écrire : soit pi = 3.1415926, c'est à dire :

let pi = 3.1415926

Cette ligne donne le nom pi à la valeur 3.1415926. Cette valeur ne changera plus au cours de l'exécution du programme. En Java, ce serait l'équivalent d'une déclaration :

public static final float PI = 3.1415926;

Identité

modifier

Les fonctions sont des valeurs comme les autres. Ainsi, pour définir la fonction identité, qui à tout x associe x lui-même, on peut écrire

let id = fun x -> x

ou encore

let id(x) = x

ou encore

let id x = x

Ces trois définitions sont équivalentes. La première précise explicitement que id est une fonction. La troisième pourra être comprise comme la définition d'une famille de valeurs   pour tout   -- dans la dernière version, nous avons tout simplement supprimé les parenthèses qui étaient inutiles. Nous verrons plus tard que cette fonction va faire l'objet d'un certain nombre de vérifications à chaque fois qu'elle est appliquée. Comme précisé plus haut, ces vérifications ont entièrement lieu durant la compilation.

On utilise alors id de la manière suivante

let y = id 5;;
let z = id "Il fait beau";;

En Java, pour obtenir la même fonctionnalité, le plus simple est d'abandonner toute vérification et d'écrire une classe contenant la méthode suivante :

public class Fonctions
{
  public static Object id(Object x)
  {
    return x;
  }
}

On utilise alors id de la manière suivante :

int    y = (Integer)Fonctions.id(5);
String z = (String)Fonctions.id("Il fait beau");

Si l'on cherche à conserver une vérification de types, il devient nécessaire de définir une classe plus complexe :

public class Id<T>
{
  public T appliquer(T x)
  {
    return x;
  }
}

On utilise alors id de la manière suivante :

int    y = new Id<Integer>().appliquer(5);
String z = new Id<String>().appliquer("Il fait beau");

Profitons de cet exemple pour remarquer que, en OCaml, le mot-clé return n'existe pas et n'est pas nécessaire. En effet, le corps d'une fonction est une expression et toute expression est évaluée. En d'autres termes, le résultat d'une fonction est donné par la dernière expression de cette fonction.

La fonction delta de Dirac est définie mathématiquement de l'ensemble des réels vers l'ensemble des réels par

  •  
  • pour tout autre x,  

Cette définition mathématique se traduit naturellement en OCaml par

let delta = function
  0.0 -> 1.0
  | _ -> 0.0

ou encore

let delta x = match x with
  0.0 -> 1.0
| _   -> 0.0

ou encore

let delta x = if x=0.0 then 1.0 else 0.0

Ces trois extraits se lisent "delta est la fonction qui à 0,0 associe 1,0 et qui à toute autre valeur associe 0,0". Retenez la structure des deux premiers extraits, qui reviendra fréquemment. Profitons-en pour préciser que le test d'égalité se note "=", qu'il fonctionne correctement, contrairement à celui de Java ou C# (dans lesquels il faut parfois utiliser "==" et parfois invoquer une méthode) ou Python (qui, par défaut, vérifie l'identité et non l'égalité).

Opérateurs logiques

modifier

OCaml définit bien entendu les opérateurs logiques habituels. Nous pourrions, si nécessaire, les redéfinir à la main. Ainsi, le ou exclusif logique peut s'écrire

let ou_exclusif a b = match (a,b) with
  (true, true) -> false
| (true, false) -> true
| (false, true) -> true
| (false, false) -> false

Ce qui peut se résumer

let ou_exclusif a b = match (a,b) with
  (true, false) | (false, true) -> true
| _ -> false

Dans ce qui précède, true et false sont les noms donnés en OCaml aux booléens vrai et faux. L'utilisation de match (a,b) permet de prendre des décisions en fonction de la structure du couple (a,b).

Bien entendu, la même fonctionnalité pourrait s'écrire, de manière moins lisible, à l'aide des opérateurs logiques habituels :

let ou_exclusif a b = (a && not b) || (b && not a)

En Java, les premiers extraits se traduiraient

public static boolean ou_exclusif(boolean a, boolean b) 
{
  if ((a==true && b == false) || (a==false && b== true))
    return true;
  else
    return false;
}

Le dernier extrait s'écrirait

public static boolean ou_exclusif(boolean a, boolean b)
{
  return (a && !b ) || (!a && b );
}

On pourrait bien entendu, en OCaml comme en Java, écrire tout ceci à l'aide d'une comparaison entre a et b, comme suit :

En Java :

public static boolean ou_exclusif(boolean a, boolean b)
{
  return (a != b);
}

En OCaml :

let ou_exclusif a b = a <> b

voire, de manière plus concise,

let ou_exclusif = ( <> )

Remarquons que la fonction obtenue en OCaml est plus générique que la version Java, puisqu'elle permet de vérifier la différence entre n'importe quelle paire d'éléments du même type, pas uniquement des booléens.

Factorielle

modifier

La fonction mathématique factorielle peut se définir de plusieurs manières. Directement, avec des points de suspension, on écrira  . Ou encore sans points de suspension, par récurrence, à l'aide de

  • pour tout n >= 1,  .
  • pour tout autre n,   .

Ces deux définitions peuvent être utilisées en OCaml.

Définition récursive

modifier

On peut traduire directement la deuxième définition par

let rec factorielle = function
| n when n>=1 -> n * (factorielle (n - 1) )
| _           -> 1

Cet extrait se lit exactement comme la définition par récurrence.

Dans ce qui précède, rec signifie que la fonction est définie par récursivité, mécanisme qui correspond à la définition mathématique par récurrence ou par induction : les cas non-triviaux sont déterminés à partir des cas précédents, ou encore la fonction s'appelle elle-même. En OCaml, la récursivité est la forme de boucle la plus fréquente.

On pourrait aussi écrire le code suivant, toujours récursif :

let rec factorielle n = if n >= 1 then n * ( factorielle (n - 1) ) else 1

En Java, on recommande d'écrire :

public static int factorielle(int n)
{
  int result = 1;
  for(int i = 1; i < n; ++i)
    result *= i;
  return result;
}

Il est aussi possible d'écrire une version récursive en Java, même si, à cause des limites des implantations actuelles de Java, ce genre de pratique est généralement évité :

public static int factorielle(int n)
{
  if(n<=0)
    return 1;
  else
    return n * factorielle (n-1);
}

Comme on peut le constater, les versions OCaml sont plus concises et plus proches de la définition mathématique que la version Java recommandée. Même si cela n'est pas fondamental pour un exemple aussi simple, on peut déjà constater qu'il y a un peu moins d'occasions de commettre des erreurs simples en suivant la structure OCaml que la structure Java recommandée. Cette tendance ne fait que s'amplifier pour des exemples plus complexes.

Définition directe

modifier

On peut aussi définir le produit des nombres de 1 à n en opérant sur l'énumération des entiers de 1 à n, comme suit :

let factorielle n = reduce ( * ) ( 1 -- n )

Cette définition (légèrement fausse) se lit "factorielle n est le résultat qu'on obtient en appliquant la multiplication aux nombres consécutifs entre 1 et n". Pour obtenir cette définition, nous avons employé 1 -- n, c'est-à-dire l'énumération des entiers de 1 à n, l'opération ( * ), c'est-à-dire la multiplication, et la fonction reduce, qui applique l'opération choisie aux deux premiers éléments de l'énumération, puis au troisième, puis au quatrième, etc. Si l'énumération ne contient qu'un seul élément, le résultat de reduce est cet élément, quelle que soit l'opération appliquée.

La notion d'énumération se retrouve dans d'autres langages de programmation, y compris Java ou Python, généralement sous le nom d'itérateur. Il s'agit d'une manière d'accéder à tous les éléments d'un ensemble, dans un ordre donné, en ne consultant qu'une seule fois chaque élément. Cette notion est centrale en OCaml Batteries Included, puisqu'il s'agit de la manière principale de réaliser des boucles ou, plus généralement, des opérations répétitives sur une structure de données.

Pourquoi notre définition est-elle fausse ? Parce que nous n'avons pas correctement géré le cas où n<=0. Dans ces cas-là, l'énumération 1 -- n est vide et la fonction que nous avons proposé n'est pas définie. Il convient donc de remplacer l'appel à reduce par un appel à une fonction légèrement plus complexe, fold, qui prend en plus une valeur initiale (ici 1) et est capable de traiter le cas de l'énumération vide :

let factorielle n = fold ( * ) 1 ( 2 -- n )

Cette définition de la factorielle renvoie 1 si n est inférieur ou égal à 2 et 1 * 2 * ... * n dans le cas contraire.

Manipulation de fichiers

modifier

Un dernier exemple, qui fait cette fois appel à des bibliothèques de manipulation de fichiers. Le rôle de cet exemple est de lire et d'afficher le contenu d'un certain nombre de fichiers, en ajoutant avant chaque ligne le numéro de la ligne.

open Enum, File, IO;;

let add_line_number num line = (string_of_int num)^": "^line;;

iter f (args ())
where f arg = with_file_in arg  (fun input ->
   write_lines stdout (mapi add_line_number (lines_of input))
);;

La première ligne permet de faire appel à des fonctions prédéfinies et rangées dans les module Enum (tout ce qui a trait aux énumération), File (tout ce qui a trait aux fichiers) et IO (des fonctions qui permettent de gérer la lecture ou l'écriture d'informations).

L'extrait définit ensuite une fonction add_line_number qui, à partir d'un nombre et d'une chaîne de caractères, construit une nouvelle chaîne de caractères qui commence par le nombre, puis continue par un caractère ":" et enfin le contenu de la chaîne initiale. Nous utiliserons cette fonction pour ajouter le numéro de la ligne avant le contenu de la ligne.

La ligne suivante fait appel à la fonction iter. Cette fonction, proche dans l'esprit de reduce, permet de répéter un traitement et de l'appliquer à chacun des éléments d'une énumération. Ici, le traitement est donné sous la forme de la fonction f, définie un peu plus loin, et l'énumération est args (), c'est-à-dire les arguments passés au programme par la ligne de commande -- si vous ignorez ce qu'est la ligne de commande, ce n'est pas important, sachez juste ici qu'il va s'agir de plusieurs noms de fichiers donnés au lancement du programme.

L'instruction where introduit une définition après son utilisation, ici la définition de la fonction f. Cette fonction prend un argument arg, le nom du fichier à lire, puis invoque la fonction with_file_in avec cet argument arg et avec une autre fonction fun input -> .... La fonction with_file_in, définie dans le module File, a pour rôle d'ouvrir un fichier (ici le fichier nommé arg), de passer le contenu de ce fichier à une autre fonction (ici, la fonction fun input -> ... ) et, une fois que cette dernière fonction a fini de s'exécuter, de fermer le fichier.

Que fait donc cette fameuse fonction fun input -> ...  ? Partant d'un contenu de fichier input, elle décompose ce contenu en une énumération de lignes à l'aide de lines_of, applique à chaque ligne consécutive la fonction add_line_number à l'aide de la fonction mapi, puis écrit la nouvelle énumération ainsi obtenue dans le fichier stdout, à raison d'une ligne à la fois, à l'aide de la fonction write_lines. Il aurait tout aussi bien été possible de décomposer en caractères à l'aide de chars_of, ou en nombre, ou en constructions plus complexes -- et de réécrire de même à l'aide de write_chars, etc. Quant à stdout, il s'agit de la sortie standard, c'est-à-dire non pas un fichier réel mais l'écran. En Java, ce serait l'équivalent de System.out.

La fonction with_file_in constitue l'une des manières de gérer ce qui, en Java ou en Python, serait traité à l'aide d'un bloc de finalisation. En OCaml, la finalisation n'est pas (encore) une opération standard du langage mais peut être remplacée sans difficultés.

En Java, pour obtenir un résultat similaire, on aurait ajouté au programme quelque chose comme

import java.io.*;
//...

LineNumberReader reader = new LineNumberReader(new FileReader("/tmp/in.txt"));
try
{
  try
  {
    PrintWriter writer = new PrintWriter(new FileWriter("/tmp/out.txt"));
    reader.setLineNumber(1);
    for(String line = reader.readLine(); line != null; line = reader.readLine())
      writer.print(reader.getLineNumber+": "+line)
  } finally {
    writer.close();
  }
} finally {
  reader.close();
}
//...

La complexité des deux programmes est comparable, même si l'on peut constater que, de nouveau, les bibliothèques de OCaml sont de plus haut niveau et donc plus simples à composer sans commettre d'erreurs. En particulier, dans la version Java, de nombreuses erreurs peuvent se glisser, qui ne seraient pas détectées par le compilateur, par exemple oublier de fermer les flux, oublier d'avancer progressivement dans un flux, tester si la ligne est vide au lieu de déterminer si elle vaut null, utiliser equals au lieu de !=, etc. À l'inverse, dans la version OCaml, le programmeur ne peut commettre aucune de ces erreurs.

Si nécessaire, OCaml fournit aussi les bibliothèques nécessaires pour gérer les fichiers à peu près de la même manière qu'en Java, ou pour traiter les expressions régulières.

Et maintenant ?

modifier

Si cette introduction vous a donné envie d'en apprendre plus, il est temps de voir Les bases d'OCaml.