- 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
Opiniões no Hacker News
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 acabou de descobrir não consegue parar de falar que é a melhor invenção desde o pão fatiado :)
Para Java, há https://github.com/functionaljava/functionaljava; o suporte foi interrompido, mas é estável
É poderoso demais para ficar sem
Recentemente entrou suporte a pattern matching imposto pelo compilador sobre sealed classes, então parece que chegamos mais ou menos à metade do caminho
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
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
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
Como uso de macros, não é tão complexo assim
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
https://github.com/melt-umn/ableC-template-algebraic-data-ty...
Ainda assim, fora isso, é muito legal
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"
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
gotosó 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áticaEssa prática desapareceu, e o goto usado hoje dentro de uma função é bem inofensivo, sendo na prática quase igual a
break/continueE 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 elsedecomptime, ele gera todos os ramos de uma instruçãoswitchsobre 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 doswitchconté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
https://gist.github.com/unclechu/eb37cc81e80afbbb5e74990b62e...
struct+union+enum? Já usei um padrão com umaunionde vários tipos e umenumjunto para distinguir qual escolherstd::varianttambém parece se comportar em certa medida como um tipo soma (sum type)O único problema é que não dá para criar um
switchlimpo sobre um valor específico de um campo, masswitchaninhados também não ficam tão bagunçados assimvtable, então você acaba tendo de descer com frequência aos detalhes de implementaçãoO 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
Pattern matching é o grande ganho de usabilidade
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
std::variantA forma de fazer matching exaustivo de todos os casos com
std::visitparece meio hacky. Seria um grande ganho se isso se tornasse de fato um recurso de primeira classe da linguagemPoderia ter um impacto maior no código do dia a dia do que outros recursos em que vêm trabalhando, como corrotinas