1 pontos por GN⁺ 2024-05-10 | 1 comentários | Compartilhar no WhatsApp
  • Datatype99 é uma biblioteca baseada em macros que fornece tipos de dados algébricos, pattern matching exaustivo e introspecção em tempo de compilação em C99 puro
  • O objetivo é detectar em tempo de compilação variants com tipo incorreto, pattern matching incompleto e acesso inválido a campos, exigindo apenas um compilador C99 compatível com o padrão
  • Internamente, datatype é expandido para uma tagged union e construtores de valor, enquanto match é expandido para uma instrução switch, e o layout de dados gerado segue uma semântica formal de geração de código
  • A instalação consiste em adicionar datatype99.h e a dependência Metalang99 ao caminho de include, e no GCC/Clang é recomendado usar opções de compilação para reduzir a verbosidade dos erros de expansão de macros
  • Pode ser integrado a uma base de código C com #include <datatype99.h> e é conhecido por funcionar em GCC, Clang, MSVC e TCC, além de oferecer suporte a C++11 ou superior

O que o Datatype99 oferece

  • Datatype99 fornece tipos de dados algébricos seguros e intuitivos em C99
  • O escopo de recursos inclui pattern matching exaustivo e introspecção em tempo de compilação
  • Funciona sem ferramentas externas de geração de código, e sua implementação é baseada em C99 puro
  • Principais características
    • Segurança de tipos: detecta em tempo de compilação variants com tipo incorreto, pattern matching não exaustivo e acesso inválido a campos
    • Portabilidade: requer um compilador C99 compatível com o padrão e não depende da biblioteca padrão, de recursos específicos de compilador/plataforma nem de VLA
    • Previsibilidade: uma semântica de geração de código definida garante que o layout de dados gerado seja sempre o mesmo
    • Erros compreensíveis: a própria biblioteca detecta alguns erros de sintaxe em código inválido
    • Caso de uso real: é usado no OpenIPC para desenvolver software de streaming em tempo real para câmeras IP, incluindo uma implementação de RTSP 1.0 e cerca de 50 mil linhas de código privado

Instalação e configuração de build

  • O Datatype99 é composto por um único arquivo de cabeçalho datatype99.h e pela dependência Metalang99
  • Para usá-lo em um projeto, é necessário adicionar datatype99 e metalang99/include aos diretórios de include
  • No GCC, recomenda-se usar -ftrack-macro-expansion=0, e no Clang, -fmacro-backtrace-limit=1, para reduzir saídas desnecessárias de erros de expansão de macros
  • Ao usar CMake, recomenda-se FetchContent
    • O datatype99/CMakeLists.txt padrão baixa o Metalang99 v1.13.5 do GitHub Releases
    • Esse comportamento pode ser redefinido chamando antes FetchContent_Declare
  • Cabeçalhos que dependem do Datatype99 podem ser transformados em precompiled header, reduzindo o tempo de compilação ao evitar recompilações a cada include

Como usar: tornando tagged unions mais seguras

  • O Datatype99 é basicamente um açúcar sintático para tagged unions, oferecendo uma forma mais segura e concisa
  • Em C comum, para representar uma árvore binária, é preciso escrever manualmente um enum tag e uma union
  • No Datatype99, a mesma estrutura pode ser declarada assim
datatype(
    BinaryTree,
    (Leaf, int),
    (Node, BinaryTree *, int, BinaryTree *)
);
  • Em uma abordagem comum com switch, o compilador pode não emitir aviso mesmo que, após case Leaf:, alguém acesse por engano tree->data.node
  • Com match e of, os bindings de cada variant só ficam visíveis naquele ramo, e um acesso inadequado faz a compilação falhar
int sum(const BinaryTree *tree) {
    match(*tree) {
        of(Leaf, x) return *x;
        of(Node, lhs, x, rhs) return sum(*lhs) + * x + sum(*rhs);
    }

    return -1;
}
  • Os bindings fornecidos por of são variáveis como x, lhs e rhs, e têm tipo ponteiro, permitindo modificar os valores
  • Para criar variants, usam-se construtores de valor gerados internamente
BinaryTree leaf5 = Leaf(5);
BinaryTree leaf7 = Leaf(7);
BinaryTree node = Node(&leaf5, 123, &leaf7);

Sintaxe e semântica de geração

  • O Datatype99 fornece sintaxe baseada em macros como datatype, record, match, of, otherwise, MATCHES e ifLet
  • Existem tanto nomes abreviados de macros quanto versões com sufixo
    • Ex.: match99, of99, derive99
    • Para evitar conflitos de nomes, é possível definir DATATYPE99_NO_ALIASES antes de incluir datatype99.h
    • Em headers de biblioteca, recomenda-se usar as macros com sufixo
  • datatype gera os seguintes elementos
    • typedef antecipado
    • structs para cada variant não vazio
    • typedef para os tipos de campo de cada variant
    • typedef para o sum type
    • tagged union contendo enum tag e union
    • construtores de valor inline static para cada variant
    • chamadas dos derivers especificados em derive(...)
  • Mesmo que todos os variants estejam vazios, pelo padrão da linguagem a union precisa ter pelo menos um item, então é inserido char dummy;
  • record é uma struct com processo de derivation definido e também gera char dummy; quando não há campos
  • match compara uma instância do sum type com os variants em sequência e, ao encontrar correspondência, executa a instrução daquele ramo antes de seguir para a próxima instrução
  • As formas completas de match e ifLet são expandidas cada uma para uma única instrução C
  • MATCHES verifica com verdadeiro/falso se uma instância do sum type corresponde a um variant específico
  • matches está deprecated, e recomenda-se usar MATCHES

derive e atributos auxiliares

  • derive(...) é usado para gerar código global para um sum type ou um record
  • O deriver de sum type é chamado na forma de macro compatível com Metalang99, e a lista de variants é passada como uma lista de tuplas
  • O deriver de record também é chamado como macro compatível com Metalang99, e a lista de campos é passada como uma lista de tuplas no formato (<type>, <field-name>)
  • Um helper attribute de derive é um argumento nomeado passado ao deriver
  • O helper attribute usa a forma de macro object-like
#define <variant-name>_<namespace>_<attribute-name> attr(/* attribute value */)
  • Macros fornecidas para manipulação de atributos
    • DATATYPE99_attrIsPresent / DATATYPE99_ATTR_IS_PRESENT: verifica se um atributo existe
    • DATATYPE99_attrValue / DATATYPE99_ATTR_VALUE: extrai o valor de um atributo existente
    • DATATYPE99_assertAttrIsPresent: gera erro fatal se um atributo obrigatório estiver ausente

Padrões de uso que exigem cuidado

  • Não se deve usar break/continue no nível superior dentro das instruções passadas a of e ifLet
    • continue dentro de loops internos for/while é aceitável
    • O controle de fluxo no nível superior deve ser substituído por labels com goto
  • Para usar arrays como parâmetro de variant, é preciso colocá-los em uma struct separada
  • Os bindings introduzidos por of são sempre mutáveis, então, se o valor passado a match for const, é preciso tomar cuidado para não modificá-lo
  • Para manter a definição de datatype legível, é possível usar // clang-format off e // clang-format on no Clang-Format
  • Helper attributes de derive devem sempre receber #undef após a definição correspondente de datatype, para evitar poluição de namespace
  • Se o significado dos parâmetros de um variant não ficar claro só pelo contexto, pode-se usar aliases de tipo ou structs separadas para dar nomes mais descritivos

Erros, IDE e compatibilidade

  • Alguns erros de sintaxe são detectados pela própria biblioteca
    • formato que não é tupla, como Bar(int)
    • vírgula ausente
    • trailing comma proibida
  • Outros erros aparecem nos diagnósticos normais do compilador
    • nome de tipo inexistente
    • match não exaustivo
    • binders em excesso em of
    • argumentos de variant com tipo incorreto
    • retorno sem desreferenciar um binding de ponteiro
  • Segundo a experiência relatada, quase 95% dos erros aparecem de forma significativa
  • Se um erro não estiver claro, é possível inspecionar o código gerado com -E; como a semântica de geração de código é formalmente definida, em geral o código não sai diferente do esperado
  • O VS Code ativa automaticamente sugestões para os tipos gerados, mas não oferece suporte a highlighting da sintaxe das macros
  • O Datatype99 é conhecido por funcionar em GCC, Clang, MSVC e TCC
  • Também oferece suporte a C++11 ou superior

Por que mirar em C e quais são os limites

  • Softwares existentes escritos em C puro podem se beneficiar do Datatype99
  • Em bases de código C existentes, a integração pode ser feita apenas com #include <datatype99.h>
  • Em alguns ambientes, como dispositivos embarcados, Linux e outros sistemas operacionais, o uso de C puro é mantido por razões históricas
  • A ABI estável de C é importante para projetos de sistemas de plugins como MetaCall
  • C é apresentado como uma linguagem madura, com especificação completa e muitas bibliotecas
  • Se for possível usar uma linguagem mais moderna ou de nível mais alto, recomenda-se fazê-lo em vez de continuar com C antigo, mas muitas pessoas não têm essa opção ou o custo é alto
  • A diferença entre Datatype99 e Metalang99 está no papel de cada um
    • Metalang99 é uma linguagem funcional para metaprogramação
    • Datatype99 é uma implementação de tipos de dados algébricos escrita com Metalang99

1 comentários

 
GN⁺ 2024-05-10
Opiniões no Hacker News
  • Sempre que uso linguagens imperativas, quase sempre sinto falta de tipos algébricos de dados
    No trabalho preciso usar Java e passei a achar que Java não é tão ruim quanto eu criticava antigamente, mas já houve dezenas de momentos em que pensei: “queria que Java tivesse as unions discriminadas do F#”
    Dá para imitar com várias técnicas, e em muitos casos só enums bastam, mas na maioria das vezes falta a flexibilidade e a concisão de tipos algébricos de dados de verdade. Acima de tudo, não há o ótimo pattern matching que se tem em linguagens funcionais
    Esta extensão para C parece ter o pattern matching que eu queria, então parece bem interessante; preciso ver se dá para usar em projetos Arduino
    • Quem nunca usou tipos algébricos de dados e pattern matching não entende qual é a grande vantagem, e mesmo quem está acostumado não percebe o quanto isso é importante até trabalhar em uma linguagem que não os tenha
      Quem acabou de descobrir não consegue parar de falar que é a melhor invenção desde o pão fatiado :)
    • Com sealed interface do Java 21, é possível fazer pattern matching
    • Kotlin é compatível com a JVM e tem tipos algébricos de dados
      Para Java, há https://github.com/functionaljava/functionaljava; o suporte foi interrompido, mas é estável
    • Tipos algébricos de dados e pattern matching, na verdade, também funcionam muito bem em linguagens imperativas. Rust é um exemplo
  • Se eu fosse reimplementar um produto do zero, unions discriminadas com pattern matching completo imposto pelo compilador seriam um requisito obrigatório
    É poderoso demais para ficar sem
    • O que eu mais gostaria em Dart são union types, mas infelizmente não parece que serão adicionados tão cedo
      Recentemente entrou suporte a pattern matching imposto pelo compilador sobre sealed classes, então parece que chegamos mais ou menos à metade do caminho
    • Que linguagens hoje se encaixam nessa descrição? Não sei exatamente o que isso quer dizer, mas acho que eu entenderia melhor vendo exemplos de linguagens que suportam isso
  • Definitivamente parece melhor e provavelmente vai funcionar melhor do que minha tentativa antiga [1], mas tem 8 vezes mais código e depende do excelente, porém um pouco assustador, conjunto de ferramentas de macros Metalang9
    Se você quiser ver como tipos algébricos de dados funcionam internamente, acho que libsum é um bom material introdutório
    [1] https://github.com/naasking/libsum
    • Pelo visto eu já tinha marcado esse repositório com estrela; acho que o consultei ao projetar o Datatype99 :)
  • Isto é obra de um mago
    Conheço C há quase 20 anos, mas nunca imaginei que o sistema de macros fosse poderoso a ponto de permitir uma magia negra dessas
    Muito legal
    • O autor tem apenas 19 anos. De repente me sinto um idiota
    • metalang99, do mesmo autor, também pode ser interessante
    • x-macro, ou seja, esse tipo de uso de macros, é bem vistoso
      Tradicionalmente era usado para criar e acessar genéricos type-safe, ou para reduzir código repetitivo em definições de registradores de hardware e interrupções
      Tem um ar meio amaldiçoado, mas a ideia central é de fato muito simples, e é uma ferramenta confiável para reduzir complexidade cognitiva e código repetitivo em projetos baseados em C
    • Tipos algébricos de dados são, em geral, substituição textual sobre structs e unions genéricos, com uma tag de union anexada
      Como uso de macros, não é tão complexo assim
  • Há algo interessante na Wikipedia relacionado a isso: uma forma de implementar unions como “hierarquias de classes da programação orientada a objetos”
    https://en.wikipedia.org/wiki/Tagged_union#Class_hierarchies...
    Também há um post longo de blog tratando do mesmo assunto, e parece que o autor ainda não tinha visto aquela seção da Wikipedia
    https://nandakumar.org/blog/2023/12/paradigms-in-disguise.ht...
    É louvável que o desenvolvedor do datatype99 mostre logo no README os problemas desse tipo de gambiarra
  • Também existe https://melt.cs.umn.edu/. Há uma extensão que adiciona templates e tipos algébricos de dados a C
    https://github.com/melt-umn/ableC-template-algebraic-data-ty...
  • A orientação “não use break/continue no nível superior dentro das instruções fornecidas a of e ifLet; use goto label em vez disso” parece um grande tiro no pé
    Ainda assim, fora isso, é muito legal
    • Usar goto em si não é um problema, mas é preciso saber que não se deve usar break/continue dentro desses blocos
      Isso me lembrou de quando criei uma UI de modo imediato com macros. No meu caso não era um problema, mas em alguns blocos é possível usar "break"
      Por exemplo, em um bloco win_form dá para sair com "break", e "goto" também funciona. Já um bloco win_command não captura "break"; então, se você usar break ou goto dentro de win_command, acabará saindo do bloco externo que envolve aquele win_command, provavelmente o bloco win_form. Normalmente isso é usado em algo como um botão "Cancel"
    • O bom do espaço de macros de Rust é que não só é possível escrever código para verificar condições como essa, como isso é realmente viável e concebível, além de contar com a ajuda de bibliotecas open source fáceis de instalar
      Não é apenas ser um pouco melhor em alguns aspectos; é suavizar, ao longo de todo o ecossistema, inúmeras pequenas inconveniências que se acumulam com o tempo. É como o efeito cumulativo de problemas como a família CPython ter tipos léxicos próprios demais, ou de bibliotecas de computação de alto desempenho fazerem o mesmo
      Por exemplo, a parte desta macro que causa esse problema simplesmente não teria sido um problema em uma macro Rust bem escrita. É um subproduto de pessoas inteligentes tentando contornar as limitações de C

No entanto, essa macro em si também era originalmente uma adaptação de um recurso nativo do Rust, então em Rust ela nem precisaria ter sido escrita; por isso ela é usada naturalmente em software da comunidade

  • goto só vira um tiro no próprio pé quando usado para saltar de uma função para outra; na verdade, “goto considered harmful” também mirava essa prática
    Essa prática desapareceu, e o goto usado hoje dentro de uma função é bem inofensivo, sendo na prática quase igual a break/continue
  • Digamos que você precise escrever um programa em C e que realmente precise de pattern matching exaustivo para tags de união. O que o Datatype99 oferece é, “em poucas palavras, açúcar sintático sobre uniões marcadas”
    E digamos que você já saiba que Rust existe, mas que, pelos motivos que todo mundo que escreve programas em C conhece, você não vá usar Rust
    Ainda assim, pelo menos vale considerar Zig. Dois dias atrás escrevi um código assim em Zig
    Usando o inline else de comptime, ele gera todos os ramos de uma instrução switch sobre uma união marcada e soma um deslocamento aos membros da união que têm o campo "l". Se forem informações de tipo conhecidas em tempo de compilação, dá para mudar bastante a natureza dos ramos, e há muita informação disponível. Como todas as condições são determinadas em tempo de compilação, cada ramo do switch contém apenas a lógica necessária para lidar com aquela variante
    “Mas meu programa já é em C e só preciso disso em um arquivo”: ainda assim, vale experimentar Zig. Talvez você goste
  • Não daria para obter a maior parte dos benefícios dos tipos de dados algébricos com struct + union + enum? Já usei um padrão com uma union de vários tipos e um enum junto para distinguir qual escolher
    std::variant também parece se comportar em certa medida como um tipo soma (sum type)
    O único problema é que não dá para criar um switch limpo sobre um valor específico de um campo, mas switch aninhados também não ficam tão bagunçados assim
    • Sim. Só com convenções e disciplina também é possível obter muitos dos benefícios da orientação a objetos, mas para isso é preciso, por exemplo, lidar manualmente com vtable, então você acaba tendo de descer com frequência aos detalhes de implementação
      O problema dessa abordagem é o grande peso mental de ter de cuidar de todos os detalhes sem esquecer nada. Com o cansaço, você começa a enfiar funcionalidades à força em classes existentes em vez de criar uma nova classe
      No fim, a abstração passa a parecer cara e, como resultado, você usa menos abstração mesmo quando poderia resolver o problema de forma mais elegante
      Em resumo, a capacidade de dizer “Darmok and Jalad at Tanagra” em vez de recontar uma longa história toda vez que se quer fazer referência a uma ideia complexa é transformadora
    • O aspecto de modelagem pode ser imitado. Mas isso não chega nem à metade dos benefícios dos tipos de dados algébricos
      Pattern matching é o grande ganho de usabilidade
    • Ao lidar com valores de tipo soma, é absolutamente essencial garantir que todas as alternativas foram verificadas
      Fazer isso com macros é difícil, mas não impossível. Basicamente, é preciso uma macro que inicie o contexto de correspondência, introduzir uma variável para rastrear todas as alternativas verificadas e configurar para que, ao fim do contexto de correspondência, seja checado se todas as alternativas foram cobertas
    • Usar C++ na maior parte do código quando necessário geralmente é aceitável, mas não gosto muito de std::variant
      A forma de fazer matching exaustivo de todos os casos com std::visit parece meio hacky. Seria um grande ganho se isso se tornasse de fato um recurso de primeira classe da linguagem
      Poderia ter um impacto maior no código do dia a dia do que outros recursos em que vêm trabalhando, como corrotinas
  • É realmente impressionante terem implementado isso. Meus respeitos