Representando Variáveis ​​Vinculadas com uma Função de Usos a Ligadores

11

O problema de representar variáveis ​​ligadas na sintaxe, e em particular o de substituição para evitar capturas, é bem conhecido e tem várias soluções: variáveis ​​nomeadas com equivalência alfa, índices de Bruijn, localmente sem nome, conjuntos nominais etc.

Mas parece haver outra abordagem bastante óbvia, que eu ainda não vi usada em nenhum lugar. Nomeadamente, na sintaxe básica, temos apenas um termo "variável", escrito digamos , e então, separadamente, fornecemos uma função que mapeia cada variável para um fichário em cujo escopo está. Então, um -term comoλλ

λx.(λy.xy)

seria escrito , e a função mapeará o primeiro para o primeiro e o segundo para o segundo . Portanto, é como os índices de Bruijn, apenas em vez de ter que "contar s" enquanto você sai do termo para encontrar o fichário correspondente, basta avaliar uma função. (Se representar isso como uma estrutura de dados em uma implementação, eu pensaria em equipar cada objeto de termo variável com um simples ponteiro / referência ao objeto de termo vinculativo correspondente.)λ λ λλ.(λ.)λλλ

Obviamente, isso não é sensato para escrever sintaxe em uma página para humanos lerem, mas também não são os índices de De Bruijn. Parece-me que faz todo o sentido matematicamente e, em particular, facilita muito a substituição que evita a captura: apenas solte o termo que você está substituindo e adote a união das funções de ligação. É verdade que ele não tem noção de "variável livre", mas então (novamente) nem os índices de Bruijn realmente; nos dois casos, um termo contendo variáveis ​​livres é representado com uma lista de ligantes de "contexto" à frente.

Estou faltando alguma coisa e há algum motivo para essa representação não funcionar? Existem problemas que o tornam muito pior do que os outros que não vale a pena considerar? (O único problema em que posso pensar agora é que o conjunto de termos (junto com suas funções vinculativas) não é definido indutivamente, mas isso não parece intransponível.) Ou, na verdade, existem lugares onde foi usado?

Mike Shulman
fonte
2
Eu não sei sobre desvantagens. Talvez a formalização (por exemplo, em um assistente de prova) seja mais pesada? Não tenho certeza ... O que sei é que não há nada errado tecnicamente: essa maneira de ver os termos lambda é a sugerida por sua representação como redes de prova, para que pessoas com consciência de prova da rede (como eu) o usem implicitamente o tempo todo. Mas as pessoas cientes da rede de provas são muito raras :-) Então talvez seja realmente uma questão de tradição. PS: Adicionei algumas tags pouco relacionadas para tornar a pergunta mais visível (espero).
Damiano Mazza
Essa abordagem não é equivalente à sintaxe abstrata de ordem superior (isto é, representa os binders como funções na linguagem host)? Em certo sentido, o uso de uma função como fichário estabelece ponteiros para fichários implicitamente, na representação de fechamentos.
Rodolphe Lepigre
2
@RodolpheLepigre Acho que não. Em particular, meu entendimento é que o HOAS só está correto quando a metateoria é bastante fraca, enquanto essa abordagem é correta em uma metateoria arbitrária.
Mike Shulman
3
Certo, então cada fichário usa um nome de variável exclusivo (dentro da árvore) (o ponteiro para ele é um automaticamente). Esta é a convenção de Barendregt. Mas quando você substitui, você deve reconstruir (em C) o que você substitui para continuar a ter nomes exclusivos. Caso contrário (em geral), você está usando os mesmos ponteiros para várias subárvores e pode obter captura variável. A reconstrução é renomeada alfa. Presumivelmente, algo semelhante acontece dependendo das especificidades de sua codificação de árvores como conjuntos?
Dan Doel
3
@ DanDoel Ah, interessante. Eu pensei que era tão óbvio que não precisava ser mencionado que você colocaria uma cópia separada do termo sendo substituído a cada ocorrência da variável que está sendo substituída; caso contrário, você não teria mais uma árvore de sintaxe ! Não me ocorreu pensar nessa cópia como renomeação alfa, mas agora que você apontou, eu posso ver.
Mike Shulman

Respostas:

11

As respostas de Andrej e Łukasz são boas, mas eu queria acrescentar comentários adicionais.

Para ecoar o que Damiano disse, essa maneira de representar a encadernação usando ponteiros é a sugerida pelas redes de prova, mas o primeiro local em que eu a vi para termos lambda foi em um antigo ensaio de Knuth:

  • Donald Knuth (1970). Exemplos de semântica formal. No Simpósio de Semântica de Línguas Algorítmicas , E. Engeler (ed.), Lecture Notes in Mathematics 188, Springer.

Na página 234, ele desenhou o diagrama a seguir (que ele chamou de "estrutura da informação") representando o termo :(λy.λz.yz)x

Diagrama de Knuth para $ (\ lambda y. \ Lambda z.yz) x $

Esse tipo de representação gráfica dos termos lambda também foi estudado de forma independente (e mais profunda) em duas teses no início dos anos 1970, tanto por Christopher Wadsworth (1971, Semântica e Pragmática do Lambda-Calculus ) quanto por Richard Statman (1974, Structural Complexity). Provas ). Atualmente, esses diagramas são frequentemente referidos como "gráficos λ" (veja, por exemplo, este artigo ).

Observe que o termo no diagrama de Knuth é linear , no sentido de que toda variável livre ou vinculada ocorre exatamente uma vez - como outros já mencionaram, existem questões e escolhas não triviais a serem feitas na tentativa de estender esse tipo de representação a não termos-lineares.

Por outro lado, para termos lineares, acho ótimo! A linearidade exclui a necessidade de cópia e, portanto, você obtém a equivalência e a substituição "de graça". Essas são as mesmas vantagens do HOAS, e eu realmente concordo com Rodolphe Lepigre de que há uma conexão (se não exatamente uma equivalência) entre as duas formas de representação: há um sentido em que essas estruturas gráficas podem ser naturalmente interpretadas como diagramas de strings , representando endomorfismos de um objeto reflexivo em uma categoria fechada e compacta (dei uma breve explicação sobre isso aqui ).α

Noam Zeilberger
fonte
10

Não tenho certeza de como sua função variável para encadernador seria representada e com qual finalidade você gostaria de usá-lo. Se você estiver usando ponteiros de retorno, como Andrej observou, a complexidade computacional da substituição não é melhor do que a renomeação alfa clássica.

Do seu comentário sobre a resposta de Andrej, deduzo que, até certo ponto, você está interessado em compartilhar. Eu posso fornecer alguma entrada aqui.

Em um cálculo lambda típico, o enfraquecimento e a contração, ao contrário de outras regras, não possuem sintaxe.

Γ , x 1 : A , x 2 : Um t : T

Γt:TΓ,x:At:TW
Γ,x1:A,x2:At:TΓ,x:At:TC

Vamos adicionar alguma sintaxe:

Γt:TΓ,x:AWx(t):TW
Γ,x1:A,x2:At:TΓ,x:ACxx1,x2(t):TC

Cab,c() está 'usando' a variável e vincula as variáveis . Eu aprendi dessa idéia com um dos " An Implementation Net de interação de redução fechada de Ian Mackie ".ab,c

Com essa sintaxe, cada variável é usada exatamente duas vezes, uma vez onde está vinculada e uma vez onde é usada. Isso nos permite nos distanciar de uma sintaxe específica e ver o termo como um gráfico em que variáveis ​​e termos são arestas.

Da complexidade algorítmica, agora podemos usar ponteiros não de uma variável para um fichário, mas de um fichário para variável e ter substituições em um tempo constante.

Além disso, essa reformulação nos permite rastrear o apagamento, a cópia e o compartilhamento com mais fidelidade. Pode-se escrever regras que copiam (ou apagam) incrementalmente um termo enquanto compartilham subtermos. Há muitas maneiras de fazer isso. Em alguns ambientes restritos, as vitórias são bastante surpreendentes .

Isso está chegando perto dos tópicos de redes de interação, combinadores de interação, substituição explícita, lógica linear, avaliação ideal de Lamping, compartilhamento de gráficos, lógicas leves e outras.

Todos esses tópicos são muito empolgantes para mim e, com prazer, daria referências mais específicas, mas não tenho certeza se isso é útil para você e quais são seus interesses.

Łukasz Lew
fonte
6

Sua estrutura de dados funciona, mas não será mais eficiente do que outras abordagens, porque você precisa copiar todos os argumentos de todas as reduções de beta e precisa fazer quantas cópias houver ocorrências da variável vinculada. Dessa forma, você continua destruindo o compartilhamento de memória entre subtermos. Combinado ao fato de você estar propondo uma solução não pura que envolve manipulações de ponteiros e, portanto, é muito propensa a erros, provavelmente não vale a pena.

Mas eu ficaria encantado em ver um experimento! Você pode pegá lambda-lo e implementá-lo com sua estrutura de dados (o OCaml tem ponteiros, eles são chamados de referências ). Mais ou menos, você apenas precisa substituir syntax.mle norm.mlpelas suas versões. São menos de 150 linhas de código.

Andrej Bauer
fonte
Obrigado! Admito que não estava realmente pensando muito em implementações, mas principalmente em poder fazer provas matemáticas sem me preocupar com a contabilidade de De Bruijn ou com a renomeação alfa. Mas existe alguma chance de uma implementação manter algum compartilhamento de memória não fazendo cópias "até que seja necessário", ou seja, até que as cópias divergam uma da outra?
Mike Shulman
Claro, você pode fazer o tipo de cópia na gravação, as pessoas dos sistemas operacionais fazem esses truques há muito tempo. Seria necessário fornecer alguma evidência de que funcionaria melhor do que as soluções estabelecidas. Muito dependerá dos padrões de uso. Por exemplo, a maioria dos argumentos para redeces é duplicada ou eles são usados ​​principalmente de maneira linear? Num típico REDEX , que é tipicamente maior, ou ? A propósito, subestações explícitas também são uma maneira de fazer as coisas com preguiça. βe 1 e 2(λx.e1)e2e1e2
Andrej Bauer
2
Com relação às provas matemáticas, já passei por uma grande formalização da sintaxe da teoria dos tipos, minha experiência é que são obtidas vantagens quando generalizamos a configuração e a tornamos mais abstrata, não quando a tornamos mais concreta. Por exemplo, podemos parametrizar a sintaxe com "qualquer boa maneira de tratar a ligação". Quando fazemos isso, é mais difícil cometer erros. Também formalizei a teoria dos tipos com índices de Bruijn. Não é muito terrível, especialmente se você tem tipos dependentes que o impedem de fazer coisas sem sentido.
Andrej Bauer
2
Para acrescentar, trabalhei em uma implementação que usava basicamente essa técnica (mas com números e números únicos, e não com ponteiros), e eu realmente não recomendaria. Definitivamente tivemos muitos bugs em que perdemos a clonagem das coisas corretamente (em grande parte devido à tentativa de evitá-lo quando possível). Mas acho que há um artigo de algumas pessoas do GHC onde eles advogam (eles usaram uma função hash para gerar nomes únicos, acredito). Pode depender do que exatamente você está fazendo. No meu caso, era tipo inferência / verificação, e parece bastante inadequado lá.
Dan Doel
@MikeShulman Para algoritmos de complexidade razoável (elementar) (em grande parte quantidade de cópias e apagamentos), a chamada "parte abstrata" da redução ideal de Lamping não está fazendo cópias até que seja necessário. A parte abstrata também é a parte não controversa, em oposição ao algoritmo completo, que requer algumas anotações que podem dominar a computação.
Łukasz Lew
5

Outras respostas estão discutindo principalmente questões de implementação. Como você menciona sua principal motivação como fazer provas matemáticas sem muita contabilidade, aqui está o principal problema que vejo com isso.

Quando você diz “uma função que mapeia cada variável para um fichário em cujo escopo ela se encontra”: o tipo de saída dessa função é certamente um pouco mais sutil do que isso soa! Especificamente, a função deve levar valores em algo como "os ligantes do termo em consideração" - ou seja, um conjunto que varia dependendo do termo (e não é obviamente um subconjunto de um conjunto de ambiente maior de qualquer maneira útil). Portanto, na substituição, você não pode simplesmente “assumir a união das funções de ligação”: você também precisa reindexar seus valores, de acordo com algum mapa, de ligantes nos termos originais a ligantes no resultado da substituição.

Essas reindexações certamente deveriam ser "rotineiras", no sentido de poderem ser razoavelmente varridas para debaixo do tapete ou bem embaladas em termos de algum tipo de funcionalidade ou naturalidade. Mas o mesmo se aplica à contabilidade envolvida no trabalho com variáveis ​​nomeadas. De modo geral, parece-me provável que haja pelo menos tanta contabilidade envolvida com essa abordagem quanto com abordagens mais padrão.

Isso, porém, é uma abordagem conceitualmente muito atraente, e eu adoraria vê-la cuidadosamente elaborada - posso imaginar que possa lançar uma luz diferente sobre alguns aspectos da sintaxe do que as abordagens padrão.

PLL
fonte
manter o controle do escopo de cada variável realmente requer contabilidade, mas não chegue à conclusão de que sempre é necessário restringir a sintaxe de escopo bem definido! Operações como substituição e redução beta podem ser definidas mesmo em termos de escopo mal definido, e minha suspeita é que se alguém quiser formalizar essa abordagem (que novamente é realmente a abordagem de redes de prova / "gráficos-λ") em um como assistente de prova, primeiro seria possível implementar as operações mais gerais e depois provar que elas preservam a propriedade de ter um escopo bem definido.
Noam Zeilberger
(Acordado que vale a pena tentar ... embora eu não ficaria surpreso se alguém já tem no contexto de formalizar prova-redes / X-gráficos.)
Noam Zeilberger
5

λLazy.t

No geral, acho que é uma representação interessante, mas envolve alguma contabilidade com ponteiros, para evitar a quebra de links vinculativos. Seria possível alterar o código para usar campos mutáveis, eu acho, mas a codificação em Coq seria menos direta. Ainda estou convencido de que isso é muito semelhante ao HOAS, embora a estrutura do ponteiro seja explicitada. No entanto, a presença de Lazy.timplica que é possível que algum código seja avaliado no momento errado. Esse não é o caso no meu código, pois apenas a substituição de uma variável por uma variável pode ocorrer no forcemomento (e não a avaliação, por exemplo).

(* 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
fonte