Définir une liste en utilisant uniquement le système de type Hindley-Milner

10

Je travaille sur un petit compilateur de calcul lambda qui a un système d'inférence de type Hindley-Milner qui fonctionne et prend désormais également en charge le let récursif (pas dans le code lié), ce qui, je crois, devrait suffire à le rendre complet .

Le problème est maintenant que je n'ai aucune idée de comment faire pour qu'il prenne en charge les listes, ou s'il les supporte déjà et j'ai juste besoin de trouver un moyen de les encoder. Je voudrais pouvoir les définir sans avoir à ajouter de nouvelles règles au système de type.

La façon la plus simple de penser à une liste xest comme quelque chose qui est null(ou la liste vide), ou une paire qui contient à la fois un xet une liste de x. Mais pour ce faire, je dois être en mesure de définir des paires et / ou, qui sont, je crois, le type de produit et de somme.

Semble que je peux définir des paires de cette façon:

pair = λabf.fab
first = λp.p(λab.a)
second = λp.p(λab.b)

Puisque pairaurait le type a -> (b -> ((a -> (b -> x)) -> x)), après avoir passé, disons, un intet un string, cela donnerait quelque chose avec le type (int -> (string -> x)) -> x, qui serait la représentation d'une paire de intet string. Ce qui me dérange ici, c'est que si cela représente une paire, pourquoi cela n'est-il pas logiquement équivalent à, ni n'implique la proposition int and string? Cependant, est équivalent à (((int and string) -> x) -> x), comme si je ne pouvais avoir que des types de produits comme paramètres de fonctions. Cette réponsesemblent résoudre ce problème, mais je n'ai aucune idée de ce que signifient les symboles qu'il utilise. De plus, si cela n'encode pas vraiment un type de produit, puis-je faire quelque chose avec les types de produits que je ne pourrais pas faire avec ma définition des paires ci-dessus (étant donné que je peux également définir des n-tuples de la même manière)? Sinon, cela ne contredirait-il pas le fait que vous ne pouvez pas exprimer la conjonction (AFAIK) en utilisant uniquement l'implication?

Et le type de somme? Puis-je en quelque sorte l'encoder en utilisant uniquement le type de fonction? Si oui, cela suffirait-il pour définir des listes? Ou bien, existe-t-il un autre moyen de définir des listes sans avoir à étendre mon système de type? Et sinon, quels changements devrais-je apporter si je veux que ce soit aussi simple que possible?

Veuillez garder à l'esprit que je suis un programmeur informatique, mais pas un informaticien ni un mathématicien et assez mauvais en lecture de notation mathématique.

Edit: je ne sais pas quel est le nom technique de ce que j'ai mis en œuvre jusqu'à présent, mais tout ce que j'ai est essentiellement le code que j'ai lié ci-dessus, qui est un algorithme de génération de contraintes qui utilise les règles pour les applications, les abstractions et les variables prises à partir de l'algorithme de Hinley-Milner, puis un algorithme d'unification qui obtient le type principal. Par exemple, l'expression \a.adonnera le type a -> aet l'expression \a.(a a)lèvera une erreur de vérification se produit. En plus de cela, il n'y a pas exactement une letrègle mais une fonction qui semble avoir le même effet qui vous permet de définir des fonctions globales récursives comme ce pseudo-code:

GetTypeOfGlobalFunction(term, globalScope, nameOfFunction)
{
    // Here 'globalScope' contains a list of name-value pair where every value is of class 'ClosedType', 
    // meaning their type will be cloned before unified in the unification algorithm so that they can be used polymorphically 
    tempType = new TypeVariable() // Assign a dummy type to `tempType`, say, type 'x'.
    // The next line creates an scope with everything in 'globalScope' plus the 'nameOfFunction = tempType' name-value pair
    tempScope = new Scope(globalScope, nameOfFunction, tempType) 
    type = TypeOfTerm(term, tempScope) // Calculate the type of the term 
    Unify(tempType, type)
    return type
    // After returning, the code outside will create a 'ClosedType' using the returned type and add it to the global scope.
}

Le code obtient essentiellement le type du terme comme d'habitude, mais avant d'unifier, il ajoute le nom de la fonction en cours de définition avec un type factice dans la portée du type afin qu'il puisse être utilisé de l'intérieur de manière récursive.

Edit 2: Je viens de réaliser que j'avais également besoin de types récursifs, que je n'ai pas, pour définir une liste comme je le veux.

Juan
la source
Pouvez-vous être un peu plus précis sur ce que vous avez mis en œuvre exactement? Avez-vous mis en œuvre le calcul lambda simplement tapé (avec des définitions récursives) et lui avez-vous donné des polymorphismes paramétriques de style Hindley-Milner? Ou avez-vous mis en œuvre le calcul lambda polymorphe de second ordre?
Andrej Bauer
Je peux peut-être demander plus facilement: si je prends OCaml ou SML et que je le limite aux termes lambda purs et aux définitions récursives, c'est de cela dont vous parlez?
Andrej Bauer
@AndrejBauer: J'ai édité la question. Je ne suis pas sûr d'OCaml et de SML, mais je suis sûr que si vous prenez Haskell et que vous le limitez aux termes lambda et aux récursifs de premier niveau (comme let func = \x -> (func x)) vous obtenez ce que j'ai.
Juan
1
Pour peut-être améliorer votre question, consultez ce meta post .
Juho

Réponses:

13

Paires

Ce codage est le codage Eglise des paires. Des techniques similaires peuvent coder des booléens, des entiers, des listes et d'autres structures de données.

Dans le contexte x:a; y:b, le terme pair x ya le type (a -> b -> t) -> t. L'interprétation logique de ce type est la formule suivante (j'utilise des notations mathématiques standard: est une implication, est ou, est et, est une négation; est l'équivalence des formules): Pourquoi « et ou »? Intuitivement, car¬

(abt)t¬(¬a¬bt)t(ab¬t)t(ab)t
ab tpairest une fonction qui prend une fonction en argument et l'applique à la paire. Cela peut se faire de deux manières: la fonction d'argument peut utiliser la paire, ou elle peut produire une valeur de type tsans utiliser la paire du tout.

pairest un constructeur pour le type de paire et firstet secondsont des destructeurs. (Ce sont les mêmes mots utilisés dans la programmation orientée objet; ici, les mots ont une signification qui est liée à l' interprétation logique des types et des termes que je n'entrerai pas ici.) Intuitivement, les destructeurs vous permettent d'accéder à ce qui est dans l'objet et les constructeurs ouvrent la voie au destructeur en prenant comme argument une fonction qu'ils appliquent aux parties de l'objet. Ce principe peut être appliqué à d'autres types.

Sommes

Le codage de l'Église d'une union discriminée est essentiellement double du codage de l'Église d'une paire. Lorsqu'une paire a deux parties qui doivent être assemblées et que vous pouvez choisir d'extraire l'une ou l'autre, vous pouvez choisir de construire l'union de deux manières et lorsque vous l'utilisez, vous devez autoriser les deux. Il y a donc deux constructeurs, et il y a un seul destructeur qui prend deux arguments.

let case w = λf. λg. w f g           case : ((a->t) -> (b->t) -> t) -> (a->t) -> (b->t) -> t
  (* or simply let case w = w *)
let left x = λf. λg. f x             left : a -> ((a->t) -> (b->t) -> t)
let right y = λf. λg. g x            right : b -> ((a->t) -> (b->t) -> t)

Permettez-moi d'abréger le type (a->t) -> (b->t) -> tcomme SUM(a,b)(t). Ensuite, les types de destructeurs et de constructeurs sont:

case : SUM(a,b)(t) -> (a->t) -> (b->t) -> t
left : a -> SUM(a,b)(t)
right : b -> SUM(a,b)(t)

Donc

case (left x) f g → f x
case (rightt y) f g → g y

Listes

Pour une liste, appliquez à nouveau le même principe. Une liste dont les éléments ont le type apeut être construite de deux manières: elle peut être une liste vide, ou elle peut être un élément (la tête) plus une liste (la queue). Par rapport aux paires, il y a une petite torsion en ce qui concerne les destructeurs: vous ne pouvez pas avoir deux destructeurs séparés headet tailparce qu'ils ne fonctionneraient que sur des listes non vides. Vous avez besoin d'un destructeur unique, avec deux arguments, dont l'un est une fonction à 0 argument (c'est-à-dire une valeur) pour le cas nul, et l'autre une fonction à 2 arguments pour le cas contraire. Des fonctions comme is_empty, headet tailpeuvent être dérivées de cela. Comme dans le cas des sommes, la liste est directement sa propre fonction destructrice.

let nil = λn. λc. n
let cons h t = λn. λc. c h t
let is_empty l = l true (λh. λt. false) 
let head l default = l default (λh. λt. h)
let tail l default = l default (λh. λt. t)

Chacune de ces fonctions est polymorphe. Si vous travaillez les types de ces fonctions, vous remarquerez que ce consn'est pas uniforme: le type du résultat n'est pas le même que le type de l'argument. Le type du résultat est une variable - il est plus général que l'argument. Si vous enchaînez les consappels, les appels successifs pour construire une liste instancient consdifférents types. Ceci est crucial pour faire fonctionner les listes en l'absence de types récursifs. Vous pouvez ainsi créer des listes hétérogènes. En fait, les types que vous pouvez exprimer ne sont pas «liste de », mais «liste dont les premiers éléments sont de types ».TT1,,Tn

Comme vous le supposez, si vous souhaitez définir un type qui ne contient que des listes homogènes, vous avez besoin de types récursifs. Pourquoi? Voyons le type d'une liste. Une liste est encodée comme une fonction qui prend deux arguments: la valeur à renvoyer sur des listes vides, et la fonction pour calculer la valeur à renvoyer sur une cellule contre. Soit ale type d'élément, ble type de la liste et cle type renvoyé par le destructeur. Le type d'une liste est

a -> (a -> b -> c) -> c

Rendre la liste homogène, c'est dire que si c'est une contre-cellule, la queue doit avoir le même type que l'ensemble, c'est-à-dire qu'elle ajoute la contrainte

a -> (a -> b -> c) -> c = b

Le système de type Hindley-Milner peut être étendu avec de tels types récursifs, et en fait les langages de programmation pratiques le font. Les langages de programmation pratiques ont tendance à interdire ces équations «nues» et nécessitent un constructeur de données, mais ce n'est pas une exigence intrinsèque de la théorie sous-jacente. Exiger un constructeur de données simplifie l'inférence de type et, en pratique, tend à éviter d'accepter des fonctions qui sont en fait boguées mais qui peuvent être typables avec une contrainte involontaire qui provoque une erreur de type difficile à comprendre lorsque la fonction est utilisée. C'est pourquoi, par exemple, OCaml accepte les types récursifs non protégés uniquement avec l' -rectypesoption de compilation non par défaut . Voici les définitions ci-dessus dans la syntaxe OCaml, ainsi qu'une définition de type pour les listes homogènes utilisant la notation pourtypes récursifs aliasés : type_expression as 'asignifie que le type type_expressionest unifié avec la variable 'a.

# let nil = fun n c -> n;;
val nil : 'a -> 'b -> 'a = <fun>
# let cons h t = fun n c -> c h t;;
val cons : 'a -> 'b -> 'c -> ('a -> 'b -> 'd) -> 'd = <fun>
# let is_empty l = l true (fun h t -> false);;
val is_empty : (bool -> ('a -> 'b -> bool) -> 'c) -> 'c = <fun>
# let head l default = l default (fun h t -> h);;
val head : ('a -> ('b -> 'c -> 'b) -> 'd) -> 'a -> 'd = <fun>
# let tail l default = l default (fun h t -> t);;
val tail : ('a -> ('b -> 'c -> 'c) -> 'd) -> 'a -> 'd = <fun>
# type ('a, 'b, 'c) ulist = 'c -> ('a -> 'b -> 'c) -> 'c;;
type ('a, 'b, 'c) ulist = 'c -> ('a -> 'b -> 'c) -> 'c
# is_empty (cons 1 nil);;
- : bool = false
# head (cons 1 nil) 0;;
- : int = 1
# head (tail (cons 1 (cons 2.0 nil)) nil) 0.;;
- : float = 2.

(* -rectypes is required for what follows *)
# type ('a, 'b, 'c) rlist = 'c -> ('a -> 'b -> 'c) -> 'c as 'b;;
type ('a, 'b, 'c) rlist = 'b constraint 'b = 'c -> ('a -> 'b -> 'c) -> 'c
# let rcons = (cons : 'a -> ('a, 'b, 'c) rlist -> ('a, 'b, 'c) rlist);;
val rcons :
  'a ->
  ('a, 'c -> ('a -> 'b -> 'c) -> 'c as 'b, 'c) rlist -> ('a, 'b, 'c) rlist =
  <fun>
# head (rcons 1 (rcons 2 nil)) 0;;
- : int = 1
# tail (rcons 1 (rcons 2 nil)) nil;;
- : 'a -> (int -> 'a -> 'a) -> 'a as 'a = <fun>
# rcons 1 (rcons 2.0 nil);;
Error: This expression has type
         (float, 'b -> (float -> 'a -> 'b) -> 'b as 'a, 'b) rlist = 'a
       but an expression was expected of type
         (int, 'b -> (int -> 'c -> 'b) -> 'b as 'c, 'b) rlist = 'c

Plis

En regardant cela un peu plus généralement, quelle est la fonction qui représente la structure des données?

  • Pour un nombre naturel: est représenté comme la fonction qui réitère son argument fois.nn
  • Pour une paire: est représenté comme une fonction qui applique son argument à et .(x,y)xy
  • Pour une somme: est représenté comme une fonction qui applique son ème argument à .ini(x)ix
  • Pour une liste: est représenté comme une fonction qui prend deux arguments, la valeur à retourner pour la liste vide et la fonction à appliquer aux contre-cellules.[x1,,xn]

De manière générale, la structure des données est représentée comme sa fonction de repli . Il s'agit d'un concept général pour les structures de données: une fonction de repli est une fonction d'ordre supérieur qui traverse la structure de données. Il y a un sens technique dans lequel le pli est universel : tous les parcours de structure de données «génériques» peuvent être exprimés en termes de pli. Le fait que la structure de données puisse être représentée comme sa fonction de repliement le montre: tout ce que vous devez savoir sur une structure de données est de savoir comment la parcourir, le reste est un détail d'implémentation.

Gilles 'SO- arrête d'être méchant'
la source
Vous mentionnez le « codage d' église » des nombres entiers, des paires, des sommes, mais pour les listes, vous donnez le codage Scott . Je pense que cela pourrait être un peu déroutant pour ceux qui ne connaissent pas les encodages de types inductifs.
Stéphane Gimenez
Donc, fondamentalement, mon type de paire n'est pas vraiment un type de produit car une fonction avec ce type pourrait simplement retourner tet ignorer l'argument qui est censé prendre aet b(ce qui est exactement ce qui (a and b) or test dit). Et il semble que j'aurais le même genre de problème avec les sommes. Et aussi, sans types récursifs, je n'aurai pas de liste homogène. Donc, en quelques mots, dites-vous que je devrais ajouter des règles de somme, de produit et de type récursif pour obtenir des listes homogènes?
Juan
Voulez-vous dire case (right y) f g → g yà la fin de votre section Sommes ?
Juan
@ StéphaneGimenez je ne m'en étais pas rendu compte. Je n'ai pas l'habitude de travailler sur ces encodages dans un monde typé. Pouvez-vous donner une référence pour l'encodage Church vs l'encodage Scott?
Gilles 'SO- arrête d'être méchant'
@JuanLuisSoldi Vous avez probablement entendu dire qu '"il n'y a pas de problème qui ne peut être résolu avec un niveau d'indirection supplémentaire". Les codages d'église codent les structures de données en tant que fonctions en ajoutant un niveau d'appel de fonction: une structure de données devient une fonction de second ordre que vous appliquez à la fonction pour agir sur les pièces. Si vous voulez un type de liste homogène, vous devrez faire face au fait que le type de la queue est le même que le type de la liste entière. Je pense que cela doit impliquer une forme de récursion de type.
Gilles 'SO- arrête d'être méchant'
2

Vous pouvez représenter des types de somme en tant que types de produits avec des balises et des valeurs. Dans ce cas, nous pouvons tricher un peu et utiliser une balise pour représenter nul ou non, la deuxième balise représentant la paire tête / queue.

Nous définissons les booléens de la manière habituelle:

true = λi.λe.i
false = λi.λe.e
if = λcond.λthen.λelse.(cond then else)

Une liste est alors une paire avec le premier élément comme booléen et le deuxième élément comme paire tête / queue. Quelques fonctions de liste de base:

isNull = λl.(first l)
null = pair false false     --The second element doesn't matter in this case
cons = λh.λt.(pair true (pair h t ))
head = λl.(fst (snd l))   --This is a partial function
tail = λl.(snd (snd l))   --This is a partial function  

map = λf.λl.(if (isNull l)
                 null 
                 (cons (f (head l)) (map f (tail l) ) ) 
jmite
la source
Mais cela ne me donnerait pas une liste homogène, n'est-ce pas?
Juan