La question du polymorphisme dans la conception d'un langage informatique est une vraie question qui a plusieurs solutions selon les objectifs du créateurs.
En OCaml, nous avons un polymorphisme uniforme ou polymorphisme paramétrique dans le sens où les opérations de la fonction polymorphe sur les valeurs seront toujours les mêmes quelque soit le type. Par exemple, let rec map f = function [] -> [] | x :: r -> f x :: map f r
donne le type ('a -> 'b) -> 'a list -> 'b list
. Que la liste soit int list
ou float list
, le traitement restera le même - à savoir l'application de la fonction f
sur tout les éléments de la liste.
Au contraire du polymorphisme ad-hoc, nous avons une fonction générique (note bien la différence entre générique et polymorphe) qui accepte des valeurs de tous types mais dont les opérations diffèrent selon le type des valeurs. C'est le cas en C++ par exemple avec une différence notable a OCaml qui est la surcharge des opérateurs comme +
qui peut autant s'appliquer à des int
qu'à des float
ou à d'autres objets plus complexes pour peu qu'on est défini le traitement.
Dans un polymorphisme ad-hoc, une fonction générique à donc plusieurs algorithmes selon le type des valeurs. C'est le cas par exemple du print
en Python qui, selon le type, va afficher d'une certaines manières la valeur (un dictionnaire n'apparait pas de la même manière qu'un entier avec cette fonction). Sur la question que tu te poses, tu pars déjà sur un polymorphisme ad-hoc: avoir des fonctions du même nom mais avec une signature différente - et par extension un traitement différent selon le type.
Maintenant, la vraie question que tu te poses, c'est d'implémenter un mécanisme de dispatch
lors de l'application de la fonction pour savoir qu'elle algorithme il faut utiliser - ce choix dépendra, comme tu le sais, du type de ton argument. Par exemple, pour l'implémentation d'un print
tel que celui de Python, nous pourrions avoir ceci:
| let print = function
| Int v -> print_int v
| Float v -> print_float v
| String v -> print_string v
| _ -> raise Not_implemented
|
On a bien un mécanisme de dispatch
selon le type de l'argument. Bien entendu, l'extensibilité de la fonction print
à d'autres types (comme ceux créé par l'utilisateur) n'est pas possible, on peut alors imaginer ce code modifié tiré de ce document:
1
2
3
4
5
6
7
8
9
10
11
12
13 | type type_id = string
type val = VInt of int | VFloat of float | VObject of value
type ty = TInt | TFloat | TObject of type_id
let printers =
ref ([(TInt, (function VInt i -> print_int i | _ -> assert false));
(TFloat, (function VFloat f -> print_float f | _ -> assert false))]
: (ty * (value -> unit)) list)
let rec print (ty, value) =
try let (_, printer) = List.find ((=) ty) !printers in
printer value
with Not_found -> raise Not_implemented
|
Comme tu le constates, tu dois avoir l'information du type de la valeur que tu manipules - ce dernier va pouvoir faire un dispatch
recherchant selon le type, le bon printer
dans liste. Ici, c'est une liste associative ty * printer
mais on peut imaginer une Map
avec pour clé ty
pour être plus efficient dans la recherche.
On peut rendre ce genre d'algorithme plus général pour l'application d'une fonction. Puisque tu fais un LISP, ça reste de la programmation fonctionnel et on peut décrire les types d'une fonction à l'aide d'un TArrow
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 | type ty = TTop | TInt | TFloat | TArrow of (ty * ty)
type value = VTop | ... | VArrow of (value -> value)
let rec ty_eq ty1 ty2 = match ty1, ty2 with
| TTop, _ | _, TTop -> true
| ...
| TArrow (tya, tyb), TArrow (tya', tyb') ->
ty_eq tya tya' && ty_eq tyb tyb'
let rec eval ?(expected_ty = TTop) environment =
| Variable name ->
Env.find name environment : (ty * value) list
|> List.find (ty_eq expected_ty)
| Application (f, v) ->
let (ty_v, v) = eval environment v in
let (ty_f, f) = eval ~expected_ty:ty_v environment f in
match f with
| VArrow f -> f v
| _ -> raise Is_not_function
|
Alors plusieurs choses, j'ai enlevé Object
pour simplifié le code (ce dernier permettait à ce que l'utilisateur puisse définir ses propres types mais il faudrait implémenter une fonction unlink
qui réduirait l'Object
à ce qu'il peut être structurellement - et à ce moment, on parle déjà de duck typing comme en Python). Le type Top
est la borne supérieur à tout les types (comme le type Object
en Java) - en général, on y associe aucuns traitements, et si on est face à une valeur Top
, c'est qu'on essaye d'appliquer une fonction à une valeur où nous n'aurions pas trouver une signature/un algorithme correspondant et c'est notamment pour éviter que l'évaluation s'arrête face à ce genre d'erreur. Enfin, Arrow
est l'équivalent de l'abstraction en lambda calcul.
Cela reste une implémentation imparfaite mais qui donne une idée général de ce que peut être le polymorphisme ad-hoc. Cependant, il y a plusieurs points à expliquer ici qui ne sont pas forcément facile à comprendre sans recul.
Le premier point, c'est l'obligation de se coltiner le type de nos valeurs tout le long de l'évaluation (et c'est le cas en Python) qui fait qu'à un niveau plus bas, on parle de valeur boxé. Bien entendu, on trouve toujours d'autres moyens comme en C++ avec le name-mangling mais ce qu'il faut dénoter, c'est qu'à vouloir du polymorphisme ad-hoc, on complexifie fortement le runtime pour quelque chose qui, AMHA, reste de l'utilisation occasionnelle.
Ensuite, c'est la gestion des erreurs. Il est difficile de gérer de manière exhaustive toutes les erreurs qui peuvent provenir du polymorphisme ad-hoc (je me souviens des messages d'erreurs de link avec GCC avec l'utilisation de template parce qu'il ne trouver pas tel ou tel méthode dans ma classe). L’inexistence d'une signature d'une fonction peut donc poser des soucis lors de l'évaluation et c'est pour cette raison que j'ai créé le type Top
qui rend cette erreur silencieuse d'une certaines manières mais doit on considérer que cette erreur doit arrêter tout le programme ou non ? C'est encore une question ouverte.
La finalité, c'est qu'au travers de tout ces inconvénients, j'ai une préférence pour un système de type statique comme celui d'OCaml ou Haskell mais où l'implémentation est certainement un peu plus complexe au niveau du compilateur mais où l'overhead est inexistant au niveau du runtime. C'est pour cette raison que j'ai fait une présentation au début du polymorphisme paramétrique comme une autre solution.