Code Writing Code: Uma Introdução à Teoria e Prática da Metaprogramação Moderna
Publicados: 2022-07-22Sempre que penso na melhor maneira de explicar macros, lembro de um programa em Python que escrevi quando comecei a programar. Não consegui organizar como queria. Eu tive que chamar várias funções ligeiramente diferentes, e o código se tornou complicado. O que eu estava procurando - embora não soubesse disso na época - era metaprogramação .
metaprogramação (nome feminino)
Qualquer técnica pela qual um programa pode tratar código como dados.
Podemos construir um exemplo que demonstre os mesmos problemas que enfrentei com meu projeto Python imaginando que estamos construindo o back-end de um aplicativo para donos de animais de estimação. Usando as ferramentas em uma biblioteca, pet_sdk
, escrevemos Python para ajudar os donos de animais de estimação a comprar comida de gato:
Depois de confirmar que o código funciona, passamos a implementar a mesma lógica para mais dois tipos de animais de estimação (pássaros e cães). Também adicionamos um recurso para agendar consultas veterinárias:
Seria bom condensar a lógica repetitiva do Snippet 2 em um loop, então começamos a reescrever o código. Rapidamente percebemos que, como cada função tem um nome diferente, não podemos determinar qual delas (por exemplo, book_bird_appointment
, book_cat_appointment
) chamar em nosso loop:
Vamos imaginar uma versão turbinada do Python na qual podemos escrever programas que geram automaticamente o código final que queremos — um no qual podemos manipular nosso programa de maneira flexível, fácil e fluida como se fosse uma lista, dados em um arquivo ou qualquer outro tipo de dado comum ou entrada de programa:
Este é um exemplo de macro , disponível em linguagens como Rust, Julia ou C, para citar algumas, mas não Python.
Este cenário é um ótimo exemplo de como pode ser útil escrever um programa capaz de modificar e manipular seu próprio código. Essa é precisamente a atração das macros, e é uma das muitas respostas para uma pergunta maior: como podemos fazer um programa fazer introspecção em seu próprio código, tratando-o como dados, e então agir de acordo com essa introspecção?
De modo geral, todas as técnicas que podem realizar tal introspecção se enquadram no termo genérico “metaprogramação”. A metaprogramação é um subcampo rico em design de linguagem de programação e pode ser rastreada até um conceito importante: código como dados.
Reflexão: em defesa do Python
Você pode apontar que, embora o Python possa não fornecer suporte a macros, ele oferece muitas outras maneiras de escrever esse código. Por exemplo, aqui usamos o método isinstance()
para identificar a classe da qual nossa variável animal
é uma instância e chamar a função apropriada:
Chamamos esse tipo de reflexão de metaprogramação e voltaremos a ele mais tarde. O código do Snippet 5 ainda é um pouco complicado, mas mais fácil para um programador escrever do que o do Snippet 2, no qual repetimos a lógica para cada animal listado.
Desafio
Usando o método getattr
, modifique o código anterior para chamar as funções apropriadas order_*_food
e book_*_appointment
dinamicamente. Isso provavelmente torna o código menos legível, mas se você conhece bem o Python, vale a pena pensar em como você pode usar getattr
em vez da função isinstance
e simplificar o código.
Homoiconicidade: a importância do Lisp
Algumas linguagens de programação, como Lisp, levam o conceito de metaprogramação para outro nível via homoiconicidade .
homoiconicidade (substantivo)
A propriedade de uma linguagem de programação pela qual não há distinção entre o código e os dados nos quais um programa está operando.
Lisp, criada em 1958, é a linguagem homoicônica mais antiga e a segunda linguagem de programação de alto nível mais antiga. Obtendo o nome de “LISt Processor”, Lisp foi uma revolução na computação que moldou profundamente como os computadores são usados e programados. É difícil exagerar o quão fundamental e distintamente o Lisp influenciou a programação.
O Emacs é escrito em Lisp, que é a única linguagem de computador que é bonita. Neal Stephenson
Lisp foi criado apenas um ano após o FORTRAN, na era dos cartões perfurados e computadores militares que enchiam uma sala. No entanto, os programadores ainda usam o Lisp hoje para escrever aplicativos novos e modernos. O principal criador do Lisp, John McCarthy, foi um pioneiro no campo da IA. Por muitos anos, Lisp foi a linguagem da IA, com pesquisadores valorizando a capacidade de reescrever dinamicamente seu próprio código. A pesquisa de IA de hoje está centrada em redes neurais e modelos estatísticos complexos, em vez desse tipo de código de geração lógica. No entanto, a pesquisa feita em IA usando Lisp – especialmente a pesquisa realizada nos anos 60 e 70 no MIT e Stanford – criou o campo como o conhecemos, e sua enorme influência continua.
O advento do Lisp expôs os primeiros programadores às possibilidades computacionais práticas de coisas como recursão, funções de ordem superior e listas vinculadas pela primeira vez. Também demonstrou o poder de uma linguagem de programação construída sobre as ideias do cálculo lambda.
Essas noções desencadearam uma explosão no design de linguagens de programação e, como disse Edsger Dijkstra, um dos maiores nomes da ciência da computação, “ [...] ajudou vários de nossos companheiros humanos mais talentosos a pensar em pensamentos anteriormente impossíveis”.
Este exemplo mostra um programa Lisp simples (e seu equivalente na sintaxe Python mais familiar) que define uma função “fatorial” que calcula recursivamente o fatorial de sua entrada e chama essa função com a entrada “7”:
Lisp | Pitão |
---|---|
( defun factorial ( n ) ( if ( = n 1 ) 1 ( * n ( factorial ( - n 1 ))))) ( print ( factorial 7 )) | |
Codifique como dados
Apesar de ser uma das inovações mais impactantes e conseqüentes do Lisp, a homoiconicidade, ao contrário da recursão e de muitos outros conceitos pioneiros do Lisp, não chegou à maioria das linguagens de programação atuais.
A tabela a seguir compara funções homoicônicas que retornam código em Julia e Lisp. Julia é uma linguagem homoicônica que, em muitos aspectos, se assemelha às linguagens de alto nível com as quais você pode estar familiarizado (por exemplo, Python, Ruby).
A peça chave da sintaxe em cada exemplo é seu caractere de aspas . Julia usa um :
(dois pontos) para citar, enquanto Lisp usa um '
(aspas simples):
Júlia | Lisp |
---|---|
function function_that_returns_code() return :(x + 1 ) end | |
Em ambos os exemplos, a citação ao lado da expressão principal ( (x + 1)
ou (+ x 1)
) a transforma do código que teria sido avaliado diretamente em uma expressão abstrata que podemos manipular. A função retorna código—não uma string ou dados. Se fôssemos chamar nossa função e escrever print(function_that_returns_code())
, Julia imprimiria o código stringified como x+1
(e o equivalente é verdadeiro para Lisp). Por outro lado, sem o :
(ou '
em Lisp), obteríamos um erro de que x
não foi definido.
Vamos voltar ao nosso exemplo de Julia e estendê-lo:
A função eval
pode ser usada para executar o código que geramos de outro lugar no programa. Observe que o valor impresso é baseado na definição da variável x
. Se eval
nosso código gerado em um contexto em que x
não foi definido, obteremos um erro.
A homoiconicidade é um tipo poderoso de metaprogramação, capaz de desbloquear novos e complexos paradigmas de programação nos quais os programas podem se adaptar em tempo real, gerando código para atender a problemas específicos de domínio ou novos formatos de dados encontrados.
Veja o caso do WolframAlpha, onde a homoicônica Wolfram Language pode gerar código para se adaptar a uma incrível variedade de problemas. Você pode perguntar ao WolframAlpha: “Qual é o PIB da cidade de Nova York dividido pela população de Andorra?” e, notavelmente, recebem uma resposta lógica.
Parece improvável que alguém pense em incluir esse cálculo obscuro e inútil em um banco de dados, mas Wolfram usa metaprogramação e um gráfico de conhecimento ontológico para escrever código em tempo real para responder a essa pergunta.
É importante entender a flexibilidade e o poder que o Lisp e outras linguagens homoicônicas fornecem. Antes de nos aprofundarmos, vamos considerar algumas das opções de metaprogramação à sua disposição:
Definição | Exemplos | Notas | |
---|---|---|---|
Homoiconicidade | Uma característica de linguagem na qual o código é dado de “primeira classe”. Como não há separação entre código e dados, os dois podem ser usados de forma intercambiável. |
| Aqui, Lisp inclui outras linguagens da família Lisp, como Scheme, Racket e Clojure. |
Macros | Uma instrução, função ou expressão que recebe código como entrada e retorna código como saída. |
| (Veja a próxima nota sobre as macros de C.) |
Diretivas de pré-processador (ou pré-compilador) | Um sistema que recebe um programa como entrada e, com base nas instruções incluídas no código, retorna uma versão alterada do programa como saída. |
| As macros de C são implementadas usando o sistema de pré-processador de C, mas os dois são conceitos separados. A principal diferença conceitual entre as macros de C (nas quais usamos a diretiva de pré-processador #define ) e outras formas de diretivas de pré-processador de C (por exemplo, #if e #ifndef ) é que usamos as macros para gerar código enquanto usamos outras diretivas não #define . diretivas de pré-processador para compilar condicionalmente outro código. Os dois estão intimamente relacionados em C e em algumas outras linguagens, mas são tipos diferentes de metaprogramação. |
Reflexão | A capacidade de um programa de examinar, modificar e introspectar seu próprio código. |
| A reflexão pode ocorrer em tempo de compilação ou em tempo de execução. |
Genéricos | A capacidade de escrever código válido para vários tipos diferentes ou que pode ser usado em vários contextos, mas armazenado em um só lugar. Podemos definir os contextos nos quais o código é válido explicitamente ou implicitamente. | Genéricos de estilo de modelo:
Polimorfismo paramétrico:
| A programação genérica é um tópico mais amplo do que a metaprogramação genérica, e a linha entre as duas não está bem definida. Na visão deste autor, um sistema de tipos paramétricos só conta como metaprogramação se estiver em uma linguagem de tipagem estática. |
Vejamos alguns exemplos práticos de homoiconicidade, macros, diretivas de pré-processador, reflexão e genéricos escritos em várias linguagens de programação:
Macros (como a do Snippet 11) estão se tornando populares novamente em uma nova geração de linguagens de programação. Para desenvolvê-los com sucesso, devemos considerar um tópico-chave: higiene.
Macros higiênicos e anti-higiênicos
O que significa um código ser “higiênico” ou “anti-higiênico”? Para esclarecer, vamos ver uma macro Rust, instanciada pela macro_rules!
função. Como o nome indica, macro_rules!
gera código baseado em regras que definimos. Neste caso, nomeamos nossa macro my_macro
e a regra é “Crie a linha de código let x = $n
”, onde n
é nossa entrada:
Quando expandimos nossa macro (executando uma macro para substituir sua invocação pelo código que ela gera), esperaríamos obter o seguinte:
Aparentemente, nossa macro redefiniu a variável x
para igual a 3, portanto, podemos esperar razoavelmente que o programa imprima 3
. Na verdade, imprime 5
! Surpreso? Em Rust, macro_rules!
é higiênico em relação aos identificadores, portanto, não “capturaria” identificadores fora de seu escopo. Nesse caso, o identificador era x
. Se tivesse sido capturado pela macro, teria sido igual a 3.
higiene (nome masculino)
Uma propriedade que garante que a expansão de uma macro não capturará identificadores ou outros estados além do escopo da macro. Macros e macrossistemas que não fornecem essa propriedade são chamados de anti- higiênicos .
A higiene nas macros é um tema um tanto controverso entre os desenvolvedores. Os proponentes insistem que, sem higiene, é muito fácil modificar sutilmente o comportamento do seu código por acidente. Imagine uma macro que é significativamente mais complexa do que o Snippet 13 usado em código complexo com muitas variáveis e outros identificadores. E se essa macro usasse uma das mesmas variáveis do seu código — e você não percebesse?
Não é incomum que um desenvolvedor use uma macro de uma biblioteca externa sem ter lido o código-fonte. Isso é especialmente comum em linguagens mais recentes que oferecem suporte a macros (por exemplo, Rust e Julia):
Essa macro anti-higiênica em C captura o website
identificador e altera seu valor. É claro que a captura de identificadores não é maliciosa. É apenas uma consequência acidental do uso de macros.
Então, macros higiênicas são boas e macros não higiênicas são ruins, certo? Infelizmente, não é tão simples. Há um forte argumento a ser feito de que macros higiênicas nos limitam. Às vezes, a captura de identificador é útil. Vamos revisitar o Snippet 2, onde usamos pet_sdk
para fornecer serviços para três tipos de animais de estimação. Nosso código original começou assim:
Você deve se lembrar que o Snippet 3 foi uma tentativa de condensar a lógica repetitiva do Snippet 2 em um loop completo. Mas e se nosso código dependesse dos identificadores cats
and dogs
, e quiséssemos escrever algo como o seguinte:
O trecho 16 é um pouco simples, é claro, mas imagine um caso em que gostaríamos que uma macro escrevesse 100% de uma determinada parte do código. Macros higiênicos podem ser limitantes nesse caso.
Embora o debate macro higiênico versus anti-higiênico possa ser complexo, a boa notícia é que não é aquele em que você precisa tomar uma posição. A linguagem que você está usando determina se suas macros serão higiênicas ou não higiênicas, então tenha isso em mente ao usar macros.
Macros modernas
As macros estão tendo um momento agora. Por muito tempo, o foco das linguagens de programação imperativas modernas se afastou das macros como parte central de sua funcionalidade, evitando-as em favor de outros tipos de metaprogramação.
As linguagens que os novos programadores estavam aprendendo nas escolas (por exemplo, Python e Java) diziam a eles que tudo o que eles precisavam era reflexão e genéricos.
Com o tempo, à medida que essas linguagens modernas se tornaram populares, as macros se tornaram associadas à sintaxe intimidante do pré-processador C e C++ - se os programadores estivessem cientes delas.
Com o advento de Rust e Julia, no entanto, a tendência voltou para as macros. Rust e Julia são duas linguagens modernas, acessíveis e amplamente utilizadas que redefiniram e popularizaram o conceito de macros com algumas ideias novas e inovadoras. Isso é especialmente empolgante em Julia, que parece pronta para substituir Python e R como uma linguagem versátil e fácil de usar, com “baterias incluídas”.
Quando olhamos pet_sdk
pela primeira vez através de nossos óculos “TurboPython”, o que realmente queríamos era algo como Julia. Vamos reescrever o Snippet 2 em Julia, usando sua homoiconicidade e algumas das outras ferramentas de metaprogramação que ele oferece:
Vamos detalhar o snippet 17:
- Nós iteramos através de três tuplas. A primeira delas é
("cat", :clean_litterbox)
, então a variávelpet
é atribuída a"cat"
e a variávelcare_fn
é atribuída ao símbolo entre aspas:clean_litterbox
. - Usamos a função
Meta.parse
para converter uma string em umaExpression
, para que possamos avaliá-la como código. Nesse caso, queremos usar o poder da interpolação de strings, onde podemos colocar uma string em outra, para definir qual função chamar. - Usamos a função
eval
para executar o código que estamos gerando.@eval begin… end
é outra forma de escrevereval(...)
para evitar a redigitação do código. Dentro do bloco@eval
está o código que estamos gerando dinamicamente e executando.
O sistema de metaprogramação de Julia realmente nos liberta para expressar o que queremos da maneira que queremos. Poderíamos ter usado várias outras abordagens, incluindo reflexão (como Python no Snippet 5). Também poderíamos ter escrito uma função macro que gera explicitamente o código para um animal específico, ou poderíamos ter gerado o código inteiro como uma string e usado Meta.parse
ou qualquer combinação desses métodos.
Além de Julia: outros sistemas modernos de metaprogramação
Julia é talvez um dos exemplos mais interessantes e convincentes de um sistema macro moderno, mas não é, de forma alguma, o único. Rust também foi fundamental para trazer macros à frente dos programadores mais uma vez.
Em Rust, as macros são muito mais centralizadas do que em Julia, embora não vamos explorar isso completamente aqui. Por vários motivos, você não pode escrever Rust idiomático sem usar macros. Em Julia, no entanto, você pode optar por ignorar completamente a homoiconicidade e o sistema macro.
Como consequência direta dessa centralidade, o ecossistema Rust realmente abraçou as macros. Os membros da comunidade construíram algumas bibliotecas incríveis, provas de conceito e recursos com macros, incluindo ferramentas que podem serializar e desserializar dados, gerar SQL automaticamente ou até mesmo converter anotações deixadas no código para outra linguagem de programação, tudo gerado em código em tempo de compilação.
Embora a metaprogramação de Julia possa ser mais expressiva e livre, Rust é provavelmente o melhor exemplo de uma linguagem moderna que eleva a metaprogramação, já que é bastante apresentada em toda a linguagem.
De olho no futuro
Agora é um momento incrível para se interessar por linguagens de programação. Hoje, posso escrever um aplicativo em C++ e executá-lo em um navegador da Web ou escrever um aplicativo em JavaScript para ser executado em um desktop ou telefone. As barreiras à entrada nunca foram tão baixas e os novos programadores têm informações na ponta dos dedos como nunca antes.
Neste mundo de escolha e liberdade do programador, temos cada vez mais o privilégio de usar linguagens ricas e modernas, que escolhem recursos e conceitos da história da ciência da computação e linguagens de programação anteriores. É empolgante ver macros apanhadas e espanadas nesta onda de desenvolvimento. Mal posso esperar para ver o que os desenvolvedores de uma nova geração farão quando Rust e Julia os apresentarem às macros. Lembre-se, “codificar como dados” é mais do que apenas um bordão. É uma ideologia central a ter em mente ao discutir metaprogramação em qualquer comunidade online ou ambiente acadêmico.
'Codificar como dados' é mais do que apenas um bordão.
A história de 64 anos da metaprogramação tem sido fundamental para o desenvolvimento da programação como a conhecemos hoje. Embora as inovações e a história que exploramos sejam apenas um canto da saga da metaprogramação, elas ilustram o poder e a utilidade robustos da metaprogramação moderna.