Usando _Generic para criar APIs genéricas em C
Introdução
No artigo anterior, Diagnósticos em tempo de compilação em C, vimos como transformar premissas e contratos em erros de compilação. Falta um tipo de erro muito comum em firmware: passar para uma função um valor do tipo errado, que o compilador converte em silêncio.
O C não tem sobrecarga de funções. Em C++, write(uint8_t) e write(uint32_t) podem ter o mesmo nome; em C, cada versão precisa de um nome próprio, e as APIs de firmware se multiplicam: uart_write_u8, uart_write_u16, log_int, log_uint, put_be16, put_be32. Quem chama precisa escolher a versão certa, e, se escolher errado, o valor é convertido sem aviso.
Desde o C11, a linguagem tem uma ferramenta para isso: o _Generic, que escolhe uma expressão em tempo de compilação conforme o tipo de outra. Neste último artigo da série, vamos ver como ele funciona, as pegadinhas que importam em firmware (uma delas faz o mesmo código se comportar de um jeito no PC e de outro na placa) e quatro aplicações práticas. Os exemplos estão no repositório rdws_examples_articles [1], testados no GCC, no Clang e no GCC para ARM.
Como funciona
O primeiro artigo da série mostrou um exemplo clássico:
#define abs_val(x) _Generic((x), \
int: abs, \
long: labs, \
float: fabsf, \
double: fabs)(x)
O _Generic olha o tipo da expressão de controle (o primeiro argumento, (x)), procura esse tipo na lista de associações e é substituído pela expressão associada: aqui, o nome de uma função, que em seguida é chamada com (x). Três detalhes do funcionamento [2]:
- A expressão de controle não é avaliada. Ela só serve para o compilador descobrir o tipo; efeitos colaterais nela não acontecem.
- Os qualificadores são descartados. O tipo considerado é o de depois da conversão de lvalue: um const int é escolhido como int, e um array, como ponteiro.
- O default é opcional. Sem ele, um tipo que não está na lista é um erro de compilação. Como veremos, isso é uma qualidade.
As pegadinhas
Para ver exatamente qual tipo o _Generic enxerga, o exemplo do repositório usa uma macro que devolve o nome do tipo. Os resultados, no GCC para x86:
'a' -> int
ch -> char
ch + 1 -> int
const int-> int
uint8_t -> unsigned char
int32_t -> int
uint32_t -> unsigned int
1 == 1 -> int
Cada linha esconde uma pegadinha.
Literais e comparações são int. Em C, um literal de caractere como ‘a’ é int, e não char, e o resultado de 1 == 1 também é int.
Não há promoções inteiras. Um char é escolhido como char, e um short como short. Mas ch + 1 já é int, porque a soma aplica as promoções. Um _Generic que só lista int não aceita um int16_t. O truque conhecido é usar (x) + 0 como expressão de controle, o que aplica as promoções antes da escolha, sem mudar ponteiros nem números de ponto flutuante.
Os typedefs não são tipos novos. uint8_t é só outro nome para unsigned char, e por isso uma lista não pode ter os dois: seriam duas associações para o mesmo tipo, o que é um erro.
O int32_t não é o mesmo em toda parte. Esta é a pegadinha que mais importa em firmware. No PC, com a glibc, int32_t é int e uint32_t é unsigned int. Nos compiladores para Cortex-M, como o arm-none-eabi-gcc e o do Zephyr SDK, int32_t é long e uint32_t é unsigned long. As consequências:
int lista_os_dois(int32_t x)
{
return _Generic(x, int: 1, int32_t: 2, default: 3);
}
Esta função não compila no PC (“’_Generic’ specifies two compatible types”), porque int e int32_t são o mesmo tipo. No ARM, compila, e o int32_t escolhe a segunda associação. E o contrário também acontece: um _Generic que só conhece int e unsigned int, testado no PC, manda um uint32_t para o default quando compilado para a placa. Código testado só no PC pode quebrar, ou mudar de comportamento, no firmware.
A recomendação prática: listar os tipos básicos (int, long, long long e as versões unsigned), que cobrem todos os typedefs de tamanho fixo em qualquer plataforma, e compilar os testes também com o toolchain da placa. É o mesmo conselho do artigo sobre o C23: testar em mais de um toolchain pega cedo esse tipo de diferença.
O default engole o erro. Um default que devolve uma versão “genérica” transforma um tipo inesperado em um comportamento silencioso. Sem default, o tipo inesperado vira um erro de compilação, que é o que queremos.
Quatro aplicações em firmware
Em firmware, o _Generic costuma ser mais útil para criar interfaces seguras do que para reduzir linhas de código. Os exemplos abaixo usam essa característica para eliminar erros comuns no acesso a registradores, na depuração, na serialização e na verificação de tipos.
Escrita em registrador pela largura
Periféricos têm registradores de 8, 16 e 32 bits, e escrever com a largura errada pode não ter efeito ou afetar o registrador vizinho. Com _Generic, a largura vem do tipo do ponteiro:
#define REG_WRITE(r, v) _Generic((r), \
volatile uint8_t *: reg_wr8, \
volatile uint16_t *: reg_wr16, \
volatile uint32_t *: reg_wr32)((r), (v))
void uart_configura(void)
{
REG_WRITE(&UART->CR, 0x200C);
REG_WRITE(&UART->BRR, 0x1A1);
REG_WRITE(&UART->DR, 'A');
}
No Cortex-M0, as três chamadas geram str, strh e strb, exatamente as instruções de cada largura. E, como não há default, passar um ponteiro de outro tipo, como um volatile int *, é um erro de compilação: “’_Generic’ selector of type ’volatile int ’ is not compatible with any association”.
Log com o formato escolhido pelo tipo
Errar a string de formato do printf é um clássico, e o problema do int32_t piora as coisas: %d serve para um int32_t no PC, mas não no ARM, onde ele é long. A solução padrão são as macros PRId32 e semelhantes, que funcionam, mas exigem que quem escreve saiba o tipo exato. Com _Generic, o formato vem do tipo:
/* (x) + 0 aplica as promoções inteiras: short e char viram int. */
#define FMT(x) _Generic((x) + 0, \
int: "%d", unsigned int: "%u", long: "%ld", unsigned long: "%lu", \
long long: "%lld", unsigned long long: "%llu", double: "%f", char *: "%s")
#define LOG_VAL(nome, x) do { \
printf(nome " = "); \
printf(FMT(x), (x)); \
printf("\n"); \
} while (0)
No PC, um uint32_t recebe %u; compilado para o ARM, a mesma macro escolhe %lu, sem nenhuma mudança no código. E o melhor: como o _Generic resulta em uma string literal, o -Wformat do GCC e do Clang continua conferindo o formato contra o argumento. Um mapeamento errado na macro gera o aviso de sempre.
Serialização em big-endian
Protocolos de rede usam big-endian, e gravar um campo com o tamanho errado corrompe o pacote. Com _Generic, o tamanho vem do tipo do valor, e os builtins do artigo sobre extensões fazem a troca de bytes:
#define PUT_BE(p, v) _Generic((v), \
uint16_t: put_be16, \
uint32_t: put_be32, \
uint64_t: put_be64)((p), (v))
No Cortex-M4, um PUT_BE de 16 bits vira a instrução rev16, e um de 32 bits, rev. Um detalhe que parece limitação, mas é proteção: PUT_BE(buf, 0x1234) não compila, porque o literal é int, e não um dos tipos da lista. O código é obrigado a dizer o tamanho do campo, com uma variável ou um cast.
Verificação de tipo com static_assert
Combinado com o static_assert do artigo anterior, o _Generic verifica tipos em tempo de compilação:
#define EXIGE_TIPO(x, T) \
static_assert(_Generic((x), T: 1, default: 0), "tipo errado: " #x " precisa ser " #T)
Um contador declarado como uint16_t, onde o resto do código supõe uint32_t, para o build com a mensagem “tipo errado: contador precisa ser uint32_t”. É útil para proteger premissas sobre variáveis compartilhadas entre módulos, como o tipo de um timestamp que outro arquivo lê.
A biblioteca padrão faz o mesmo
O _Generic foi padronizado no C11, mas a ideia é mais antiga na própria biblioteca: desde o C99, o cabeçalho tgmath.h oferece versões “genéricas” das funções matemáticas, que escolhem a variante certa pelo tipo do argumento [3]. A diferença aparece com força em um Cortex-M4, cuja FPU só faz precisão simples:
#include <math.h>
float modulo(float x, float y)
{
return sqrt(x * x + y * y); /* math.h: sqrt é sempre double */
}
Com math.h, sqrt recebe e devolve double: o GCC converte o argumento para double, chama a versão em software e converte o resultado de volta, três chamadas de biblioteca. Trocando o cabeçalho por tgmath.h, o mesmo código, com um argumento float, chama sqrtf, e a função inteira vira uma única instrução vsqrt.f32. E um detalhe que liga este artigo ao anterior: o -Wdouble-promotion não avisa nesse caso, porque a conversão acontece na passagem do argumento para o protótipo double sqrt(double), e não em uma expressão.
Os limites
O _Generic não é sobrecarga de verdade, e tem custos:
- É escrito com macros. As mensagens de erro, quando algo dá errado dentro da macro, podem ser longas e pouco claras, e a depuração passo a passo não “entra” na macro.
- A lista de tipos é fechada. Cada tipo novo precisa ser acrescentado na macro; um módulo não consegue estender o _Generic definido em outro.
- As pegadinhas de tipo exigem cuidado, principalmente a diferença do int32_t entre plataformas.
Para duas ou três variantes usadas em poucos lugares, funções com nomes diferentes continuam sendo a opção mais simples. O _Generic compensa quando a mesma escolha se repete por todo o código, como no log, no acesso a registradores e na serialização, e quando o erro que ele evita é caro.
Conclusão
O _Generic traz para o C uma parte do que a sobrecarga de funções oferece em outras linguagens: escolher a implementação certa pelo tipo, em tempo de compilação, sem custo em tempo de execução. Usado sem default, ele ainda transforma o tipo errado em erro de compilação. As pegadinhas são poucas, mas uma delas, o int32_t, é exatamente o tipo de diferença entre o PC e a placa que só aparece quando o código é compilado para o alvo.
Com este artigo, fechamos a série. Começamos com os 25 anos de evolução da linguagem, vimos quanto do C23 já pode ser usado em firmware, as extensões do GCC que preenchem as lacunas, a diferença entre volatile e sincronização, os diagnósticos em tempo de compilação e, agora, o _Generic. Em comum, todos os artigos tiveram uma ideia: o C moderno oferece ferramentas para deixar o compilador pegar erros que antes só apareciam na placa, e vale a pena conhecê-las. Os exemplos de toda a série, com os testes, continuam no repositório para quem quiser experimentar.
Referências
- DIAS, Rafael. rdws_examples_articles: exemplos deste artigo. GitHub.
- WG14. N1570: rascunho final do C11, seção 6.5.1.1 (seleção genérica).
- WG14. N1256: rascunho do C99, seção 7.22 (tgmath.h).
Esta obra está licenciada com uma Licença Creative Commons Atribuição-CompartilhaIgual 4.0 Internacional.
