Site menu Fumando listas: Lisp

Fumando listas: Lisp

Na programação, existe uma "barreira código/dados", análoga à "barreira sangue/cérebro" da medicina. O objetivo deste artigo é falar desta barreira, e da importância de conhecer a linguagem Lisp, onde essa barreira não existe.

Mas que droga de barreira é essa? Vou dar um exemplo bem simples: uma função que recebe dois números e uma operação aritmética, e tem de devolver o resultado. Em C:

double math(const char *op, double a, double b)
{
        if (strcmp(op, "mais") == 0) {
                return mais(a, b);
        } else if (strcmp(op, "menos") == 0) {
                return menos(a, b);
        ...
}

A ponte entre a string "mais" (dado) e a função "mais" (código) tem de ser feita pelo programador, ainda que sejam homônimas. (Nem mesmo pude usar switch-case porque o seletor op é uma string.)

Parece um problema de importância menor, mas este divórcio entre dados e código é uma constante pedra no sapato. O pesadelo de qualquer programa C é ter de lidar com dados de tipo variável, porque cada tipo implica em executar código ligeiramente diferente. A "lambança" de bibliotecas como GLib deve-se em grande parte a isto.

Outro desafio frequente, na direção contrária, é a necessidade de "scriptar" um aplicativo. A maioria das linguagens compiladas não é apropriada para isto — ou por falta de interpretador ou porque a linguagem é árida demais para a tarefa.

Qualquer aplicativo que mereça este nome, que não seja um programinha trivial, tem de lidar com estes problemas: carregar e intepretar arquivos de configuração, compartilhar dados, receber compartilhamentos... Como diz a décima regra de Greenspun:

Qualquer programa em C ou FORTRAN suficientemente complicado contém uma implementação improvisada, mal especificada, bugada e lenta de metade do Common Lisp.

Na verdade, já passamos deste estágio. É lugar-comum uma aplicação C ou C++ embutir um interpretador de uma linguagem como Javascript ou Lua (linguagens muito mais próximas do Lisp).

Em C++, a barreira código/dados fica um pouquinho mais baixa, pois podemos usar uma matriz associativa e ponteiros de funções:

typedef double (*opfunction)(double, double);
map<string, opfunction> mapa;
mapa["mais"] = &mais;
mapa["menos"] = &menos;
...

double math(const char *op, double a, double b)
{
        return mapa[op](a, b);
}

Mas no fundo é açúcar sintático, o programador continua responsável por construir cada "furo" da barreira. Em Javascript, a barreira é consideravelmente mais fina:


var mapa = {};

mapa.mais = function (a, b) {
        return a + b;
}

mapa["menos"] = function (a, b) {
        return a - b;
}

function math(op, a, b)
{
        return mapa[op](a, b);
}

A função final ficou parecida com C++, mas a forma de construir "mapa" é muito mais direta. Javascript permite atingir e.g. a função "mais" a partir de uma string contendo o nome "mais". Note ainda que a função "menos" foi incluída no mapa de forma diferente, porém equivalente.

Isto tudo é possível porque Javascript matriz associativa como tijolo estrutural da própria linguagem, enquanto em C++ o tipo map<> é definido pela STL.

Python é um pouco menos direto que Javascript, mas a "barreira código/dados" é praticamente da mesma "finura".

Porém, em ambas as linguagens, trata-se de uma "finura" de mão única. É fácil relacionar dados a código (no exemplo, ir de string à função), mas não é tão fácil manipular código como se fosse dados.

Você até tem o método toSource(), mas ele devolve o código-fonte, não a representação interna que a máquina virtual realmente utiliza. Para fazer algo útil em cima do código cuspido por toSource(), você teria de reescrever um interpretador Javascript completo...

Neste ponto, Python está mais bem na fita pois tem o módulo dis, que dá acesso aos opcodes da máquina virtual. Isto dá um poder de introspecção bem maior à linguagem:

def plus(a, b):
   return a + b
 
import dis
dis.dis(plus)
  2           0 LOAD_FAST                0 (a)
              3 LOAD_FAST                1 (b)
              6 BINARY_ADD          
              7 RETURN_VALUE        

Resta um pequeno problema (que felizmente não é uma pedra no sapato para a maioria das aplicações): uma nova barreira entre código-fonte e código-máquina. A transformação de um para outro é uma via de mão única: você não pode mexer nos opcodes e "descompilar" o resultado para código-fonte humanamente legível.

(Não que seja completamente impossível. O Valgrind faz isso, e com código binário :)

Outra pequena (ou grande) limitação de quase toda linguagem é a impossibilidade de definir novas primitivas. Python tem "if", "while", "for", mas você não pode criar um "when". Javascript e Ruby mascaram esta limitação com açúcar sintático, mas ela continua lá.

Sucede que em Lisp todas estas barreiras e limitações simplesmente não existem! Em Lisp, tudo são listas, seja dado ou código. Segue uma sessão interativa:

>> (+ 1 2)   ; lista que é avaliada imediatamente
3

>> '(+ 1 2)  ; a mesma lista sem ser avaliada
(+ 1 2)

>> (setf a (+ 1 2))   ; atribuir a lista (avaliada) à variavel "a"
3

>> (setf b '(+ 1 2))  ; atribuir a lista (sem avaliar) à "b"
(+ 1 2)

>> b     ; obtém "b"
(+ 1 2)

>> (eval b)  ; avalie "b"
3

>> (setf c '(eval b))    ; uma indireção adicional...
(EVAL B)

>> c
(EVAL B)

>> (eval c)
3

>> (1 2 3 4 5)     ; falha ao avaliar, pois "1" não é função
*** - EVAL: 1 is not a function name; try using a symbol instead

>> '(1 2 3 4 5)    ; uma simples lista de números
(1 2 3 4 5)

>> (* 2 5/6)     ; lembra das aulas do primário com frações?
5/3
>> (/ 1/4 2/3)
3/8

Não existe distinção dura entre uma lista de dados e uma de código. Código é uma lista que você avalia, ou resolve. Dados são listas que você não avalia. Mas você pode avaliar uma lista de dados. Ou processar uma lista de código como se fossem dados.

A rigor, Lisp não tem sintaxe. Só listas.

Mas como fazer loops com "for", "while" etc? Bem, Lisp tem um recurso que nenhuma outra linguagem tem: macros. Ok, C tem macros, mas as macros do C são simples substituições textuais, enquanto as macros Lisp são manipulações elaboradas do código.

Isto permite implementar primitivas no estilo "for", "while" etc. como macros, efetivamente estendendo a linguagem, e convenientemente mantendo esse tipo de coisa fora do interpretador em si.

Lisp define alguns tipos primitivos: números inteiros/racionais (vimos acima), strings, números de ponto flutuante e símbolos.

Símbolos são um bicho familiar a quem programa em Ruby: parecem strings, mas não têm aspas. Em Lisp, variáveis e funções são atreladas a símbolos. Como é de se esperar, a "ponte" entre dados-strings e símbolos é fácil de percorrer:

(defun plus (x y)
	(+ x y))

(defun minus (x y)
	(- x y))

(setf var 3)

>> (symbol-name 'plus)
"PLUS"

; funções nativas também podem entrar nessa
>> (string '+)
"+"

>> (read-from-string "var")
VAR ;
3

; usando intern em vez de read-from-string
>> (+ 1 (eval (intern "VAR")))
4

; A forma de usar uma função é ligeiramente diferente
>> (funcall (read-from-string "PLUS") 1 2)
3

Voltando à história das macros, vou mostrar um caso simples onde a macro "pode mais" que uma função.

Vamos supor que desejemos implementar o operador lógico OR com "curto-circuito", ou seja, avaliando o segundo parâmetro apenas se necessário. Isto permite usar parâmetros com efeitos colaterais, no estilo:

Faça isso OU mostre mensagem de erro
Faça isso E faça aquilo (apenas se "Faça isso" deu certo)

Segue a implementação como função e como macro, e os testes que mostram porque a macro é mais adequada.

; funções-cobaia com efeitos colaterais
(defun verdadeiro () (format t "Verdadeiro ") t)
; lembrando que em Lisp "nil" faz papel de falso e "t" é "verdadeiro"
(defun falso () (format t "Falso ") nil)

(defun funcao-ou (a b)
        (if a
                t
                (if b
                        t
                        nil
                )
        )
)

(defmacro macro-ou (a b)
        `(if ,a             ; note a aspa simples reversa
                t
                (if ,b          ; e as vírgulas frente a "a" e "b"
                        t
                        nil
                )
        )
)

; a função, que não vai curto-circuitar
>>(funcao-ou (verdadeiro) (falso))
Verdadeiro Falso
T

; e a macro, que vai
>>(macro-ou (verdadeiro) (falso))
Verdadeiro
T

A função retorna o valor correto (T) mas não pode curto-circuitar porque ambos os parâmetros são avaliados antes da função ser chamada, aí os efeitos colaterais já aconteceram.

A macro curto-circuita porque... é uma macro, é substituição de código. Na execução, a chamada da macro é substituída por algo semelhante a isto:

; as variáveis "a" e "b" só valem dentro da expressão "let"
(let ((a '(verdadeiro)) (b '(falso)))
        (if (eval a)
                t
                (if (eval b)
                        t
                        nil
                )
        )
)

As variáveis "a" e "b" recebem os parâmetros na forma "virgem" e a avaliação com "eval" só acontece quando realmente necessário.

A propósito, o Lisp padrão já possui as macros "and" e "or". Reimplementei apenas com fins didáticos.

Os tijolos estruturais do Lisp

Assim como Javascript e Python são fortemente baseados em listas e matrizes associativas, Lisp é baseado em listas, que por sua vez são baseadas num tipo bastante primitivo, o cons, que vamos chamar de célula.

A célula é composta de apenas um par de valores primitivos, nem mais nem menos.

>> (cons 1 2)
(1 . 2)

>> (setf a (cons 1 2))
(1 . 2)

>> a
(1 . 2)

>> (cdr a)
2

>> (car a)
1

>> (cons "a" 1)
("a" . 1)

>> (cons 1 NIL)
(1)

Uma lista com mais de dois valores é construída a partir de células, como uma lista encadeada simples:

(1 . (2 . (3 . (4 . 5))))

Uma árvore também pode construída a partir da humilde célula, embora fique humanamente complicada bem depressa.

    4     
  /  \   
 2    6      (4 . ((2 . (NIL . NIL)) . (6 . (NIL . NIL))))

Padronagem recursiva: nó = (chave . conteúdo)
	    	      conteúdo = (nó filho 1, nó filho 2)
		      NIL para terminações

Como disse Paul Graham: se você definir uma linguagem com primitivas equivalentes a cons, cdr, car, eq, cond e uma notação para funções baseada em células, você definiu um dialeto de Lisp, mesmo sem querer.

Mesmo programando em Lisp você não precisa lidar diretamente com células se não quiser. O ponto aqui é a minúscula quantidade de definições pétreas contidas no interpretador.

Ok, eu deveria usar apenas Lisp daqui em diante?

Paul Graham advoga que se use Lisp a sério. Um bom argumento é que Lisp já tinha em 1959 todos os recursos que hoje são comemorados como novos: closures, remoção da barreira código/dados, garbage collection etc. Se as melhores linguagens modernas tendem a LISP, por que não dar o último passo?

Já Eric Raymond sugere aprender Lisp para abrir a mente, numa admissão implícita que talvez ele não seja tão prático.

Um profissional de TI que não ama a profissão, nem respeita a história dela, diria que Lisp é uma linguagem "fora do mercado". Não há muitos empregos que pedem conhecimento em Lisp, portanto é completa perda de tempo aprender. (*)

Por ora, fico mais com a segunda opinião, e dou minhas razões.

Primeiro, a sintaxe do Lisp, ou ausência dela, me é um pouco estranha. Paul Graham diria que isto é cacoete do uso de outras linguagens. Mas se Lisp era perfeito, por que ele ajudou a desenvolver um dialeto novo, o Arc? A liberdade de criar sua própria sintaxe é interessante, mas é um pouco como fazer fogo com dois pauzinhos. É reconfortante saber que é possível, mas na prática um isqueiro vai melhor. Esta é minha sensação.

Dependendo da aplicação, a existência de uma boa biblioteca é mais importante que a linguagem. Aí as implementações de Lisp, como Common Lisp, sofrem bastante frente às linguagens de uso mais corrente. Isto é um fato da vida. No mundo real, as bibliotecas e frameworks são mais importantes que a linguagem.

Claro que isto depende da aplicação. Escrever um simulador sem dependências externas seria uma boa forma de "molhar os pés" em Lisp. Já um programa com muitas dependências implicaria em perder muito tempo em achar os bindings mais apropriados, ou mesmo desenvolvê-los. Mesmo que o tempo de desenvolvimento em si fosse zerado pelo Lisp, lidar com bindings incertos anularia a vantagem.

Felizmente, não estamos mais nos anos 60, e a opção não é mais entre Lisp, Fortran, Cobol e assembler. É entre Lisp e diversas outras excelentes linguagens modernas: Python, Javascript, Go, Swift, Rust, Kotlin...

É importante conhecer Lisp, porque é uma forma de enxergar onde as demais linguagens ainda apresentam limitações e erguem barreiras entre código e dados. Eu nunca tinha pensado seriamente na impossibilidade de "reassemblar" opcodes Python para código-fonte — até o momento em que escrevi este artigo sobre Lisp.

(*) Clojure, um dialeto de Lisp que roda em cima da JVM, tem tido boa recepção. Certamente você é cliente da Nubank e seu cartão roxinho. Esta fintech é o maior "unicório" brasileiro, um dos maiores do mundo. Boa parte do sucesso é creditado ao Clojure (e ao Datomic), a ponto do Nubank ter comprado a empresa mantenedora desses produtos. Então, não é mais correto dizer que Lisp não tem mercado...