8 pontos por GN⁺ 2023-08-20 | 1 comentários | Compartilhar no WhatsApp
  • Tradicionalmente, a linguagem de implementação de ferramentas do tipo compilador se dividia entre OCaml e C++, mas em pequenos experimentos de linguagem o TypeScript pode ser uma opção leve, quase como uma linguagem da família ML
  • Rust combina as vantagens de ML e C++, além de oferecer multithreading seguro, mas exige modelar o layout físico dos dados, o que pode tornar pequenos protótipos mais pesados do que o ideal
  • O Deno permite começar rapidamente experimentos de linguagem em TypeScript graças a binário único, linting e formatação embutidos, ausência de etapa de compilação, task runner e modo watch
  • O typechecker de exemplo combina AST genérica, tagged unions, visitor e transform bottom-up para converter Expr<void> em Expr<Type>, inserindo TypeError como valor de tipo para reduzir erros em cascata
  • O TypeScript pode ser produtivo como ferramenta para pequenos hacks de linguagem graças ao autocompletar, ao sistema de tipos flexível e às características de runtime que permitem recorrer a abordagens dinâmicas quando necessário

O cenário tradicional na escolha da linguagem de implementação

  • Ao criar ferramentas no estilo de compiladores, a escolha da linguagem de implementação costuma seguir dois grandes caminhos
  • Para trabalhos mais voltados à implementação e que precisam estar prontos para produção, C++ é uma escolha frequente
    • LLVM, clang, v8 e HotSpot são todos baseados em C++
  • Rust foi influenciada diretamente por ML e C++, combina as vantagens das duas linguagens e ainda oferece forças próprias, como multithreading seguro
    • Ainda assim, no espectro ela pende mais para o lado da prontidão para produção
    • Um sistema de build que “simplesmente funciona” ajuda na prototipagem, mas vem acompanhado da complexidade extra de ter que modelar o layout físico dos dados

As vantagens do estilo com índices em Rust e o peso disso em código pequeno

  • Um conselho comum ao criar compiladores em Rust é evitar ponteiros e usar índices
  • Índices trazem várias vantagens em codebases grandes
    • Side tables podem ficar dentro dos módulos relacionados, reduzindo o acoplamento
    • Os índices podem ser u32 e incentivar um layout struct-of-arrays, favorável ao desempenho
    • Fica mais fácil serializar ou conectar a frameworks de compilação incremental, dando flexibilidade à estratégia de computação
  • Mas em programação de pequena escala, a abordagem baseada em índices acaba sendo trabalhosa, e em experimentos por hobby esse custo pode ser fatal
  • OCaml ainda passa uma sensação de antiguidade, e nesse contexto surge a ideia de avaliar se o TypeScript pode funcionar como uma alternativa equivalente ao papel do ML

Deno e TypeScript como ambiente para experimentação rápida

  • deno foi usado como ambiente inicial
    • Ele oferece uma experiência pronta para uso imediato com TypeScript
    • Em OCaml, isso costuma ser um ponto doloroso; Rust é melhor que OCaml ou C++ nesse aspecto, mas Deno oferece uma experiência ainda mais simples que Rust
  • A experiência de desenvolvimento com Deno combina bem com pequenos hacks de PLT
    • É um binário único
    • Traz linting e formatação embutidos
    • Não há etapa separada de compilação
    • Há task runner e modo watch integrados
  • O próprio TypeScript oferece um sistema de tipos suficientemente flexível com baixa carga sintática

Padrões em TypeScript vistos em um pequeno typechecker de exemplo

  • A AST começa com expressões que carregam informações de posição no arquivo
    • Location contém file, line e column
    • Em TypeScript, strings são apenas string e números são apenas number, então não é preciso se preocupar com distinções como usize e u32
  • As expressões separam posição e kind, e depois os dados associados são generalizados na forma Expr<T>
    • Logo após o parsing, a expressão carrega dados void
    • Depois do processamento do typechecker, a expressão carrega dados Type
    • A função de inferência de tipos recebe ast.Expr<void> e retorna ast.Expr<Type>
  • Como o TypeScript não adiciona automaticamente comportamento de runtime, é preciso inserir manualmente informação de tipo em runtime para casar union types
    • Campos como tag: "binary" e tag: "if" cumprem esse papel
    • tag: "binary" significa que, em runtime, esse valor só pode ser a string "binary"
  • Literais booleanos e literais inteiros têm praticamente a mesma forma, então são abstraídos como ExprLiteral<T, V, Tag>
    • ExprBool<T> é ExprLiteral<T, boolean, "bool">
    • ExprInt<T> é ExprLiteral<T, number, "int">
  • Valores de tipo são divididos em TypeBool e TypeInt, e valores singleton também são fornecidos com os mesmos nomes
    • Como o TypeScript apaga completamente os tipos, nomes ligados a tipos e nomes ligados a valores existem em namespaces separados
    • Essa característica permite definir tipo e valor com o mesmo nome

Visitor, transform, tipo de erro e desugaring

  • Como o switch do TypeScript é uma instrução e não uma expressão, define-se um visitor para tratar de forma confortável os kinds de expressão
    • Métodos bool, int, binary e if tratam cada kind correspondente
    • O autocompletar do editor ajuda tanto nos casos de switch quanto na implementação do visitor
  • transform<U, V> é uma função de travessia generalizada que converte Expr<U> em Expr<V>
    • A transformação é executada de baixo para cima, em estilo bottom-up
    • Ao visitar um nó interno, as subexpressões já foram transformadas, então o tipo do visitor passa a ser Visitor<U, V> e não Visitor<V, V>
  • Aproveitando o fato de o TypeScript ser uma linguagem de tipagem dinâmica em runtime, também seria possível construir uma travessia mais genérica com Object.keys
    • Mesmo assim, a assinatura estática da função pode ser preservada
    • No exemplo isso não é estritamente necessário, mas existe a possibilidade de recorrer a essa abordagem dinâmica quando for útil
  • Erros de tipo não são acumulados em um array como efeito colateral; eles são representados pelo tipo TypeError
    • Type passa a ser TypeBool | TypeInt | TypeError
    • TypeError contém tag: "Error", location e message
    • type_equal retorna true se qualquer um dos lados for Error, evitando falhas em cascata
  • O typechecker final verifica expressões binary e if
    • Se os tipos dos operandos esquerdo e direito de uma expressão binary forem diferentes, ele retorna o erro "binary expression operands have different types"
    • Se a condição da expressão if não for booleana, ele retorna o erro "if condition is not a boolean"
    • Se os tipos dos branches then e else forem diferentes, ele retorna o erro "if branches have different types"
  • O resultado exige alguma quantidade de tipagem, mas o autocompletar compensa bastante, a sensação de lutar contra a linguagem é menor e o encaixe com o problema é natural
  • As razões pelas quais o TypeScript pode ser produtivo como ferramenta para pequenos hacks de linguagem são resumidas em três pontos
    • Deno é um runtime de scripting pequeno, coeso, poderoso e otimizado para um fluxo de desenvolvimento eficaz
    • As ferramentas de TypeScript são úteis e produtivas no IDE, e com Deno não exigem configuração
    • A linguagem é poderosa tanto em runtime quanto em compile time, permite expressões bem refinadas por meio de tipos e, quando necessário, deixa espaço para recorrer a abordagens dinâmicas
  • Como ideia adicional, também é possível fazer desugaring type-safe de grande quantidade de açúcar sintático
    • Em vez de parametrizar Expr e ExprKind pelos dados associados, eles são parametrizados recursivamente pelo ExprKind inteiro
    • ExprKindCore representa o conjunto básico de expressões
    • ExprKindSugar inclui expressões básicas ou expressões que podem ser convertidas por desugaring para expressões básicas
    • desugar(expr: ExprSugar): ExprCore reduz uma expressão com açúcar sintático a uma expressão central
    • desugar_one(expr: ExprKindSugar<ExprCore>): ExprKindCore<ExprCore> executa uma etapa de transformação quando as subexpressões já foram convertidas por desugaring

1 comentários

 
GN⁺ 2023-08-20
Opiniões no Hacker News
  • TypeScript é uma ótima linguagem de modo geral, e o fato de funções serem objetos que podem ter propriedades/métodos é subestimado
    Dá para executar um array de funções como se fossem comandos e depois anexar descrições como help, ou adicionar estado com closures/aplicação parcial, sem precisar definir cedo demais classes como Command
    Em orientação a objetos, dar nomes cedo demais cria muitos atritos, e a ideia é que passar os valores necessários para uma função é mais natural do que estruturas como VideoCompressor#compress()

    • Mais precisamente, isso é mais um recurso de JavaScript do que de TypeScript
      Outras linguagens que dão suporte a objetos que se comportam como funções estão listadas em https://en.wikipedia.org/wiki/Function_object
    • Em Go, também é possível anexar métodos a funções; na prática, isso vale para qualquer tipo
      Como nos handlers de net/http, uma struct pode implementar o método serve, ou uma função handler pode chamar a si mesma para satisfazer a interface
      Em Clojure, dá para tratar isso de forma parecida anexando metadados ao var de uma função; e, por ser Lisp, quase tudo é possível com macros
      Além disso, os canais CSP de core/async separam execução e comunicação, ajudando a evitar o problema de “cor” de funções de callbacks/promises/async/await, e comandos podem se comportar como produtores que enviam resultados para canais
    • Também é possível obter um efeito parecido com as interfaces de método único do Java
      Seja qual for o nome do método, ele pode ser usado em um contexto de função, sem precisar referenciar o nome concreto do método
      Porém, não gosto da ideia de uma função ter propriedades para carregar estado e poder mudar de comportamento mesmo quando chamada com os mesmos argumentos. Vejo uma das grandes vantagens da programação funcional justamente em escapar do estado ao estilo da orientação a objetos
    • Python dá bom suporte a essa direção porque tudo é objeto
    • Em C#, algo semelhante pode ser representado com Func para funções com valor de retorno e Action para funções sem valor de retorno
      As expressões lambda de JavaScript se parecem bastante com as de C#, e as assinaturas de função de TypeScript e C# também são bem parecidas
      Há também um pequeno repositório que mostra as semelhanças entre JavaScript, TypeScript e C#: https://github.com/CharlieDigital/js-ts-csharp
      Captura de tela mostrando a mesma lógica lado a lado em JS/TS/C#: https://github.com/CharlieDigital/js-ts-csharp/blob/main/js-...
  • Não é tão surpreendente. No fim, vejo TypeScript como mais uma linguagem que incorporou, com dificuldade, uma boa parte dos recursos da família ML
    A ausência de pattern matching de verdade a torna mais incômoda que OCaml, mas, em comparação com linguagens como C#, Swift, Dart e Kotlin, ela fica em um nível razoável

    • É uma comparação feita em um nível alto demais. A experiência real de uso é bem diferente
      TypeScript tem um sistema de tipos poderoso, mas a biblioteca-padrão de base e a própria linguagem deixam a desejar, e não há pattern matching/expressões switch
      Dart tem um modelo de objetos fechado, com menos liberdade dinâmica, um sistema de tipos e expressões mais fracos, e quase não tem recursos de metaprogramação, o que leva a depender de boilerplate ao estilo Java e de geradores de código
      C# é, entre as linguagens mencionadas, a que mais se aproxima dos recursos de ML, mas, ao contrário de TypeScript, não tem tipos soma, o que torna muita coisa mais trabalhosa
    • Em um compilador didático no estilo Pascal-C que fiz antigamente em Haskell, os combinadores de parser permitiam expressar a gramática diretamente em código, de forma parecida com BNF
      Por exemplo, dava para compor um parser que reconhece { ... } com outros parsers, e definir uma instrução como uma entre fluxo de controle/declaração/atribuição
      O processamento de listas ao estilo ML e pattern matching eram muito expressivos ao lidar com representações intermediárias
    • Quando preciso de pattern matching em TypeScript, gosto de usar esta biblioteca: https://github.com/gvergnaud/ts-pattern
    • É difícil dizer que TypeScript trouxe a maior parte dos recursos de ML
      No próprio texto, como switch não é uma expressão, foi preciso contornar isso com o padrão visitor, e o suporte a iteradores em JavaScript também é estranhamente limitado
      Existe .map(), mas ele só funciona em arrays e não pode ser aplicado diretamente a iteradores genéricos
    • Ao usar TypeScript e kotlin-js, as duas linguagens parecem bem próximas
      Há muitas diferenças, mas a transição não é difícil; pessoalmente, mesmo preferindo Kotlin, consigo usar ambas
      Fico pensando como seria se TypeScript se afastasse da compatibilidade com JavaScript e compilasse para WASM. Kotlin está adicionando um compilador para WASM e já tem um transpilador para JS; o mesmo código carrega menor e mais rápido em WASM
      JavaScript no navegador não é um bom alvo de compilação e, à medida que mais projetos novos já começam em TypeScript desde o início, o argumento de que é preciso facilitar a migração a partir de JavaScript existente vai ficando cada vez mais fraco
  • Ao alternar entre Rust e TypeScript, fica muito claro o quanto fazem falta recursos como enums tagged
    A proposta de enums ADT parece ter parado; fico curioso se há outros esforços: https://github.com/Jack-Works/proposal-enum/discussions/19

    • Para usos parecidos, acho que uniões discriminadas funcionam bastante bem
  • O sistema de tipos do TypeScript é interessante, mas fico me perguntando quanto mais rápido seria se o compilador fosse escrito em uma linguagem compilada
    Claro que isso parte da grande premissa de uma “boa implementação”

    • swc e esbuild não são bons pontos de comparação. Uma parte considerável do ganho de velocidade vem do fato de removerem a sintaxe específica de TypeScript para gerar JavaScript
      O tsc tende a ser lento só na primeira execução e, usando a flag incremental ou o modo de observação --transpile-only, o tempo de compilação geralmente cai para menos de 100 ms, tornando a diferença percebida em relação ao SWC ou ao ESBuild quase inexistente
    • Há uma resposta da equipe do TypeScript: https://twitter.com/drosenwasser/status/1260723846534979584
      A ideia é que, se a verificação de tipos de um programa de porte médio leva 20 segundos, muitas vezes não é por ser JS, mas porque os tipos causam uma explosão combinatória
      Outros runtimes podem trazer ganhos em paralelismo ou tempo de inicialização, mas ele diz não ter visto benchmarks centrados em CPU que sustentem uma melhoria geral de 20 vezes
    • O sistema de tipos é divertido até você tentar explorá-lo ao máximo com genéricos
      Aí, depois de passar o dia inteiro depurando 5 linhas de lógica de tipos, você começa a se perguntar como foi parar ali
    • Não é mais preciso só imaginar: https://github.com/dudykr/stc
      É um projeto escrito em Rust por um dos principais desenvolvedores do SWC; o SWC compila TS para JS, e o STC verifica tipos de TS
    • O desempenho recente do compilador TypeScript melhorou bastante
      Dizem que o futuro isolated declaration mode pode reduzir o tempo de compilação em até 75%: https://github.com/microsoft/TypeScript/pull/53463#issuecomm...
  • Para quem está começando a aprender compiladores, recomendo este livro: https://keleshev.com/compiling-to-assembly-from-scratch/
    O autor constrói um compilador para assembly ARM de 32 bits usando um subconjunto de TypeScript, e explica que ele parece quase pseudocódigo, o que o torna bem acessível

    • Crafting Interpreters também é altamente recomendado: https://craftinginterpreters.com/
      O livro é dividido em duas partes: na primeira, ele constrói um interpretador de linguagem em Java; na segunda, compila a mesma linguagem para bytecode e depois implementa em C uma máquina virtual para esse bytecode
      Cada linha de código da implementação é referenciada no livro
  • Para evitar o padrão visitor, dá para usar um switch em estilo IIFE com uma função utilitária run
    Basta usar um switch dentro de uma função imediatamente executada e deixar o tipo de retorno ser inferido

    • Já escrevi sobre esse padrão: https://maxgreenwald.me/blog/do-more-with-run
    • Na verdade, nem isso é necessário; se você simplesmente chamar a função imediatamente, o tipo de retorno é inferido
      Se quiser evitar IIFE porque não gosta do () no fim, basta definir a função separadamente e depois chamá-la
  • Estou escrevendo um compilador em TypeScript e concordo que é melhor do que eu esperava
    No começo, assim como o autor, usei Deno, mas acabei migrando para Bun; apesar das arestas, estou mais satisfeito do que com Deno, e ele é muito rápido
    Como front-end padrão para geração de parsers, Ohm-js é bastante agradável: https://ohmjs.org/
    O compilador oficial tsc é grande demais, então não recomendo lê-lo; para ver como o tsc funciona, mini-typescript é melhor: https://github.com/sandersn/mini-typescript/
    Em especial, a branch centi-typescript ajuda bastante: https://github.com/sandersn/mini-typescript/tree/centi-types...
    Espero que GC e acesso ao DOM fiquem disponíveis em WASM

    • Fico curioso sobre o motivo da migração para Bun
  • Achei que TypeScript teria uma sobrecarga adicional por causa das interfaces, então isso me surpreende
    Fico curioso se isso poderia ser aplicado a outras áreas, por exemplo, se também seria bom para parsing de linguagens

    • Em runtime, a sobrecarga das interfaces é 0
      Elas desaparecem completamente durante a compilação
  • O resultado não é tão surpreendente. Afinal, o próprio compilador TypeScript é escrito em TypeScript
    TypeScript como ML já é validado todos os dias em ambientes de produção pesados

  • Já escrevi um compilador em C#, e o que parece especial aqui é basicamente o tipo union
    Pessoalmente, optei por evitar a verbosidade do padrão visitor e estou aguardando um recurso de enums fechados para verificações de exaustividade em tempo de compilação

    • Quando uso uma linguagem sem tipos union ou enums no estilo Rust, sinto muita falta desse recurso
      As alternativas geralmente são estranhas: ter N propriedades nullable em uma classe que representa um tipo soma, confiando numa condição documentada de que “sempre exatamente uma será non-null”, ou fazer várias classes herdarem de uma classe comum; ambas parecem pesadas
      Para criar vários tipos union sobrepostos, os dois métodos acabam exigindo duplicação ou combinações engenhosas