La surcharge des opérateurs en Objective Caml

S'il y a un point sur lequel tout le monde tombe rapidement d'accord après quelques heures de Caml, c'est que l'utilisation des opérateurs du type +. avec les flottants, c'est super lourd. Tout comme les multiples print_int, print_float et assimilés. Je n'ai pas changé d'avis trois ans plus tard, d'autant plus que les gentils messages Newbie-error: This expression has type float but an expression was expected of type int sont toujours mon quotidien.

Les programmeurs C++ et Java (entre autres) connaissent une solution à ce problème : la surcharge des opérateurs et des méthodes. Dans les grandes lignes, cela consiste à avoir des opérateurs binaires, comme +, qui agissent différemment selon le type des arguments qu'on leur passe. Un petit exemple en Java :

  • 1 + 1; retourne l'entier 2
  • 1.0 + 1.0; retourne le flottant 2
  • "Hello " + "World!"; retourne la chaîne "Hello World!"

L'objectif de cet article est de reproduire ce comportement en Objective Caml. Mais alors que je vois déjà les puristes s'évanouir, je vais éclaircir quelques points.

 

Avant-propos

Tout d'abord, il ne faut pas confondre les surcharges qui sont présentes dans le langage, comme celles du Java citées plus haut, et celles crées par le programmeur. En C++, ce dernier peut surcharger à la fois les opérateurs et les méthodes. En Java, il ne peut surcharger que les méthodes, mais beaucoup de surcharges d'opérateurs sont déjà inclues dans le langage. En OCaml, seuls quelques pauvres opérateurs sont "surchargés" par défaut, tels que < de type (infix) 'a -> 'a -> bool, le programmeur peut remplacer le rôle d'un opérateur de son choix (nous verrons comment), mais il est réputé impossible de faire vivre conjointement deux surcharges du même opérateur. Dommage, c'est exactement ce que l'on voulait.

Ensuite, il faut bien distinguer les surcharges et les fonctions à type polymorphique (type dépendant d'un paramètre), comme  < de type (infix) 'a -> 'a -> bool. Car certes, les fonctions polymorphiques sont, en quelque sorte, des surcharges, mais on ne peut pas faire n'importe quelle surcharge avec des fonctions polymorphiques. Un petit exemple :

# let id x = x;;
val id : 'a -&gt; 'a = ;
# let next x = x+1;;
val next : int -&gt; int =

On ne peut faire des fonctions polymorphiques qu'à partir d'autre fonctions polymorphiques, de variables libres et inutilisées, ce qui limite rapidement les possibilités. Impossible de surcharger + avec une fonction polymorphique, par exemple.

Dernier point, et non des moindres, ce que je vais vous proposer n'a pas vocation à être une solution efficace ou sérieuse au problème, ce n'est qu'un petit hack pour faire découvrir à ceux qui le veulent les entrailles parfois méconnues d'Objective Caml.

 

Pourquoi le programmeur OCaml ne peut-il pas surcharger des opérateurs ?

C'est la bonne question. Et voici les réponses que vous trouverez facilement sur internet.

  • Parce que tous les opérateurs doivent conserver au maximum des types statiques, pour que l'inférence de type puisse fonctionner. Dans l'exemple précédent, on remarque bien que c'est l'usage du + qui permet de déterminer l'intégralité du typage de la fonction.

 

# let id x = x;;
val id : 'a -&gt; 'a = ;
# let next x = x+1;;
val next : int -&gt; int =

 

  • Parce qu'il faudrait pouvoir matcher le type des arguments donnés à l'opérateur pour savoir quelles actions effectuer. Or il n'existe pas de fonction qui prend une variable en argument et qui retourne son type (cette fonction serait surchargée !).
  • Parce que même si l'on disposait d'une telle fonction, on aurait rapidement un problème de typage ! Imaginons que l'on ait construit cette fonction typage : 'a -> string qui pour une variable retourne le type de cette variable écrit dans une chaîne de caractères. On serait tenté d'écrire une fonction plus comme suit

 

# let plus x y = match typage x with
    | "int" -&gt; x+y
    | "float" -&gt; x+.y
    | _ -&gt; failwith "Wrong type of arguments for \"plus\"";

La réponse du compilo ne se ferait alors pas attendre

Characters 72-73:
      | "float" -&gt; x+.y
                   ^
Error: This expression has type int but an expression was
expected of type float

x a en effet été déterminé comme un entier au à la ligne précédente, à cause de l'utilisation de +, donc il ne peut pas être un flottant à cette ligne !

 

Votre mission, si vous l'acceptez

L'avantage avec toutes ces interdictions, c'est que l'on connait maintenant exactement les règles que l'on doit violer. Voici nos objectifs :

  • Créer des opérateurs surchargés de type 'a -> 'b -> 'c
  • Créer la dite fonction typage : 'a -> string
  • Résoudre le problème de conflit de typage que l'on vient de soulever

Pour cela, plusieurs solutions s'offre à nous

  • Modifier le code source d'Objective Caml. C'est la solution radicale, la plus propre et la plus sérieuse. Si cela vous intéresse, je vous conseille de jeter un oeil sur les projets $'Caml et G'Caml de Camlspotter, qui semblent plutôt bien fonctionner.
  • Utiliser des modules. Ce n'est ni propre, ni très efficace, ni très marrant. Je vous laisse googler ça si ça vous plait.
  • Remplir notre mission  sans modifier le code source de Caml et en n'utilisant que la bibliothèque standard. Voilà un défi, et voilà ce que nous allons faire. Pour le fun bien sûr !

 

La fonction de typage

Objectif : écrire une fonction fonction typage : 'a -> string.

Pour cela, nous allons utiliser le module Obj, que les magouilleurs connaissent bien. Il a la particularité d'avoir pour seul description dans la documentation l"amusante phrase "Not for casual users." et comprend un grand nombre de méthodes permettant, notamment, d'accéder à la représentation des objets Caml au niveau de la machine.

Pour simplifier, un objet comme l'entier 1 est stocké dans la mémoire de votre machine non pas sous la forme d'un entier, mais des différents blocs de données, comprenant sa valeur (ici, 1), ou encore le type de l'objet (c'est ce que l'on va récupérer). Pour plus d'informations à ce sujet, je vous conseille cet article.

Ce que l'on va utiliser :

  • Obj.repr : 'a -> t qui à une variable retourne sa représentation machine
  • Obj.tag : t -> int qui à une représentation machine associe le tag de l'objet, et donc des informations sur son type
  • Obj.int_tag : int qui donne directement le tag des entiers
  • Obj.float_tag : int, le tag des flottants
  • Obj.string_tag : int, le tag des strings

Il ne reste qu'à dérouler :

# let typage x =
    let tag_x = Obj.tag (Obj.repr x) in
        if tag_x = Obj.int_tag then "int"
        else if tag_x = Obj.string_tag then "string"
        else if tag_x = Obj.double_tag then "float"
        else if tag_x = Obj.tag (Obj.repr [||]) then "array"
        else "unknown";;

Et même le compilo est heureux :

val typage : 'a -&gt; string =

Vous noterez que l'on peut facilement étendre cette fonction à des types plus complexes, comme je l'ai fait pour les arrays, mais attention aux problèmes de collision (par exemple, il n'y a aucune différence entre un booléen et un entier en représentation machine).

 

Décapiter l'inférence de types

Objectif : construire des opérateurs et fonctions de types indéterminables par le système d'inférence.

Attardons-nous avant tout un peu sur le système d'inférence de types d'Objective Caml avec un cas simple :

# let pow4 x =
    let y = x*x
    in float_of_int(y*y);;
 
val pow4 : int -&gt; float =

Le type d'entrée int a été déterminé à la ligne 2, grâce au typage de l'opérateur *. Le type de sortie float a été inféré à la ligne suivante, grâce au typage de la fonction float_of_int.

Notons X l'argument de la fonction que l'on créé et Y sa valeur de retour. Pour obtenir une fonction de typage quelconque, que Caml notera 'a -> 'b, il faut dans le corps de notre fonction, que :

  • toutes les fonctions appelées avec X en argument soient de type 'a -> _
  • la dernière fonction appelée avant le return soit de type _ -> 'a

Et ces fonctions-là ne courent pas les rues. Nous allons donc jouer finement avec la fonction magique Obj.magic : 'a -> 'b, qui à n'importe quel objet retourne ce même objet, mais avec un type particulier, noté 'a = <poly>. Pour le compilateur, ça veut dire que le type de cet objet est indéterminé et qu'il demande à être casté proprement par le programmeur. Un petit exemple montre que l'on peut rapidement faire n'importe quoi avec ça :

# let x = Obj.magic 1;;
val x : 'a =
 
# (x:int);;
- : int = 1
# let y = Obj.magic 1.0;;
val y : 'a =
 
# (y:int);;
- : int = 18675208

L'idée maintenant, c'est d'isoler X et Y du corps de la fonction grâce à des Obj.magic. Ainsi, Caml ne pourra pas inférer leur type.

# let pow4 x =
    let x_untyped = Obj.magic x in
    (* à partir d'ici, on utilisera uniquement x_untyped *)
    let v = x_untyped*x_untyped in
    let y = float_of_int(v*v) in
    (* on retourne une version non typée de y *)
    Obj.magic y;;
 
val pow4 : 'a -&gt; 'b =

Ainsi, on respecte bien les deux points que l'on avait identifiés : x n'est utilisé que par Obj.magic de type d'entrée 'a, et la dernière fonction appliquée avant le return est de type de sortie 'b. Le compilateur nous confirme que le type de notre fonction pow4 est maintenant indéterminé. Ô joie.

Il y a quand même un point négatif à cette technique : il faut caster la valeur de retour a posteriori...

# let x = pow4 2;;
val x : 'a =
 
# (x:float);;
- : float = 16.

 

Mais ce cast est automatique si l'on utilise la sortie de notre fonction en entrée d'une fonction typée :

# 1. +. pow4 2;;
- : float = 17.

 

Bon, on les surcharge, ces opérateurs ?

Petit cours de rattrapage sur les opérateurs binaires pour les retardataires : l'opérateur binaire + n'est rien d'autre qu'une fonction de type int -> int -> int qui a été infixée. La forme préfixe correspondante s'appelle en entourant l'opérateur de parenthèses :

# (+);;
- : int -&gt; int -&gt; int = ;
# (+) 1 2;;
- : int = 3

Cette forme permet de redéfinir localement les opérateurs. Exemple stupide : on va échanger le rôle des opérateurs (+) et (-).

Notre première idée serait de faire

# let (+) x y = x-y;;
# let (-) x y = x+y;;

Trois remarques : tout d'abord, ça s'écrit plus élégamment...

# let (+) = (-);;
# let (-) = (+);;

Ensuite, ce code est faux. A la première ligne, (-) correspond à la soustraction, et (+) se voit associé à la soustraction. A la deuxième ligne, (+) a été précédemment associé à la soustraction, donc le rôle de l'opérateur (-) ne change pas.

Enfin, on ne sera plus en mesure d'effectuer une addition propre (comprendre, sans faire # let (+) x y = x - (-y);;). En effet, l'addition de Caml n'est plus associée à aucun symbole d'opérateur ! Il faut donc faire une double affectation au même niveau, que voici :

# let (+) = (-) and (-) = (+);;
val ( + ) : int -&gt; int -&gt; int = 
val ( - ) : int -&gt; int -&gt; int =

Et c'est là qu'on commence à s'amuser

# 5+5;;
- : int = 0
# 5-5;;
- : int = 10

Maintenant que l'on sait redéfinir des opérateurs, il reste à les surcharger. Il suffit d'habilement combiner tout ce que l'on a vu jusqu'ici.

On commence par définir une fonction qui match le type de ses arguments et qui agit en conséquence

# let plus x y =
    let type_x = typage x
    and type_y = typage y in
    if type_x &lt;&gt; type_y then
        failwith "Faut pas abuser..."
    else match type_x with
        | "int" -&gt; x+y
        | "float" -&gt; x+.y
        | "string" -&gt; x^y
        | _ -&gt; failwith "Je ne sais pas additionner ça !";;

Comme on l'a vu, ce code contient un conflit de type entre les deux premiers cas de matching. On isole donc les variables d'entrée et la valeur de sortie du corps de la fonction :

# let plus x y =
    let x_untyped = Obj.magic x
    and y_untyped = Obj.magic y
    and type_x = typage x
    and type_y = typage y in
    if type_x &lt;&gt; type_y then
        failwith "Faut pas abuser..."
    else match type_x with
        | "int" -&gt; Obj.magic (x_untyped + y_untyped)
        | "float" -&gt; Obj.magic (x_untyped +. y_untyped)
        | "string" -&gt; Obj.magic (x_untyped ^ y_untyped)
        | _ -&gt; failwith "Je ne sais pas additionner ça !";;

Cette fois ça compile !

val plus : 'a -&gt; 'b -&gt; 'c =

 

Redéfinissons l'opérateur (+) avec ça.

# let (+) = plus;;
val ( + ) : 'a -&gt; 'b -&gt; 'c =

 

Et testons rapidement (n'oubliez pas de caster les sorties !)

# (1+1:int);;
- : int = 2
# (1.+1.:float);;
- : float = 2.
# ("Hello "+"World!":string);;
- : string = "Hello World!"
# (1+"Hello");;
Exception: Failure "Faut pas abuser...".
# ([5]+[3]:int list);;
Exception: Failure "Je ne sais pas additionner ça !".

 

Game Over. Insert coin to continue.

 

Un petit dernier pour la route

Juste parce que vous avez eu le courage de lire mes stupidités jusque là, et parce que vous trouvez certainement que caster les sorties, c'est assez nul, en fait... Qui veut une méthode print : 'a -> unit qui serait l'union de print_int, print_float et print_string ?

# let print x =
    let x_untyped = Obj.magic x
    and type_x = typage x in
    match type_x with
        | "int" -&gt; print_int x_untyped
        | "float" -&gt; print_float x_untyped
        | "string" -&gt; print_string x_untyped
        | _ -&gt; failwith "Je ne sais pas imprimer ça !";;
 
val print : 'a -&gt; unit =

Tout ça sent très bon.

#print 1;
#print_newline();
#print 1.;
#print_newline();
#print "Hello World!";
#print_newline();;
1
1.
Hello World!
- : unit = ()

 

Elle est pas belle la vie ?

2 Réponses à "La surcharge des opérateurs en Objective Caml"

Laisser un commentaire