4.4 Programação Lógica
No capítulo 1, enfatizamos que a ciência da computação lida com conhecimento imperativo (como fazer), enquanto a matemática lida com conhecimento declarativo (o que é). De fato, as linguagens de programação exigem que o programador expresse conhecimento de uma forma que indique os métodos passo a passo para resolver problemas específicos. Por outro lado, linguagens de alto nível fornecem, como parte da implementação da linguagem, uma quantidade substancial de conhecimento metodológico que libera o usuário da preocupação com numerosos detalhes de como uma computação especificada progredirá.
A maioria das linguagens de programação, incluindo JavaScript, é organizada em torno do cálculo dos valores de funções matemáticas. Linguagens orientadas a expressões (como Lisp, C, Python e JavaScript) capitalizam o "trocadilho" de que uma expressão que descreve o valor de uma função também pode ser interpretada como um meio de calcular esse valor. Por causa disso, a maioria das linguagens de programação é fortemente tendenciosa em direção a computações unidirecionais (computações com entradas e saídas bem definidas). Existem, no entanto, linguagens de programação radicalmente diferentes que relaxam esse viés. Vimos um exemplo na seção 3.3.5, onde os objetos de computação eram restrições aritméticas. Em um sistema de restrições, a direção e a ordem da computação não são tão bem especificadas; ao realizar uma computação, o sistema deve, portanto, fornecer conhecimento mais detalhado de "como fazer" do que seria o caso com uma computação aritmética comum. Isso não significa, no entanto, que o usuário seja liberado completamente da responsabilidade de fornecer conhecimento imperativo. Existem muitas redes de restrições que implementam o mesmo conjunto de restrições, e o usuário deve escolher do conjunto de redes matematicamente equivalentes uma rede adequada para especificar uma computação particular.
O avaliador de programa não determinístico da seção 4.3 também se afasta da visão de que programação é sobre construir algoritmos para computar funções unidirecionais. Em uma linguagem não determinística, as expressões podem ter mais de um valor e, como resultado, a computação está lidando com relações em vez de funções de valor único. A programação lógica estende essa ideia combinando uma visão relacional de programação com um tipo poderoso de correspondência de padrões simbólicos chamado unificação.1
Esta abordagem, quando funciona, pode ser uma maneira muito poderosa de escrever programas. Parte do poder vem do fato de que um único fato "o que é" pode ser usado para resolver vários problemas diferentes que teriam diferentes componentes "como fazer". Como exemplo, considere a operação append, que recebe duas listas como argumentos e combina seus elementos para formar uma única lista. Em uma linguagem procedural como JavaScript, poderíamos definir append em termos do construtor de lista básico pair, como fizemos na seção 2.2.1:
function append(x, y) {
return is_null(x)
? y
: pair(head(x), append(tail(x), y));
}
Esta função pode ser vista como uma tradução para JavaScript das duas regras seguintes, a primeira das quais cobre o caso em que a primeira lista está vazia e a segunda das quais lida com o caso de uma lista não vazia, que é um pair de duas partes:
- Para qualquer lista
y, a lista vazia eyfazemappendpara formary. - Para quaisquer
u,v,yez,pair(u, v)eyfazemappendpara formarpair(u, z)seveyfazemappendpara formarz.2
Usando a função append, podemos responder perguntas como:
Encontre o
appenddelist("a", "b")elist("c", "d").
Mas as mesmas duas regras também são suficientes para responder aos seguintes tipos de perguntas, que a função não pode responder:
Encontre uma lista
yque fazappendcomlist("a", "b")para produzirlist("a", "b", "c", "d").
Encontre todos
xeyque fazemappendpara formarlist("a", "b", "c", "d").
Em uma linguagem de programação lógica, o programador escreve uma "função" append declarando as duas regras sobre append dadas acima. O conhecimento "como fazer" é fornecido automaticamente pelo interpretador para permitir que este único par de regras seja usado para responder a todos os três tipos de perguntas sobre append.3
As linguagens de programação lógica contemporâneas (incluindo a que implementamos aqui) têm deficiências substanciais, no sentido de que seus métodos gerais de "como fazer" podem levá-las a loops infinitos espúrios ou outro comportamento indesejável. A programação lógica é um campo ativo de pesquisa em ciência da computação.4
No início deste capítulo, exploramos a tecnologia de implementação de interpretadores e descrevemos os elementos que são essenciais para um interpretador para uma linguagem semelhante a JavaScript (na verdade, para um interpretador de qualquer linguagem convencional). Agora aplicaremos essas ideias para discutir um interpretador para uma linguagem de programação lógica. Chamamos essa linguagem de linguagem de consulta, porque é muito útil para recuperar informações de bancos de dados formulando consultas, ou perguntas, expressas na linguagem. Embora a linguagem de consulta seja muito diferente de JavaScript, acharemos conveniente descrever a linguagem nos mesmos termos da estrutura geral que temos usado o tempo todo: como uma coleção de elementos primitivos, juntamente com meios de combinação que nos permitem combinar elementos simples para criar elementos mais complexos e meios de abstração que nos permitem considerar elementos complexos como unidades conceituais únicas. Um interpretador para uma linguagem de programação lógica é consideravelmente mais complexo do que um interpretador para uma linguagem como JavaScript. No entanto, veremos que nosso interpretador de linguagem de consulta contém muitos dos mesmos elementos encontrados no interpretador da seção 4.1. Em particular, haverá uma parte "avaliar" que classifica expressões de acordo com o tipo e uma parte "aplicar" que implementa o mecanismo de abstração da linguagem (funções no caso de JavaScript, e regras no caso de programação lógica). Além disso, um papel central é desempenhado na implementação por uma estrutura de dados de frame, que determina a correspondência entre símbolos e seus valores associados. Um aspecto adicional interessante de nossa implementação de linguagem de consulta é que fazemos uso substancial de streams, que foram introduzidos no capítulo 3.