Représenter les variables liées avec une fonction des utilisations aux classeurs

11

Le problème de la représentation des variables liées dans la syntaxe, et en particulier celui de la substitution évitant la capture, est bien connu et a plusieurs solutions: variables nommées avec équivalence alpha, indices de Bruijn, localement sans nom, ensembles nominaux, etc.

Mais il semble y avoir une autre approche assez évidente, que je n'ai pourtant pas vue utilisée nulle part. À savoir, dans la syntaxe de base, nous n'avons qu'un seul terme "variable", écrit par exemple , puis séparément nous donnons une fonction qui mappe chaque variable à un classeur dans la portée duquel elle se trouve. Donc, un -term commeλ

λX.(λy.Xy)

serait écrit , et la fonction mapperait le premier au premier et le second au second . C'est donc un peu comme les indices de Bruijn, mais au lieu d'avoir à "compter s" lorsque vous reculez pour trouver le liant correspondant, vous évaluez simplement une fonction. (Si cela représente une structure de données dans une implémentation, je penserais à équiper chaque objet à terme variable d'un simple pointeur / référence vers l'objet à terme de liant correspondant.)λ λ λλ.(λ.)λλλ

Évidemment, cela n'est pas judicieux pour écrire la syntaxe sur une page que les humains peuvent lire, mais les indices de Bruijn ne le sont pas non plus. Il me semble que cela a un sens mathématique parfait, et en particulier il rend la substitution évitant la capture très facile: il suffit de laisser tomber le terme que vous remplacez et de prendre l'union des fonctions de liaison. Il est vrai qu'il n'a pas de notion de "variable libre", mais alors (encore) les indices de Bruijn non plus; dans les deux cas, un terme contenant des variables libres est représenté par un terme avec une liste de classeurs "contextuels" devant.

Suis-je en train de manquer quelque chose et il y a une raison pour laquelle cette représentation ne fonctionne pas? Y a-t-il des problèmes qui le rendent tellement pire que les autres que cela ne vaut pas la peine d'être considéré? (Le seul problème auquel je peux penser en ce moment est que l'ensemble des termes (ainsi que leurs fonctions de liaison) ne sont pas définis par induction, mais cela ne semble pas insurmontable.) Ou existe-t-il réellement des endroits où il a été utilisé?

Mike Shulman
la source
2
Je ne connais pas les inconvénients. Peut-être que la formalisation (par exemple dans un assistant de preuve) est plus lourde? Je ne suis pas sûr ... Ce que je sais, c'est qu'il n'y a rien de mal sur le plan technique: cette façon de voir les termes lambda est celle suggérée par leur représentation sous forme de réseaux de preuves, donc les gens conscients du filet de preuve (comme moi) l'utilisent implicitement tout le temps. Mais les gens connaissant bien les preuves sont très rares :-) Alors c'est peut-être vraiment une question de tradition. PS: J'ai ajouté quelques balises vaguement liées pour rendre la question plus visible (espérons-le).
Damiano Mazza
Cette approche n'est-elle pas équivalente à une syntaxe abstraite d'ordre supérieur (c'est-à-dire représenter des classeurs comme des fonctions dans le langage hôte)? Dans un sens, l'utilisation d'une fonction comme classeur établit implicitement des pointeurs vers des classeurs dans la représentation des fermetures.
Rodolphe Lepigre
2
@RodolpheLepigre Je ne pense pas. En particulier, je crois comprendre que HOAS n'est correct que lorsque la métathéorie est assez faible, alors que cette approche est correcte dans une métathéorie arbitraire.
Mike Shulman
3
À droite, donc chaque classeur utilise un nom de variable unique (dans l'arborescence) (le pointeur vers celui-ci en est un automatiquement). Il s'agit de la convention Barendregt. Mais lorsque vous remplacez, vous devez reconstruire (en C) la chose que vous remplacez pour continuer à avoir des noms uniques. Sinon (en général), vous utilisez les mêmes pointeurs pour plusieurs sous-arbres et pouvez obtenir une capture variable. La reconstruction est un changement de nom alpha. Vraisemblablement, quelque chose de similaire se produit en fonction des spécificités de votre codage des arbres en tant qu'ensembles?
Dan Doel
3
@DanDoel Ah, intéressant. Je pensais que c'était si évident qu'il n'était pas nécessaire de mentionner que vous déposeriez une copie distincte du terme substitué à chaque occurrence de la variable à laquelle il est substitué; sinon vous n'auriez plus d' arbre de syntaxe ! Il ne m'est pas venu à l'esprit de considérer cette copie comme un changement de nom alpha, mais maintenant que vous le signalez, je peux le voir.
Mike Shulman

Réponses:

11

Les réponses d'Andrej et de Łukasz soulèvent de bons points, mais je voulais ajouter des commentaires supplémentaires.

Pour faire écho à ce que Damiano a dit, cette façon de représenter la liaison à l'aide de pointeurs est celle suggérée par les réseaux de preuves, mais le premier endroit où je l'ai vu pour les termes lambda était dans un ancien essai de Knuth:

  • Donald Knuth (1970). Exemples de sémantique formelle. Dans Symposium on Semantics of Algorithmic Languages , E. Engeler (éd.), Lecture Notes in Mathematics 188, Springer.

À la page 234, il a dessiné le diagramme suivant (qu'il a appelé une "structure d'information") représentant le terme :(λy.λz.yz)X

Diagramme de Knuth pour $ (\ lambda y. \ Lambda z.yz) x $

Ce type de représentation graphique des termes lambda a également été étudié de manière indépendante (et plus approfondie) dans deux thèses au début des années 1970, à la fois par Christopher Wadsworth (1971, Semantics and Pragmatics of the Lambda-Calculus ) et par Richard Statman (1974, Structural Complexity des preuves ). De nos jours, de tels diagrammes sont souvent appelés "graphiques λ" (voir par exemple cet article ).

Observez que le terme dans le diagramme de Knuth est linéaire , dans le sens où chaque variable libre ou liée se produit exactement une fois - comme d'autres l'ont mentionné, il y a des problèmes et des choix non triviaux à faire en essayant d'étendre ce type de représentation à des non - termes linéaires.

α

Noam Zeilberger
la source
10

Je ne sais pas comment votre fonction variable-liant serait représentée et dans quel but vous souhaitez l'utiliser. Si vous utilisez des pointeurs arrière, comme Andrej l'a noté, la complexité de calcul de la substitution n'est pas meilleure que l'alpha-renommage classique.

À partir de votre commentaire sur la réponse d'Andrej, j'en déduis que vous êtes dans une certaine mesure intéressé à partager. Je peux fournir quelques informations ici.

Dans un calcul lambda typé typique, l'affaiblissement et la contraction, contrairement à d'autres règles, n'ont pas de syntaxe.

Γt:TΓ,X:UNEt:TW
Γ,X1:UNE,X2:UNEt:TΓ,X:UNEt:TC

Ajoutons une syntaxe:

Γt:TΓ,X:UNEWX(t):TW
Γ,X1:UNE,X2:UNEt:TΓ,X:UNECXX1,X2(t):TC

Cuneb,c()uneb,c

Avec cette syntaxe, chaque variable est utilisée exactement deux fois, une fois où elle est liée et une fois où elle est utilisée. Cela nous permet de nous éloigner d'une syntaxe particulière et de considérer le terme comme un graphique où les variables et les termes sont des arêtes.

De la complexité algorithmique, nous pouvons maintenant utiliser des pointeurs non pas d'une variable à un liant, mais de liant à variable et avoir des substitutions dans un temps constant.

De plus, cette reformulation nous permet de suivre l'effacement, la copie et le partage avec plus de fidélité. On peut écrire des règles qui copient (ou effacent) de façon incrémentielle un terme tout en partageant des sous-termes. Il y a plusieurs façons de procéder. Dans certains contextes restreints, les victoires sont assez surprenantes .

Cela se rapproche des sujets des réseaux d'interaction, des combinateurs d'interaction, de la substitution explicite, de la logique linéaire, de l'évaluation optimale de Lamping, du partage des graphiques, de la logique lumineuse et autres.

Tous ces sujets sont très excitants pour moi et je me ferais un plaisir de vous donner des références plus spécifiques mais je ne sais pas si tout cela vous est utile et quels sont vos intérêts.

Łukasz Lew
la source
6

Votre structure de données fonctionne, mais elle ne sera pas plus efficace que les autres approches, car vous devez copier chaque argument à chaque réduction bêta et vous devez faire autant de copies qu'il y a d'occurrences de la variable liée. De cette façon, vous continuez de détruire le partage de mémoire entre les sous-termes. Combiné avec le fait que vous proposez une solution non pure qui implique des manipulations de pointeurs, et est donc très sujette aux erreurs, cela ne vaut probablement pas la peine.

Mais je serais ravi de voir une expérience! Vous pouvez le prendre lambdaet l'implémenter avec votre structure de données (OCaml a des pointeurs, ils sont appelés références ). Plus ou moins, il vous suffit de remplacer syntax.mlet norm.mlavec vos versions. C'est moins de 150 lignes de code.

Andrej Bauer
la source
Merci! J'avoue que je ne pensais pas vraiment aux implémentations, mais principalement à la possibilité de faire des preuves mathématiques sans se soucier de la comptabilité de Bruijn ou du changement de nom alpha. Mais y a-t-il une chance qu'une implémentation puisse conserver un certain partage de mémoire en ne faisant pas de copies "tant que cela n'est pas nécessaire", c'est-à-dire jusqu'à ce que les copies s'écartent les unes des autres?
Mike Shulman
β(λX.e1)e2e1e2
2
En ce qui concerne les preuves mathématiques, j'ai maintenant traversé une bonne partie de la formalisation de la syntaxe de la théorie des types, mon expérience est que les avantages sont obtenus lorsque nous généralisons la configuration et la rendons plus abstraite, pas quand nous la rendons plus concrète. Par exemple, nous pouvons paramétrer la syntaxe avec "toute bonne façon de traiter la liaison". Lorsque nous le faisons, il est plus difficile de faire des erreurs. J'ai également formalisé la théorie des types avec des indices de Bruijn. Ce n'est pas trop terrible, surtout si vous avez des types dépendants qui vous empêchent de faire des choses absurdes.
Andrej Bauer
2
Pour ajouter, j'ai travaillé sur une implémentation qui utilise essentiellement cette technique (mais avec des entiers et des cartes uniques, pas des pointeurs), et je ne le recommanderais pas vraiment. Nous avons certainement eu beaucoup de bugs où nous avons raté le clonage correctement (en grande partie en essayant de l'éviter lorsque cela était possible). Mais je pense qu'il y a un article de certains gens du GHC où ils le préconisent (ils ont utilisé une fonction de hachage pour générer les noms uniques, je crois). Cela peut dépendre de ce que vous faites exactement. Dans mon cas, c'était une inférence de type / vérification, et cela semble plutôt mal adapté.
Dan Doel
@MikeShulman Pour les algorithmes de complexité (élémentaire) raisonnable (dans une large mesure, la copie et l'effacement), la soi-disant «partie abstraite» de la réduction optimale de Lamping ne fait pas de copies tant que cela n'est pas nécessaire. La partie abstraite est également la partie non controversée par opposition à l'algorithme complet qui nécessite quelques annotations qui peuvent dominer le calcul.
Łukasz Lew
5

D'autres réponses traitent principalement des problèmes de mise en œuvre. Puisque vous mentionnez votre principale motivation comme faire des preuves mathématiques sans trop de comptabilité, voici le principal problème que je vois avec cela.

Lorsque vous dites "une fonction qui mappe chaque variable à un classeur dans la portée duquel elle se trouve": le type de sortie de cette fonction est sûrement un peu plus subtil que cela la fait sonner! Plus précisément, la fonction doit prendre des valeurs dans quelque chose comme «les liants du terme considéré» - c'est-à-dire un ensemble qui varie en fonction du terme (et n'est évidemment pas un sous-ensemble d'un ensemble ambiant plus grand de quelque manière utile). Donc, en substitution, vous ne pouvez pas simplement "prendre l'union des fonctions de liaison": vous devez également réindexer leurs valeurs, selon une correspondance entre les liants dans les termes d'origine et les liants dans le résultat de la substitution.

Ces réindexations devraient sûrement être «routinières», dans le sens où elles pourraient être raisonnablement soit balayées sous le tapis, soit bien emballées en termes de fonction ou de naturalité. Mais il en va de même pour la comptabilité impliquée dans le travail avec les variables nommées. Donc, dans l'ensemble, il me semble probable qu'il y aurait au moins autant de comptabilité impliquée dans cette approche qu'avec des approches plus standard.

Ceci mis à part, cependant, c'est une approche conceptuellement très attrayante, et j'aimerais que cela soit soigneusement élaboré - je peux bien imaginer que cela pourrait jeter un éclairage différent sur certains aspects de la syntaxe que les approches standard.

PLL
la source
garder une trace de la portée de chaque variable nécessite en effet une comptabilité, mais ne sautez pas à la conclusion qu'il faut toujours se limiter à une syntaxe bien délimitée! Des opérations telles que la substitution et la réduction bêta peuvent être définies même en termes mal définis, et je soupçonne que si l'on voulait formaliser cette approche (qui est encore une fois vraiment l'approche des réseaux de preuves / "λ-graphs") dans un assistant de preuve, on mettrait d'abord en œuvre les opérations plus générales, puis prouverait qu'elles conservent la propriété d'être bien cadrées.
Noam Zeilberger
(Je suis d'accord que cela vaut la peine d'essayer ... bien que je ne serais pas surpris si quelqu'un l'a déjà dans le contexte de la formalisation des réseaux de preuves / graphiques λ.)
Noam Zeilberger
5

λLazy.t

Dans l'ensemble, je pense que c'est une représentation sympa, mais cela implique une comptabilité avec des pointeurs, pour éviter de rompre les liens de liaison. Il serait possible de changer le code pour utiliser des champs mutables je suppose, mais l'encodage en Coq serait alors moins direct. Je suis toujours convaincu que cela est très similaire à HOAS, bien que la structure du pointeur soit explicite. Cependant, la présence de Lazy.timplique qu'il est possible qu'un code soit évalué au mauvais moment. Ce n'est pas le cas dans mon code car seule la substitution d'une variable par une variable peut se produire à la forcefois (et pas d'évaluation par exemple).

(* Representation of a term of the λ-calculus. *)
type term =
  | FVar of string      (* Free variable  *)
  | BVar of bvar        (* Bound variable *)
  | Appl of term * term (* Application    *)
  | Abst of abst        (* Abstraction    *)

(* A bound variable is a pointer to the corresponding binder. *)
and bvar = abst

(* A binder is represented as its body in which the bound variable points to
   the binder itself. Note that we need to use a thunk to be able to work
   underneath a binder (for substitution, evaluation, ...). A name can be
   given for easy printing, but no renaming is done. Only “visual capture”
   can happen since pointers are established the right way, even if names
   can clash. *)
and abst = { body : term Lazy.t ; name : string }

(* Terms can be built with recursive values for abstractions. *)

(* Krivine's notation is used for application (function in parentheses). *)

let id    : term = (* λx.x        *)
  Abst(let rec id = {body = lazy (BVar(id)); name = "x"} in id)

let idid  : term = (* (λx.x) λx.x *)
  Appl(id, id)

let delta : term = (* λx.(x) x *)
  Abst(let rec d = {body = lazy (Appl(BVar(d), BVar(d))); name = "x" } in d)

let weird : term = (* (λx.x) λy.(λx.(x) x) (C) y *)
  Appl(id, Abst(let rec x = {body = lazy (Appl(delta, Appl(FVar("C"),
    BVar(x)))); name = "y"} in x))

let omega : term = (* (λx.(x) x) λx.(x) x *)
  Appl(delta, delta)

(* Printing function is immediate. *)
let rec print : out_channel -> term -> unit = fun oc t ->
  match t with
  | FVar(x)   -> output_string oc x
  | BVar(x)   -> output_string oc x.name
  | Appl(t,u) -> Printf.fprintf oc "(%a) %a" print t print u
  | Abst(f)   -> Printf.fprintf oc "λ%s.%a" f.name print (Lazy.force f.body)

(* Substitution of variable [x] by [v] in the term [t]. Occurences of [x] in
   [t] are identified using physical equality ([BVar] case). The subtle case
   is [Abst], because we need to reestablish the physical link between the
   binder and the variable it binds. *)
let rec subst_var : bvar -> term -> term -> term = fun x t v ->
  match t with
  | FVar(_)   -> t
  | BVar(y)   -> if y == x then v else t
  | Appl(t,u) -> Appl(subst_var x t v, subst_var x u v)
  | Abst(f)   ->
      (* First compute the new body. *)
      let fv = subst_var x (Lazy.force f.body) v in
      (* Reestablish the physical link, using [subst_var] itself again. This
         requires a second traversal of the term. We could probably do both
         at once, but who cares the complexity is linear in [t] anyway. *)
      Abst(let rec g = {f with body = lazy (subst_var f fv (BVar(g)))} in g)

(* Actual substitution function. *)
let subst : abst -> term -> term = fun f v ->
  subst_var f (Lazy.force f.body) v

(* Normalization function (all the way, even under binders). *)
let rec eval : term -> term = fun t ->
  match t with
  | Appl(t,u) ->
      begin
        let v = eval u in
        match eval t with
        | Abst(f) -> eval (subst f v)
        | t       -> Appl(t,v)
      end
  | Abst(f)   ->
      (* Actual computation in the body. *)
      let fv = eval (Lazy.force f.body) in
      (* Here, the physical link is reestablished, but it is important to note
         that the computation of evaluation is done above. So the part below
         only takes a linear time in the size of the normal form of the body
         of the abstraction. *)
      Abst(let rec g = {f with body = lazy (subst_var f fv (BVar(g)))} in g)
  | _         ->
      t

let _ = Printf.printf "id         = %a\n%!" print id
let _ = Printf.printf "eval id    = %a\n%!" print (eval id)

let _ = Printf.printf "idid       = %a\n%!" print idid
let _ = Printf.printf "eval idid  = %a\n%!" print (eval idid)

let _ = Printf.printf "delta      = %a\n%!" print delta
let _ = Printf.printf "eval delta = %a\n%!" print (eval delta)

let _ = Printf.printf "omega      = %a\n%!" print omega
(* The following obviously loops. *)
(*let _ = Printf.printf "eval omega = %a\n%!" print (eval omega)*)

let _ = Printf.printf "weird      = %a\n%!" print weird
let _ = Printf.printf "eval weird = %a\n%!" print (eval weird)

(* Output produced:
id         = λx.x
eval id    = λx.x
idid       = (λx.x) λx.x
eval idid  = λx.x
delta      = λx.(x) x
eval delta = λx.(x) x
omega      = (λx.(x) x) λx.(x) x
weird      = (λx.x) λy.(λx.(x) x) (C) y
eval weird = λy.((C) y) (C) y
*)
Rodolphe Lepigre
la source