Polimorfismo Paramétrico e System F
Objetivos de Aprendizagem
- Escrever a sintaxe e as duas regras de tipagem que o System F acrescenta ao cálculo lambda simplesmente tipado: abstração de tipo e aplicação de tipo .
- Definir termos polimórficos (identidade, booleanos e numerais de Church) e calcular seus tipos por instanciação.
- Usar parametricidade para derivar um "teorema de graça" só a partir de um tipo, e explicar quando uma linguagem a quebra.
- Contrastar o System F com a inferência de tipos de Hindley-Milner, e relacionar a teoria com a implementação de generics em Java, C#, Rust e Haskell.
Contexto e Motivação
software-distributed/programming-languages apresentou o cálculo lambda simplesmente tipado, em que id precisa ser escrita uma vez por tipo, e depois a inferência de tipos por unificação, que dá a id o tipo sem anotações. Este conceito olha o sistema por baixo: o System F, descoberto independentemente por Girard (1972) e Reynolds (1974), o cálculo lambda polimórfico. É o menor cálculo com generics de verdade, é o núcleo da linguagem intermediária do Haskell, um ancestral do polimorfismo da família ML e um modelo limpo para o <T> do Java e do C#.
O System F importa para quem programa por dois motivos. Primeiro, explica o que uma assinatura genérica promete. Como uma função de tipo não pode olhar dentro de um valor de tipo desconhecido, a assinatura sozinha quase determina o comportamento, um princípio que Wadler chamou de "teoremas de graça". Segundo, explica por que generics são implementados de formas tão diferentes entre linguagens (apagamento, monomorfização, dicionários, reificação): a história da teoria de tipos é a mesma, a do runtime não.
Teoria Central
Sintaxe e regras
O System F estende o cálculo lambda simplesmente tipado com uma variável de tipo , um tipo universalmente quantificado e termos para introduzi-lo e eliminá-lo:
Em T-TAbs, não pode já ser livre em . A função identidade é com tipo , e tem tipo . A aplicação de tipo é verificada por substituição e apagada em tempo de execução; numa linguagem com inferência você raramente a escreve.
Uma característica marcante do System F é que o tipo quantificado pode ser instanciado no próprio tipo quantificado (impredicatividade). O termo é bem tipado, e não tem tipagem no cálculo simplesmente tipado nem em Hindley-Milner.
Codificando dados
O System F pode codificar tipos de dados sem construtores embutidos. Booleanos de Church têm tipo , com e false devolvendo . Numerais de Church têm tipo , e o numeral de aplica o primeiro argumento vezes ao segundo. Todo termo bem tipado do System F termina (normalização forte), então o cálculo não consegue expressar recursão geral e não é Turing completo. É o mesmo motivo pelo qual uma linguagem funcional total e uma lógica podem dividir um sistema de tipos, ideia desenvolvida no conceito sobre tipos dependentes.
Parametricidade e teoremas de graça
Uma função de tipo precisa tratar uniformemente, já que não tem nenhuma operação sobre valores de tipo desconhecido além de movê-los de lugar. Reynolds formalizou isso como parametricidade, e Wadler mostrou que ela gera teoremas só a partir de tipos:
- precisa devolver o seu argumento (ou não terminar, numa linguagem com recursão geral).
- comuta com
map: para todo . Então pode reordenar, descartar ou duplicar elementos por posição, mas não pode depender dos seus valores. - ou "devolve o primeiro" ou "devolve o segundo".
Inferência: Hindley-Milner contra o System F completo
Hindley-Milner (ML, Haskell 98) restringe o System F para que os quantificadores apareçam só no nível mais externo de um tipo (prenex, polimorfismo de posto 1) e o polimorfismo seja introduzido só por let. Essa restrição torna a inferência de tipos decidível e eficiente por unificação, sem anotações. O System F completo é muito mais expressivo, mas a sua inferência de tipos é indecidível (Wells, 1994), então os programas precisam de anotações explícitas ao usar tipos de posto mais alto. O RankNTypes do GHC e as chamadas explícitas de método genérico do Java (Collections.<String>emptyList()) ficam de cada lado dessa troca, com inferência local preenchendo os casos óbvios.
Como as linguagens implementam
A teoria de tipos é a mesma; o runtime é uma escolha de projeto:
| Estratégia | Linguagens | O que acontece |
|---|---|---|
| Apagamento | Java, Haskell, OCaml | Um único corpo compilado; os argumentos de tipo são verificados e depois removidos |
| Monomorfização | Templates do C++, Rust | Uma cópia especializada por instanciação: rápido, binários maiores |
| Passagem de dicionários | Type classes do Haskell | Capacidades (ex.: Ord) são passadas como argumentos extras |
| Reificação | C# / .NET | O runtime conhece List<int> e List<string> como tipos distintos |
O apagamento do Java explica as suas restrições: sem new T[], sem instanceof List<String>, e casts inseridos pelo compilador nos pontos de uso. O Java também quebra a parametricidade pura, porque todo objeto tem equals, hashCode, toString e getClass, e reflexão e null estão sempre disponíveis.
Exemplos Resolvidos
Exemplo 1: tipando a identidade e aplicando-a
Problema: Derivar o tipo de e depois o de .
Derivação: Sob o contexto , a variável tem tipo por consulta. T-ABS dá sob o contexto . T-TABS então abstrai a variável de tipo: . Para a aplicação, T-TAPP instancia com , substituindo no tipo do corpo para obter . T-APP com resulta em . Em Java os mesmos dois passos são static <T> T id(T x) { return x; } seguido de id(true), em que o compilador infere o argumento de tipo.
Exemplo 2: numerais de Church
Problema: Conferir que tem tipo e calcular com ele.
Solução: Dentro do corpo, , então e . Abstraindo sobre , e obtém-se , que é . Para usá-lo como número comum, instancie e forneça succ e 0: reduz a . O sucessor é , e a soma é .
Exemplo 3: um teorema de graça como teste de propriedade
Problema: Um método de biblioteca tem a assinatura static <T> List<T> rotate(List<T> xs), e a afirmação é que ele nunca examina os elementos. O que dá para testar sem conhecer a implementação?
Solução: A parametricidade prevê para toda função . Um teste baseado em propriedades pode gerar uma lista aleatória e um aleatório e conferir que os dois lados são iguais. Se o teste falhar, o método está olhando valores dos elementos (talvez por equals, hashCode ou instanceof), o que a sua assinatura não admite. Compare com static <T extends Comparable<? super T>> void sort(List<T> xs): o limite é uma capacidade extra, e o teorema de graça se enfraquece para "comuta com map g quando preserva a ordem".
Equívocos Comuns e Armadilhas
- "Generics são templates." Templates do C++ são instanciados por uso e verificados depois da instanciação. Os generics do Java e do System F são verificados uma vez, na forma genérica, e é essa verificação que dá a garantia.
- "Em Java,
<T> T f(T x)precisa ser a identidade." Só no System F. O Java deixafdevolvernull, lançar exceção, chamarx.toString()ou inspecionarx.getClass(), porque a linguagem tem operações universais sobre todos os objetos. - "System F é só os tipos do Haskell." A linguagem fonte do Haskell usa um fragmento de posto 1 com inferência, e usa o System F internamente (como linguagem núcleo tipada) depois da elaboração. Extensões como
RankNTypespermitem escrever tipos de posto mais alto, com anotações. - "Apagamento significa que os generics do Java são inseguros." Apagamento é uma estratégia de implementação. O verificador é correto enquanto não houver avisos de operação não verificada; tipos brutos (raw types) e casts não verificados causam heap pollution e os
ClassCastExceptionque a acompanham.
Resumo
O System F acrescenta abstração e aplicação de tipo ao cálculo lambda simplesmente tipado, dando tipos com as regras T-TAbs e T-TApp, suficientes para codificar booleanos e números e para tipar a autoaplicação. Todo termo tipado termina. A parametricidade diz que funções polimórficas tratam o seu argumento de tipo uniformemente, o que gera teoremas de graça a partir de assinaturas. Hindley-Milner restringe o System F para tornar a inferência decidível, e a inferência do System F completo é indecidível. O Java apaga argumentos de tipo, C++ e Rust monomorfizam, o Haskell passa dicionários e o C# reifica, todos implementando a mesma teoria. O próximo conceito acrescenta a outra metade dos generics orientados a objetos: o subtipo, e como ele interage com o polimorfismo paramétrico por meio da variância.