Criando linguagens JVM utilizáveis: uma visão geral
Publicados: 2022-03-11Existem várias razões possíveis para a criação de uma linguagem, algumas das quais não são imediatamente óbvias. Gostaria de apresentá-los juntamente com uma abordagem para criar uma linguagem para a Java Virtual Machine (JVM) reutilizando ao máximo as ferramentas existentes. Dessa forma, reduziremos o esforço de desenvolvimento e forneceremos uma cadeia de ferramentas familiar ao usuário, facilitando a adoção de nossa nova linguagem de programação.
Neste artigo, o primeiro da série, apresentarei uma visão geral da estratégia e várias ferramentas envolvidas na criação de nossa própria linguagem de programação para a JVM. em artigos futuros, vamos mergulhar nos detalhes de implementação.
Por que criar sua linguagem JVM?
Já existe um número infinito de linguagens de programação. Então, por que se preocupar em criar um novo? Há muitas respostas possíveis para isso.
Em primeiro lugar, existem muitos tipos diferentes de linguagens: você deseja criar uma linguagem de programação de propósito geral (GPL) ou uma linguagem específica de domínio? O primeiro tipo inclui linguagens como Java ou Scala: linguagens destinadas a escrever soluções decentes o suficiente para um grande conjunto de problemas. Em vez disso, as linguagens específicas de domínio (DSL) se concentram em resolver muito bem um conjunto específico de problemas. Pense em HTML ou Latex: você poderia desenhar na tela ou gerar documentos em Java, mas seria complicado, com essas DSLs, você pode criar documentos com muita facilidade, mas eles são limitados a esse domínio específico.
Então, talvez haja um conjunto de problemas com os quais você trabalha com muita frequência e para os quais pode fazer sentido criar uma DSL. Uma linguagem que o tornaria muito produtivo enquanto resolve os mesmos tipos de problemas repetidamente.
Talvez você queira criar uma GPL porque teve algumas ideias novas, por exemplo, para representar relacionamentos como cidadãos de primeira classe ou representar contexto.
Finalmente, você pode querer criar um novo idioma porque é divertido, legal e porque você aprenderá muito no processo.
O fato é que se você direcionar a JVM você pode obter uma linguagem utilizável com um esforço reduzido, isso porque:
- Você só precisa gerar bytecode e seu código estará disponível em todas as plataformas onde houver uma JVM
- Você poderá aproveitar todas as bibliotecas e frameworks existentes para a JVM
Portanto, o custo de desenvolvimento de uma linguagem é bastante reduzido na JVM e pode fazer sentido criar novas linguagens em cenários que seriam antieconômicos fora da JVM.
O que você precisa para torná-lo utilizável?
Existem algumas ferramentas que você absolutamente precisa para usar sua linguagem - um analisador e um compilador (ou um interpretador) estão entre essas ferramentas. No entanto, isso não é suficiente. Para tornar sua linguagem realmente utilizável na prática, você precisa fornecer muitos outros componentes da cadeia de ferramentas, possivelmente integrando-se às ferramentas existentes.
O ideal é que você seja capaz de:
- Gerenciar referências ao código compilado para a JVM de outras linguagens
- Edite arquivos de origem em seu IDE favorito com destaque de sintaxe, identificação de erros e preenchimento automático
- Você deseja compilar arquivos usando seu sistema de compilação favorito: maven, gradle ou outros
- Você deseja escrever testes e executá-los como parte de sua solução de integração contínua
Se você puder fazer isso, adotar seu idioma será muito mais fácil.
Então, como podemos conseguir isso? No restante do post, examinamos as diferentes peças que precisamos para tornar isso possível.
Analisando e compilando
A primeira coisa que você precisa fazer para transformar seus arquivos fonte em um programa é analisá-los, obtendo uma representação Abstract-Syntax-Tree (AST) das informações contidas no código. Nesse ponto, você precisará validar o código: há erros sintáticos? Erros semânticos? Você precisa encontrar todos eles e denunciá-los ao usuário. Se tudo correr bem, você ainda precisa resolver os símbolos. Por exemplo, “Lista” se refere a java.util.List ou java.awt.List ? Quando você invoca um método sobrecarregado, qual você está invocando? Finalmente, você precisa gerar bytecode para seu programa.
Assim, do código-fonte ao bytecode compilado existem três fases principais:
- Construindo um AST
- Analisando e transformando o AST
- Produzindo o bytecode do AST
Vamos ver essas fases em detalhes.
Construir um AST : análise é uma espécie de problema resolvido. Existem muitos frameworks por aí, mas sugiro que você use o ANTLR. Ele é bem conhecido, bem mantido e possui alguns recursos que facilitam a especificação de gramáticas (ele lida com regras menos recursivas - você não precisa entender isso, mas seja grato por isso!).
Analisar e transformar o AST : escrever um sistema de tipos, validação e resolução de símbolos pode ser desafiador e exigir muito trabalho. Este tópico por si só exigiria um post separado. Por enquanto, considere que esta é a parte do seu compilador na qual você gastará a maior parte do esforço.
Produzir o bytecode a partir do AST : esta última fase não é tão difícil. Você deve ter resolvido os símbolos na fase anterior e preparado o terreno para que basicamente você possa traduzir nós únicos do seu AST transformado para uma ou poucas instruções de bytecode. As estruturas de controle podem exigir algum trabalho extra porque você traduzirá seus loops for, switches, ifs e assim por diante em uma sequência de saltos condicionais e incondicionais (sim, abaixo de sua bela linguagem ainda haverá um monte de gotos). Você precisa aprender como a JVM funciona internamente, mas a implementação real não é tão difícil.
Integração com outros idiomas
Quando você tiver obtido o domínio do mundo para seu idioma, todo o código será escrito usando-o exclusivamente. No entanto, como uma etapa intermediária, sua linguagem provavelmente será usada junto com outras linguagens JVM. Talvez alguém comece a escrever algumas aulas ou pequenos módulos em seu idioma dentro de um projeto maior. É razoável esperar poder misturar várias linguagens JVM. Então, como isso afeta suas ferramentas de linguagem?
Você precisa considerar dois cenários diferentes:
- Seu idioma e os outros vivem em módulos compilados separadamente
- Seu idioma e os outros vivem nos mesmos módulos e são compilados juntos
No primeiro cenário, seu código só precisa usar código compilado escrito em outras linguagens. Por exemplo, algumas dependências como Guava ou módulos no mesmo projeto podem ser compiladas separadamente. Esse tipo de integração requer duas coisas: primeiro, você deve ser capaz de interpretar arquivos de classe produzidos por outras linguagens para resolver símbolos para eles e gerar o bytecode para invocar essas classes. O segundo ponto é especulativo ao primeiro: outros módulos podem querer reutilizar o código escrito em sua linguagem depois de compilado. Agora, normalmente isso não é um problema porque Java pode interagir com a maioria dos arquivos de classe. No entanto, você ainda pode conseguir gravar arquivos de classe que são válidos para a JVM, mas não podem ser chamados de Java (por exemplo, porque você usa identificadores que não são válidos em Java).

O segundo cenário é mais complicado: suponha que você tenha uma classe A definida em código Java e uma classe B escrita em sua linguagem. Suponha que as duas classes se refiram uma à outra (por exemplo, A poderia estender B e B poderia aceitar A como parâmetro para o mesmo método). Agora o ponto é que o compilador Java não pode processar o código em sua linguagem, então você precisa fornecer um arquivo de classe para a classe B. No entanto, para compilar a classe B, você precisa inserir referências à classe A. Então, o que você precisa fazer é ter uma espécie de compilador Java parcial, que dado um arquivo de origem Java é capaz de interpretá-lo e produzir um modelo dele que você pode usar para compilar sua classe B. Observe que isso requer que você seja capaz de analisar o código Java (usando algo como JavaParser) e resolver símbolos. Se você não tem ideia de por onde começar, dê uma olhada em java-symbol-solver.
Ferramentas: Gradle, Maven, Test Frameworks, CI
A boa notícia é que você pode tornar o fato de que eles estão usando um módulo escrito em sua linguagem totalmente transparente para o usuário desenvolvendo um plugin para gradle ou maven. Você pode instruir o sistema de compilação a compilar arquivos em sua linguagem de programação. O usuário continuará executando mvn compile ou gradle assemble e não notará nenhuma diferença.
A má notícia é que escrever plugins Maven não é fácil: a documentação é muito pobre, não inteligível e na maioria das vezes desatualizada ou simplesmente errada . Sim, não soa reconfortante. Eu ainda não escrevi plugins gradle, mas parece muito mais fácil.
Observe que você também deve considerar como os testes podem ser executados usando o sistema de compilação. Para dar suporte a testes você deve pensar em um framework bem básico para testes unitários e deve integrá-lo com o sistema de compilação, para que a execução do maven test procure por testes em sua linguagem, compile e execute-os relatando a saída para o usuário.
Meu conselho é olhar os exemplos disponíveis: um deles é o plugin Maven para a linguagem de programação Turin.
Depois de implementá-lo, todos devem poder compilar facilmente arquivos de origem escritos em seu idioma e usá-los em serviços de integração contínua como o Travis.
Plugin IDE
Um plugin para um IDE será a ferramenta mais visível para seus usuários e algo que afetará muito a percepção do seu idioma. Um bom plug-in pode ajudar o usuário a aprender o idioma, fornecendo preenchimento automático inteligente, erros contextuais e refatorações sugeridas.
Agora, a estratégia mais comum é escolher um IDE (normalmente Eclipse ou IntelliJ IDEA) e desenvolver um plugin específico para ele. Esta é provavelmente a peça mais complexa de sua cadeia de ferramentas. Este é o caso por vários motivos: em primeiro lugar, você não pode reutilizar razoavelmente o trabalho que gastará desenvolvendo seu plugin para um IDE para os outros. Seu Eclipse e seu plugin IntelliJ serão totalmente separados. O segundo ponto é que o desenvolvimento de plugins IDE é algo não muito comum, então não há muita documentação e a comunidade é pequena. Isso significa que você terá que gastar muito tempo descobrindo as coisas por si mesmo. Eu pessoalmente desenvolvi plugins para Eclipse e para IntelliJ IDEA. Minhas perguntas nos fóruns do Eclipse permaneceram sem resposta por meses ou anos. Nos fóruns do IntelliJ, tive mais sorte e, às vezes, recebi uma resposta dos desenvolvedores. No entanto, a base de usuários de desenvolvedores de plugins é menor e a API é muito bizantina. Prepare-se para sofrer.
Existe uma alternativa para tudo isso, e é usar o Xtext. Xtext é um framework para desenvolvimento de plugins para Eclipse, IntelliJ IDEA e web. Ele nasceu no Eclipse e foi recentemente estendido para suportar outras plataformas, então não há muita experiência nisso, mas pode ser uma alternativa digna de ser considerada. Deixe-me esclarecer: a única maneira de desenvolver um plugin muito bom é desenvolvê-lo usando a API nativa de cada IDE. No entanto, com o Xtext, você pode ter algo razoavelmente decente com uma fração do esforço - você apenas fornece a sintaxe do seu idioma e obtém erros/conclusão de sintaxe gratuitamente. Ainda assim, você precisa implementar a resolução do símbolo e as partes difíceis, mas este é um ponto de partida muito interessante; no entanto, os bits difíceis são a integração com as bibliotecas específicas da plataforma para resolver os símbolos Java, portanto, isso não resolverá todos os seus problemas.
Conclusões
Há muitas maneiras de perder usuários em potencial que demonstraram interesse em seu idioma. Adotar um novo idioma é um desafio porque exige aprendê-lo e adaptar nossos hábitos de desenvolvimento. Ao reduzir ao máximo o atrito e aproveitar o ecossistema já conhecido por seus usuários, você pode evitar que os usuários desistam antes de aprenderem e se apaixonarem pelo seu idioma.
No cenário ideal, seu usuário poderia clonar um projeto simples escrito em sua linguagem e construí-lo usando as ferramentas padrão (Maven ou Gradle) sem perceber nenhuma diferença. Se ele quiser editar o projeto, ele pode abri-lo em seu editor favorito e o plug-in ajudará a apontar erros e fornecer conclusões inteligentes. Este é um cenário muito diferente de ter que descobrir como invocar seu compilador e editar arquivos usando o bloco de notas. O ecossistema em torno do seu idioma pode realmente fazer a diferença, e hoje em dia pode ser construído com um esforço razoável.
Meu conselho é ser criativo em sua linguagem, mas não em suas ferramentas. Reduza as dificuldades iniciais que as pessoas têm que enfrentar para adotar seu idioma usando padrões familiares.
Feliz design de linguagem!
Leitura adicional no Blog da Toptal Engineering:
- Como abordar a escrita de um intérprete do zero