Qu'est-ce qu'une explication brève mais complète d'un système de type pur / dépendant?

32

Si quelque chose est simple, alors il devrait être complètement explicable en quelques mots. Cela peut être fait pour le λ-calcul:

Le λ-calcul est une grammaire syntaxique (fondamentalement, une structure) avec une règle de réduction (ce qui signifie qu'une procédure de recherche / remplacement est appliquée à plusieurs reprises à chaque occurrence d'un modèle spécifique jusqu'à ce qu'il n'existe pas de modèle de ce type).

Grammaire:

Term = (Term Term) | (λ Var . Term) | Var

Règle de réduction:

((λ var body) term) -> SUBS(body,var,term)
    where `SUBS` replaces all occurrences of `var`
    by `term` in `body`, avoiding name capture.

Exemples:

(λ a . a)                             -> (λ a a)
((λ a . (λ b . (b a))) (λ x . x))     -> (λ b . (b (λ x x)))
((λ a . (a a)) (λ x . x))             -> (λ x . x)
((λ a . (λ b . ((b a) a))) (λ x . x)) -> (λ b . ((b (λ x . x)) (λ x . x)))
((λ x . (x x)) (λ x . (x x)))         -> never halts

Bien que quelque peu informel, on pourrait dire que c'est suffisamment informatif pour qu'un humain normal comprenne le λ-calcul dans son ensemble - et cela prend 22 lignes de démarque. J'essaie de comprendre les systèmes de type purs / dépendants utilisés par Idris / Agda et des projets similaires, mais l'explication plus brève que j'ai pu trouver était Simply Easy - un excellent article, mais qui semble supposer beaucoup de connaissances antérieures (Haskell, inductive définitions) que je n'ai pas. Je pense que quelque chose de plus bref, de moins riche pourrait éliminer certains de ces obstacles. Ainsi,

Est-il possible de donner une explication brève et complète des systèmes de type pur / dépendant, dans le même format que j'ai présenté le λ-calcul ci-dessus?

MaiaVictor
la source
4
Les règles de Pure Type Systems sont très brèves. Simply Easy consiste à implémenter des types dépendants.
2
Ce n'est donc pas "hostile" au sens de l'offensive, mais au sens où vous pensez que j'exige beaucoup pour ne pas faire assez d'efforts pour trouver la réponse par moi-même? Si tel est le cas, je conviens que cette question pourrait être très exigeante, alors peut-être qu'elle est tout simplement mauvaise. Mais il y a aussi beaucoup d'efforts derrière, pensez-vous que je devrais éditer dans mes tentatives?
MaiaVictor
3
Je suis offensé, aussi, au nom de mes co-auteurs qui ont écrit le texte de "A Tutorial Implementation of a Dependently Typed Lambda Calculus", qui a remplacé "Simply Easy" comme titre de travail. J'ai écrit le noyau du code, qui est un vérificateur de type dans <100 lignes de Haskell.
2
Alors je me suis certainement mal exprimé. J'adore le papier "Simply Easy" et je le lis à chaque pause depuis il y a quelques jours - c'est la seule chose au monde qui m'a donné une sensation partielle je commence à comprendre le sujet (et je crois que j'ai essayé) . Mais je pense que cela s'adresse à un public avec plus de connaissances que moi, et cela pourrait être la raison pour laquelle j'ai encore du mal à en faire partie. Rien à voir avec la qualité du papier, mais mes propres limites.
MaiaVictor
1
@pigworker et le code est ma partie préférée, exactement parce que (par rapport à l'explication en anglais) est une explication beaucoup plus courte, mais complète, de l'ensemble, comme je l'ai demandé ici. Avez-vous une copie du code que je peux télécharger?
MaiaVictor

Réponses:

7

Avertissement

C'est très informel, comme vous l'avez demandé.

La grammaire

Dans un langage typé de manière dépendante, nous avons un classeur au niveau du type ainsi qu'au niveau de la valeur:

Term = * | (∀ (Var : Term). Term) | (Term Term) | (λ Var. Term) | Var

Un terme bien typé est un terme avec un type attaché, nous écrirons t ∈ σou

σ
t

pour indiquer que le terme ta un type σ.

Règles de frappe

Par souci de simplicité, nous exigeons que dans les λ v. t ∈ ∀ (v : σ). τdeux λet lier la même variable ( vdans ce cas).

Règles:

t ∈ σ is well-formed if σ ∈ * and t is in normal form (0)

*            ∈ *                                                 (1)
∀ (v : σ). τ ∈ *             -: σ ∈ *, τ ∈ *                     (2)
λ v. t       ∈ ∀ (v : σ). τ  -: t ∈ τ                            (3)
f x          ∈ SUBS(τ, v, x) -: f ∈ ∀ (v : σ). τ, x ∈ σ          (4)
v            ∈ σ             -: v was introduced by ∀ (v : σ). τ (5)

Ainsi, *est "le type de tous les types" (1), forme les types à partir des types (2), les abstractions lambda ont des pi-types (3) et si vest introduit par ∀ (v : σ). τ, alors va le type σ(5).

"sous forme normale" signifie que nous effectuons autant de réductions que possible en utilisant la règle de réduction:

"La" règle de réduction

(λ v. b ∈ ∀ (v : σ). τ) (t ∈ σ) ~> SUBS(b, v, t) ∈ SUBS(τ, v, t)
    where `SUBS` replaces all occurrences of `v`
    by `t` in `τ` and `b`, avoiding name capture.

Ou en syntaxe bidimensionnelle où

σ
t

signifie t ∈ σ:

(∀ (v : σ). τ) σ    SUBS(τ, v, t)
                 ~>
(λ  v     . b) t    SUBS(b, v, t)

Il n'est possible d'appliquer une abstraction lambda à un terme que lorsque le terme a le même type que la variable dans le quantificateur forall associé. Ensuite, nous réduisons à la fois l'abstraction lambda et le quantificateur forall de la même manière que dans le calcul lambda pur auparavant. Après avoir soustrait la partie du niveau de valeur, nous obtenons la règle de typage (4).

Un exemple

Voici l'opérateur d'application de fonction:

∀ (A : *) (B : A -> *) (f : ∀ (y : A). B y) (x : A). B x
λ  A       B            f                    x     . f x

(nous abrégeons ∀ (x : σ). τà σ -> τsi τne mentionne pas x)

frenvoie B ypour tout ytype fourni A. Nous appliquons fà x, qui est du type droit Aet substituer yà xla suite ., donc f x ∈ SUBS(B y, y, x)~> f x ∈ B x.

Abrévions maintenant l'opérateur d'application de fonction as appet appliquons-le à lui-même:

∀ (A : *) (B : A -> *). ?
λ  A       B          . app ? ? (app A B)

Je place ?pour les termes que nous devons fournir. D'abord, nous introduisons et instancions explicitement Aet B:

∀ (f : ∀ (y : A). B y) (x : A). B x
app A B

Maintenant, nous devons unifier ce que nous avons

∀ (f : ∀ (y : A). B y) (x : A). B x

ce qui est le même que

(∀ (y : A). B y) -> ∀ (x : A). B x

et ce qui app ? ?reçoit

∀ (x : A'). B' x

Il en résulte

A' ~ ∀ (y : A). B y
B' ~ λ _. ∀ (x : A). B x -- B' ignores its argument

(voir aussi Qu'est-ce que la prédicativité? )

Notre expression (après quelques renommage) devient

∀ (A : *) (B : A -> *). ?
λ  A       B          . app (∀ (x : A). B x) (λ _. ∀ (x : A). B x) (app A B)

Depuis pour tout A, Bet f(où f ∈ ∀ (y : A). B y)

∀ (y : A). B y
app A B f

nous pouvons instancier Aet Bobtenir (pour tout ftype approprié)

∀ (y : ∀ (x : A). B x). ∀ (x : A). B x
app (∀ (x : A). B x) (λ _. ∀ (x : A). B x) f

et la signature de type est équivalente à (∀ (x : A). B x) -> ∀ (x : A). B x.

L'expression entière est

∀ (A : *) (B : A -> *). (∀ (x : A). B x) -> ∀ (x : A). B x
λ  A       B          . app (∀ (x : A). B x) (λ _. ∀ (x : A). B x) (app A B)

C'est à dire

∀ (A : *) (B : A -> *) (f : ∀ (x : A). B x) (x : A). B x
λ  A       B            f                    x     .
    app (∀ (x : A). B x) (λ _. ∀ (x : A). B x) (app A B) f x

qui, après toutes les réductions au niveau de la valeur, donne le même appretour.

Ainsi , alors qu'il ne nécessite que quelques étapes du calcul pur lambda pour obtenir appde app app, dans un cadre typé (et surtout un dactylographiées dépendamment) nous avons aussi besoin de se soucier de l' unification et les choses deviennent encore plus complexes avec une certaine commodité inconsitent ( * ∈ *).

Vérification de type

  • Si test *alors t ∈ *par (1)
  • Si test ∀ (x : σ) τ, σ ∈? *, τ ∈? *(voir la note sur ∈?ci - dessous) puis t ∈ *par (2)
  • Si test f x, f ∈ ∀ (v : σ) τpour certains σet τ, x ∈? σalors t ∈ SUBS(τ, v, x)par (4)
  • Si test une variable v, a vété introduit d'ici ∀ (v : σ). τt ∈ σpar (5)

Ce sont toutes des règles d'inférence, mais nous ne pouvons pas faire de même pour les lambdas (l'inférence de type est indécidable pour les types dépendants). Donc, pour les lambdas, nous vérifions ( t ∈? σ) plutôt que de déduire:

  • Si test λ v. bet vérifié contre ∀ (v : σ) τ, b ∈? τpuist ∈ ∀ (v : σ) τ
  • Si tc'est autre chose et vérifié, σdéduisez le type d' tutilisation de la fonction ci-dessus et vérifiez si elle estσ

La vérification de l'égalité pour les types nécessite qu'ils soient sous des formes normales, donc pour décider si ta du type, σnous vérifions d'abord qu'il σa du type *. Si c'est le cas, alors il σest normalisable (paradoxe de Modulo Girard) et il se normalise (donc σdevient bien formé par (0)). SUBSnormalise également les expressions pour conserver (0).

C'est ce qu'on appelle la vérification de type bidirectionnelle. Avec lui, nous n'avons pas besoin d'annoter chaque lambda avec un type: si f xle type de fest connu, il xest fcomparé au type d'argument reçu au lieu d'être déduit et comparé pour l'égalité (ce qui est également moins efficace). Mais s'il fs'agit d'un lambda, il nécessite une annotation de type explicite (les annotations sont omises dans la grammaire et partout, vous pouvez soit ajouter Ann Term Termsoit λ' (σ : Term) (v : Var)aux constructeurs).

Jetez également un œil à Simpler, Easier! blogpost.

user3237465
la source
1
Appui «plus simple, plus facile».
La première règle de réduction sur forall semble bizarre. Contrairement aux lambdas, les foralls ne doivent pas être appliquées de manière bien typée (non?).
@chi, je ne comprends pas ce que tu dis. Peut-être que ma notation est mauvaise: la règle de réduction dit (λ v. b ∈ ∀ (v : σ). τ) (t ∈ σ)~> SUBS(b, v, t) ∈ SUBS(τ, v, t).
user3237465
1
Je trouve la notation trompeuse. Il semble que vous ayez deux règles: l'une pour le non-sens et (∀ (v : σ). τ) t ~> ...l'autre pour le sens (λ v. b) t ~> .... Je supprimerais le premier et le transformerais en commentaire ci-dessous.
1
La règle (1) contient sa conclusion comme prémisse. Vous ne pouvez comparer la simplicité de votre système à la version bidirectionnelle qu'une fois que vous avez un système qui fonctionne. Vous pouvez dire que vous gardez tout normalisé, mais vos règles ne le font pas.
24

Allons-y. Je ne m'occuperai pas du paradoxe de Girard, car il détourne l'attention des idées centrales. Je vais devoir introduire quelques mécanismes de présentation sur les jugements et les dérivations et autres.

Grammaire

Terme :: = (Elim) | * | (Var: Terme) → Terme | λVar↦Term

Elim :: = Terme: Terme | Var | Elim Term

La grammaire a deux formes mutuellement définies, les "termes" qui sont la notion générale de chose (les types sont des choses, les valeurs sont des choses), y compris * (le type de types), les types de fonction dépendants et les abstractions lambda, mais aussi l'intégration " éliminations "(c'est-à-dire" usages "plutôt que" constructions "), qui sont des applications imbriquées où la chose finalement en position de fonction est soit une variable soit un terme annoté de son type.

Règles de réduction

(λy↦t: (x: S) → T) s ↝ t [s: S / y]: T [s: S / x]

(t: T) ↝ t

L'opération de substitution t [e / x] remplace chaque occurrence de la variable x par l'élimination e, évitant la capture de nom. Pour former une application qui peut réduire, un terme lambda doit être annoté par son type pour faire une élimination . L'annotation de type donne à l'abstraction lambda une sorte de "réactivité", permettant à l'application de se poursuivre. Une fois que nous avons atteint le point où il n'y a plus d'application et que le t: T actif est de nouveau incorporé dans la syntaxe du terme, nous pouvons supprimer l'annotation de type.

Étendons la relation de réduction by par la fermeture structurelle: les règles s'appliquent partout dans les termes et les éliminations que vous pouvez trouver quelque chose correspondant au côté gauche. Écrivez ↝ * pour la fermeture réflexive-transitive (0 ou plus étape) de ↝. Le système de réduction qui en résulte est confluent en ce sens:

Si s ↝ * p et s ↝ * q, alors il existe des r tels que p ↝ * r et q ↝ * r.

Contextes

Contexte :: = | Contexte, Var: terme

Les contextes sont des séquences qui affectent des types à des variables, croissant à droite, que nous considérons comme l'extrémité "locale", nous indiquant les variables liées les plus récentes. Une propriété importante des contextes est qu'il est toujours possible de choisir une variable qui n'est pas déjà utilisée dans le contexte. Nous maintenons l'invariant que les variables attribuées aux types dans le contexte sont distinctes.

Jugements

Jugement :: = Contexte ⊢ Le terme a un terme | Contexte ⊢ Elim est un terme

C'est la grammaire des jugements, mais comment les lire? Pour commencer, ⊢ est le symbole traditionnel du «tourniquet», séparant les hypothèses des conclusions: vous pouvez le lire de manière informelle comme «dit».

G ⊢ T a t

signifie que dans un contexte G donné, le type T admet le terme t;

G ⊢ e est S

signifie que compte tenu du contexte G, l'élimination e est donnée de type S.

Les jugements ont une structure intéressante: zéro ou plusieurs entrées , un ou plusieurs sujets , zéro ou plusieurs sorties .

INPUTS                   SUBJECT        OUTPUTS
Context |- Term   has    Term
Context |-               Elim      is   Term

Autrement dit, nous devons proposer les types de termes à l'avance et simplement les vérifier , mais nous synthétisons les types d'éliminations.

Règles de frappe

Je les présente dans un style vaguement prologue, en écrivant J -: P1; ...; Pn pour indiquer que le jugement J est valable si les locaux P1 à Pn le sont également. Une prémisse sera un autre jugement ou une demande de réduction.

termes

G ⊢ T a t -: T ↝ R; G ⊢ R a t

G ⊢ * a *

G ⊢ * a (x: S) → T -: G ⊢ * a S; G, z: S! - * a T [z / x]

G ⊢ (x: S) → T a λy↦t -: G, z: S ⊢ T [z / x] a t [z / y]

G ⊢ T a (e) -: G ⊢ e est T

Éliminations

G ⊢ e est R -: G ⊢ e est S; S ↝ R

G, x: S, G '⊢ x est S

G ⊢ fs est T [s: S / x] -: G ⊢ f est (x: S) → T; G ⊢ S a s

Et c'est tout!

Seules deux règles ne sont pas axées sur la syntaxe: la règle qui dit "vous pouvez réduire un type avant de l'utiliser pour vérifier un terme" et la règle qui dit "vous pouvez réduire un type après l'avoir synthétisé à partir d'une élimination". Une stratégie viable consiste à réduire les types jusqu'à ce que vous ayez exposé le constructeur le plus haut.

Ce système n'est pas fortement normalisant (à cause du paradoxe de Girard, qui est un paradoxe d'auto-référence de type menteur), mais il peut être fortement normalisé en divisant * en "niveaux universels" où toutes les valeurs qui impliquent des types à des niveaux inférieurs eux-mêmes avoir des types à des niveaux plus élevés, empêchant l'auto-référence.

Ce système a cependant la propriété de la conservation de type, en ce sens.

Si G ⊢ T a t et G ↝ * D et T ↝ * R et t ↝ r, alors D ⊢ R a r.

Si G ⊢ e est S et G ↝ * D et e ↝ f, alors il existe R tel que S ↝ * R et D ⊢ f est R.

Les contextes peuvent calculer en permettant aux termes qu'ils contiennent de calculer. Autrement dit, si un jugement est valide maintenant, vous pouvez calculer ses entrées autant que vous le souhaitez et son sujet en une étape, puis il sera possible de calculer ses sorties d'une manière ou d'une autre pour vous assurer que le jugement résultant reste valide. La preuve est une simple induction sur les dérivations de typage, étant donné la confluence de -> *.

Bien sûr, je n'ai présenté ici que le noyau fonctionnel, mais les extensions peuvent être assez modulaires. Voici des paires.

Terme :: = ... | (x: S) * T | s, t

Elim :: = ... | e.head | e.tail

(s, t: (x: S) * T) .head ↝ s: S

(s, t: (x: S) * T) .tail ↝ t: T [s: S / x]

G ⊢ * a (x: S) * T -: G ⊢ * a S; G, z: S ⊢ * a T [z / x]

G ⊢ (x: S) * T a s, t -: G ⊢ S a s; G ⊢ T [s: S / x] a t

G ⊢ e.head est S -: G ⊢ e est (x: S) * T

G ⊢ e.tail est T [e.head / x] -: G ⊢ e est (x: S) * T

travailleur du porc
la source
1
G, x:S, G' ⊢ x is S -: G' ⊬ x?
user3237465
1
@ user3237465 Non. Merci! Fixé. (Lorsque je remplaçais les tourniquets ascii art par des tourniquets html (les rendant ainsi invisibles sur mon téléphone; désolé si cela se produit ailleurs), j'ai raté celui-ci.)
1
Oh, je pensais que tu faisais juste remarquer la faute de frappe. La règle dit que, pour chaque variable du contexte, nous synthétisons le type que le contexte lui attribue. Lors de l'introduction des contextes, j'ai dit "Nous maintenons l'invariant que les variables attribuées aux types dans le contexte sont distinctes." si l'observation est interdite. Vous verrez que chaque fois que les règles étendent le contexte, elles choisissent toujours un "z" frais qui instancie les classeurs sous lesquels nous marchons. L'observation est un anathème. Si vous avez le contexte x: *, x: x alors le type du x plus local n'est plus correct car c'est le x hors de portée.
1
Je voulais juste que vous et les autres répondeurs sachiez que je reviens à ce sujet à chaque pause du travail. Je veux vraiment apprendre cela, et pour le premier ime, je suis tombé comme si j'en obtenais la plupart. La prochaine étape consistera à mettre en œuvre et à écrire quelques programmes. Je suis ravi de pouvoir vivre à une époque où des informations sur des sujets aussi merveilleux sont disponibles à travers le monde pour quelqu'un comme moi, et tout cela grâce à des génies comme vous qui consacrent un certain temps de leur vie à diffuser ces connaissances, pour gratuit, sur internet. Désolé encore d'avoir mal formulé ma question, et merci!
MaiaVictor
1
@cody Oui, il n'y a pas d'extension. Pour voir pourquoi ce n'est pas nécessaire, notez que les deux règles de calcul vous permettent de déployer la stratégie où vous normalisez complètement les types avant de vérifier les termes, et vous normalisez également les types immédiatement après les avoir synthétisés à partir des éliminations. Donc dans la règle où les types doivent correspondre, ils sont déjà normalisés, donc égaux au nez si les types "originaux" vérifiés et synthétisés étaient convertibles. Pendant ce temps, restreindre les contrôles d'égalité à cet endroit est correct à cause de ce fait: si T est convertible en type canonique, il se réduit en type canonique.
pigworker
8

La correspondance de Curry-Howard dit qu'il existe une correspondance systématique entre les systèmes de types et les systèmes de preuve en logique. En prenant une vue centrée sur le programmeur de cela, vous pouvez le refondre de cette façon:

  • Les systèmes à preuve logique sont des langages de programmation.
  • Ces langues sont typées statiquement.
  • La responsabilité du système de type dans un tel langage est d'interdire les programmes qui construiraient des preuves non fiables.

Vu sous cet angle:

  • Le calcul lambda non typé que vous résumez n'a pas de système de type significatif, donc un système de preuve construit sur lui serait malsain.
  • Le lambda calcul simplement tapé est un langage de programmation qui a tous les types nécessaires pour construire des preuves sonores en logique sententielle ("si / alors", "et", "ou", "pas"). Mais ses types ne sont pas assez bons pour vérifier les preuves qui impliquent des quantificateurs ("pour tous les x, ..."; "il existe un x tel que ...").
  • Le calcul lambda de type dépendant a des types et des règles qui prennent en charge la logique sententielle et les quantificateurs de premier ordre (quantification sur les valeurs).

Voici les règles de déduction naturelle pour la logique du premier ordre, en utilisant un diagramme de l'entrée Wikipedia sur la déduction naturelle . Ce sont également les règles d'un calcul lambda minimal typé de manière dépendante.

Déduction naturelle de premier ordre

Notez que les règles contiennent des termes lambda. Ceux-ci peuvent être lus comme les programmes qui construisent les preuves des phrases représentées par leurs types (ou plus succinctement, nous disons simplement que les programmes sont des preuves ). Des règles de réduction similaires que vous donnez peuvent être appliquées à ces termes lambda.


Pourquoi nous soucions-nous de cela? Eh bien, tout d'abord, parce que les preuves peuvent s'avérer être un outil utile dans la programmation, et avoir un langage qui peut fonctionner avec des preuves en tant qu'objets de première classe ouvre de nombreuses voies. Par exemple, si votre fonction a une condition préalable, au lieu de l'écrire en tant que commentaire, vous pouvez en demander une preuve en tant qu'argument.

Deuxièmement, parce que la machinerie du système de type nécessaire pour gérer les quantificateurs peut avoir d'autres utilisations dans un contexte de programmation. En particulier, les langages typés de manière dépendante gèrent les quantificateurs universels ("pour tous les x, ...") en utilisant un concept appelé types de fonction dépendants - une fonction où le type statique du résultat peut dépendre de la valeur d'exécution de l'argument.

Pour donner une application très piétonne à cela, j'écris du code tout le temps qui doit lire les fichiers Avro qui se composent d'enregistrements avec une structure uniforme - tous partagent le même ensemble de noms et de types de champs. Cela m'oblige à:

  1. Codez en dur la structure des enregistrements du programme en tant que type d'enregistrement.
    • Avantages: Le code est plus simple et le compilateur peut détecter des erreurs dans mon code
    • Inconvénient: le programme est codé en dur pour lire les fichiers qui correspondent au type d'enregistrement.
  2. Lire le schéma des données au moment de l'exécution, le représenter de manière générique comme une structure de données et l'utiliser pour traiter les enregistrements de manière générique
    • Avantages: mon programme n'est pas codé en dur avec un seul type de fichier
    • Inconvénients: le compilateur ne peut pas détecter autant d'erreurs.

Comme vous pouvez le voir sur la page du didacticiel Avro Java , ils vous montrent comment utiliser la bibliothèque selon ces deux approches.

Avec les types de fonctions dépendants, vous pouvez avoir votre gâteau et le manger, au prix d'un système de type plus complexe. Vous pouvez écrire une fonction qui lit un fichier Avro, extrait le schéma et renvoie le contenu du fichier sous la forme d'un flux d'enregistrements dont le type statique dépend du schéma stocké dans le fichier . Le compilateur serait en mesure de détecter les erreurs lorsque, par exemple, j'ai essayé d'accéder à un champ nommé qui pourrait ne pas exister dans les enregistrements des fichiers qu'il traitera lors de l'exécution. Doux, hein?

sacundim
la source
1
Construire des types à l'exécution de la manière que vous avez mentionnée est quelque chose de vraiment cool auquel je n'ai pas pensé. Plutôt doux, en effet! Merci pour la réponse perspicace.
MaiaVictor