Defina uma lista usando apenas o sistema do tipo Hindley-Milner

10

Estou trabalhando em um pequeno compilador de cálculo lambda que possui um sistema de inferência do tipo Hindley-Milner em funcionamento e agora também oferece suporte a vamos recursivos (não no código vinculado), que eu entendo que deve ser suficiente para torná-lo completo .

O problema agora é que não tenho idéia de como fazê-lo, ou se ele já os suporta e só preciso encontrar uma maneira de codificá-los. Eu gostaria de poder defini-los sem precisar adicionar novas regras ao sistema de tipos.

A maneira mais fácil de pensar em uma lista xé como algo que é null(ou a lista vazia) ou um par que contém uma xe uma lista de x. Mas, para fazer isso, preciso ser capaz de definir pares e ou 's, que acredito serem os tipos de produto e soma.

Parece que eu posso definir pares desta maneira:

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

Uma vez pairque teria o tipo a -> (b -> ((a -> (b -> x)) -> x)), depois de passar, digamos, an inte a string, produziria algo com o tipo (int -> (string -> x)) -> x, que seria a representação de um par de inte string. O que me incomoda aqui é que, se isso representa um par, por que isso não é logicamente equivalente ou implica a proposição int and string? No entanto, é equivalente a (((int and string) -> x) -> x), como se eu pudesse ter apenas tipos de produtos como parâmetros para funções. Esta respostaparece resolver esse problema, mas não tenho idéia do significado dos símbolos que ele usa. Além disso, se isso realmente não codifica um tipo de produto, há algo que eu possa fazer com os tipos de produtos que eu não poderia fazer com minha definição de pares acima (considerando que eu também posso definir n-tuplas da mesma maneira)? Caso contrário, isso não contradiz o fato de que você não pode expressar a conjunção (AFAIK) usando apenas implicação?

Além disso, e o tipo de soma? De alguma forma, posso codificá-lo usando apenas o tipo de função? Em caso afirmativo, isso seria suficiente para definir listas? Ou então, existe outra maneira de definir listas sem precisar estender meu sistema de tipos? E, se não, que mudanças eu precisaria fazer para manter o mais simples possível?

Lembre-se de que sou um programador de computadores, mas não um cientista da computação, nem um matemático e muito ruim em ler notações matemáticas.

Edit: Não tenho certeza de qual é o nome técnico do que implementei até agora, mas tudo o que tenho é basicamente o código que vinculei acima, que é um algoritmo de geração de restrição que usa as regras para aplicativos, abstrações e variáveis ​​obtidas do algoritmo Hinley-Milner e, em seguida, um algoritmo de unificação que obtém o tipo principal. Por exemplo, a expressão \a.aproduzirá o tipo a -> ae a expressão \a.(a a)lançará um erro de verificação de ocorrência. Além disso, não existe exatamente uma letregra, mas uma função que parece ter o mesmo efeito que permite definir funções globais recursivas como este pseudo-código:

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.
}

O código basicamente obtém o tipo do termo como de costume, mas antes da unificação, ele adiciona o nome da função que está sendo definida com um tipo fictício no escopo do tipo, para que possa ser usado recursivamente dentro de si.

Edição 2: Acabei de perceber que também precisaria de tipos recursivos, que não tenho, para definir uma lista como eu quero.

Juan
fonte
Você pode ser um pouco mais específico sobre o que exatamente você implementou? Você implementou o cálculo lambda de tipagem simples (com definições recursivas) e deu a ele polimorfismos paramétricos no estilo Hindley-Milner? Ou você implementou o cálculo lambda polimórfico de segunda ordem?
Andrej Bauer 30/01
Talvez eu possa perguntar de uma maneira mais fácil: se eu pego o OCaml ou o SML e o restrito a termos puros lambda e definições recursivas, é disso que você está falando?
Andrej Bauer 30/01
@AndrejBauer: Eu editei a pergunta. Não tenho certeza sobre OCaml e SML, mas tenho certeza que se você usar Haskell e restringi-lo a termos lambda e a permissões recursivas de nível superior (como let func = \x -> (func x)), você obtém o que tenho.
Juan
11
Para talvez melhorar sua pergunta, confira esta meta post .
Juho 30/01

Respostas:

13

Pares

Essa codificação é a codificação da Igreja de pares. Técnicas semelhantes podem codificar booleanos, números inteiros, listas e outras estruturas de dados.

x:a; y:bpair x y(a -> b -> t) -> t¬

(umabt)t¬(¬uma¬bt)t(umab¬t)t(umab)t
ab tpairt

pairé um construtor para o tipo par e firste secondsão destruidores. (Essas são as mesmas palavras usadas na programação orientada a objetos; aqui as palavras têm um significado que está relacionado à interpretação lógica dos tipos e termos que não abordarei aqui.) Intuitivamente, os destruidores permitem acessar o que é no objeto e nos construtores pavimentam o caminho para o destruidor, assumindo como argumento uma função que eles aplicam às partes do objeto. Este princípio pode ser aplicado a outros tipos.

Soma

A codificação da Igreja de uma união discriminada é essencialmente dual à codificação da Igreja de um par. Onde um par tem duas partes que devem ser reunidas e você pode optar por extrair uma ou outra, você pode construir a união de duas maneiras e, quando usá-la, precisa permitir as duas maneiras. Portanto, existem dois construtores e um único destruidor que recebe dois argumentos.

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)

Deixe-me abreviar o tipo (a->t) -> (b->t) -> tcomo SUM(a,b)(t). Então, os tipos de destruidores e construtores são:

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

portanto

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

Listas

Para uma lista, aplique novamente o mesmo princípio. Uma lista cujos elementos têm o tipo apode ser construída de duas maneiras: pode ser uma lista vazia ou pode ser um elemento (a cabeça) mais uma lista (a cauda). Comparado aos pares, há uma pequena reviravolta no que diz respeito aos destruidores: você não pode ter dois destruidores separados heade tailporque eles funcionariam apenas em listas não vazias. Você precisa de um único destruidor, com dois argumentos, um dos quais é uma função de 0 argumentos (ou seja, um valor) para o caso nulo e o outro uma função de 2 argumentos para o caso de contras. Funções como is_empty, heade tailpodem ser derivadas disso. Como no caso de somas, a lista é diretamente sua própria função destruidora.

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)

consconsconsTT1 1,,Tn

Como você supõe, se você deseja definir um tipo que contém apenas listas homogêneas, precisa de tipos recursivos. Por quê? Vejamos o tipo de uma lista. Uma lista é codificada como uma função que recebe dois argumentos: o valor para retornar em listas vazias e a função para calcular o valor para retornar em uma célula de contras. Seja ao tipo de elemento, bo tipo da lista e co tipo retornado pelo destruidor. O tipo de uma lista é

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

Tornar a lista homogênea é dizer que, se é uma célula contrária, a cauda deve ter o mesmo tipo que o todo, ou seja, acrescenta a restrição

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

O sistema do tipo Hindley-Milner pode ser estendido com esses tipos recursivos e, de fato, linguagens de programação práticas fazem isso. Linguagens de programação práticas tendem a desaprovar essas equações "nuas" e requerem um construtor de dados, mas esse não é um requisito intrínseco da teoria subjacente. A exigência de um construtor de dados simplifica a inferência de tipo e, na prática, tende a evitar a aceitação de funções que são realmente defeituosas, mas podem ser tipificadas com alguma restrição não intencional que causa um erro de tipo difícil de entender quando a função é usada. É por isso que, por exemplo, o OCaml aceita tipos recursivos não protegidos apenas com a -rectypesopção de compilador não padrão . Aqui estão as definições acima na sintaxe do OCaml, juntamente com uma definição de tipo para listas homogêneas usando a notação paraalias tipos recursiva : type_expression as 'asignifica que o tipo type_expressioné unificado com a variável '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

Folds

Olhando para isso de forma um pouco mais geral, qual é a função que representa a estrutura de dados?

  • nn
  • (x,y)xy
  • EunEu(x)Eux
  • [x1 1,,xn]

Em termos gerais, a estrutura de dados é representada como sua função de dobra . Este é um conceito geral para estruturas de dados: uma função fold é uma função de ordem superior que atravessa a estrutura de dados. Existe um sentido técnico em que a dobra é universal : todos os percursos "genéricos" da estrutura de dados podem ser expressos em termos de dobra. Que a estrutura de dados pode ser representada como sua função fold mostra isso: tudo o que você precisa saber sobre uma estrutura de dados é como percorrê-la, o restante é um detalhe de implementação.

Gilles 'SO- parar de ser mau'
fonte
Você menciona a " codificação da igreja " de números inteiros, pares, somas, mas nas listas você fornece a codificação Scott . Eu acho que pode ser um pouco confuso para aqueles que não estão familiarizados com codificações de tipos indutivos.
Stéphane Gimenez
Então, basicamente, meu tipo de par não é realmente um tipo de produto, pois uma função com esse tipo poderia retornar te ignorar o argumento que deveria ser utilizado ( ae bé exatamente o que (a and b) or testá dizendo). E parece que eu teria o mesmo tipo de problema com somas. E também, sem tipos recursivos, não terei lista homogênea. Então, em poucas palavras, você está dizendo que devo adicionar regras de soma, produto e tipo recursivo para obter listas homogêneas?
Juan Juan
Você quis dizer case (right y) f g → g yno final da sua seção Sums ?
Juan
@ StéphaneGimenez eu não tinha percebido. Não estou acostumado a trabalhar nessas codificações em um mundo digitado. Você pode dar uma referência para codificação da Igreja versus codificação Scott?
Gilles 'SO- stop be evil'
@JuanLuisSoldi Você provavelmente já ouviu falar que “não há problema que não possa ser resolvido com um nível extra de indireção”. As codificações da igreja codificam estruturas de dados como funções adicionando um nível de chamada de função: uma estrutura de dados se torna uma função de segunda ordem que você aplica à função para atuar nas partes. Se você deseja um tipo de lista homogêneo, terá que lidar com o fato de que o tipo da cauda é o mesmo que o tipo da lista inteira. Eu acho que isso precisa envolver uma forma de recursão de tipo.
Gilles 'SO- stop be evil'
2

Você pode representar tipos de soma como tipos de produto com tags e valores. Nesse caso, podemos trapacear um pouco e usar uma tag para representar nulo ou não, com a segunda tag representando o par cabeça / cauda.

Definimos booleanos da maneira usual:

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

Uma lista é então um par com o primeiro elemento como um booleano e o segundo elemento como um par de cabeça / cauda. Algumas funções básicas da lista:

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
fonte
Mas isso não me daria uma lista homogênea, está correto?
Juan