Meu estilo pessoal de programação em C no fim de 2023
(nullprogram.com)- Em 2023, a forma de escrever código C mudou bastante, e o estilo foi reorganizado em torno de nomes de tipos curtos, da exclusão de strings terminadas em nulo, do retorno de structs e da compilação em uma única unidade de tradução
- Aliases curtos como
u8,i32,sizees8, junto com a remoção deconstestruct, são escolhas para reduzir o ruído visual e a carga cognitiva em declarações repetitivas - Strings são tratadas não como terminadas em nulo, mas como um fat pointer
s8comdataelen; em ambientes Win32 e UTF-16,c16es16são usados em conjunto - No desenho de funções, há preferência por retornar structs em vez de usar parâmetros de saída, com um padrão em que o valor de retorno inicializado com zero só recebe
okno ponto de sucesso - Valoriza-se regras locais legíveis, de macros, assert e declarações Win32 até assembly inline, mas ao contribuir para outros projetos, segue-se o estilo daquele projeto
Revelando a intenção com nomes de tipos curtos
- São usados aliases curtos para tipos básicos de inteiros, caracteres e tamanhos de ponteiros
- Exemplos:
u8,c16,b32,i32,u32,u64,f32,f64,uptr,byte,size,usize
- Exemplos:
- Como esses nomes aparecem com frequência em todo o programa, a concisão traz benefícios diretos para leitura e revisão
- O sufixo
_tnão é mais usado; hoje ele parece um elemento visualmente dispersivo - Para o prefixo de tipos signed, prefere-se
iem vez dessfica reservado para nomes de tipos de string
- Para o tipo de tamanho, usa-se
sizeem vez deisize- Porque o signed size é visto como o padrão mais importante
usizetem um uso mais restrito, principalmente ao interagir com interfaces externas
b32expressa a intenção de “booleano de 32 bits”- Usa um tamanho de palavra natural em vez de
_Bool - Na prática, considera-se que ele muitas vezes estará em registradores ou no padding de structs
- Quando memória realmente importa, booleanos são compactados em uma variável
flags
- Usa um tamanho de palavra natural em vez de
c16é o tipo para caracteres UTF-16 necessário no Win32- Se for baseado em
char16_t, isso ajuda debuggers como o GDB a exibir os dados como caracteres - O nome de tipo oficial do Win32 é
wchar_t, mas prefere-se explicitar UTF-16
- Se for baseado em
u8é usado para octetos e principalmente dados UTF-8, enquantobyteé separado para memória bruta e como tipo especial de aliasing- Preocupar-se com sistemas que não dão suporte a tipos de largura fixa é visto como pouco prático
- Nomes longos de tipos como
int_fast32_ttambém são vistos como desperdício desnecessário
- Nomes longos de tipos como
- Ao mostrar apenas trechos de código isolados, esses aliases não são usados sozinhos
- Porque, para o leitor entender o contexto, o
typedeftambém seria necessário
- Porque, para o leitor entender o contexto, o
Regras para macros e assert
- Macros com aparência de função usam letras minúsculas
- Exemplos:
countof(a),lengthof(s),new(a, t, n)
- Exemplos:
- Para constantes, ainda se prefere
ALL_CAPS, mas para macros funcionais, minúsculas são consideradas mais legíveis - Macros funcionais têm menos problemas de namespace do que macros comuns
- É possível ter ao mesmo tempo uma macro
new()e variáveis ou camposnew - Porque ela não é expandida como macro se não estiver na forma de chamada de função
- É possível ter ao mesmo tempo uma macro
- Para GCC e Clang, a macro
assertusa a formawhile (!(c)) __builtin_unreachable() - Esse estilo de assert não exige separar configurações de build
- Não é preciso ter definições separadas para builds de debug e release
- A ativação é controlada pela presença do Undefined Behavior Sanitizer, ou UBSan
- A
libubsanfornece diagnósticos com nome de arquivo e número de linha - Em builds de release, isso se torna uma dica prática de otimização
- Para ativar assertions em builds de release, coloca-se o UBSan em modo trap com
-fsanitize-trape habilita-se pelo menos-fsanitize=unreachable - Em teoria,
-funreachable-trapstambém permitiria isso, mas, no momento da escrita, está quebrado em algumas versões recentes do GCC
O que é reduzido nas declarações
- Não se usa
constem parâmetros- Considera-se que ele não tem papel prático na otimização
- O autor diz não lembrar de casos em que isso tenha pego, ou teria pegado, um erro
- Um bom nome de parâmetro é considerado suficiente para cumprir o papel de documentação no protótipo
- A remoção de
constfoi uma mudança que aumentou a produtividade ao reduzir a carga cognitiva e o ruído visual - Como pequena exceção,
constainda é preferido como dica para colocar tabelas estáticas em memória somente leitura próxima ao código- Se necessário, remove-se o
constcom cast
- Se necessário, remove-se o
- Para ponteiros nulos, usa-se o literal
0- É um estilo usado há cerca de 7 anos
- Embora exista a possibilidade teórica de defeitos, nenhum caso real foi visto em centenas de milhares de linhas de código
restricté usado apenas quando necessário- O código é estruturado de modo a não usar parâmetros de saída em loops, ou a evitar os próprios parâmetros de saída
inlinenão é usado- Porque tudo é compilado em uma única unidade de tradução
- Todas as structs recebem
typedef- Remover a palavra-chave
structtorna o código mais fácil de ler - Structs recursivas recebem uma forward declaration logo acima, e os campos usam nomes curtos
- Remover a palavra-chave
- Todas as funções, exceto o entry point, são declaradas como
static- Porque se assume a compilação em uma única unidade de tradução
- Graças aos nomes de tipos curtos e à remoção de
constestruct, é confortável colocar o tipo de retorno da função e o nome da função na mesma linha - Houve uma época em que nomes de tipos eram escritos com inicial maiúscula, mas isso acabou sendo abandonado
Strings com s8, em vez de terminação nula
- Uma das mudanças mais produtivas foi rejeitar completamente strings terminadas em nulo e passar a usar o tipo de string
s8, comdataelen - Estrutura de
s8:u8 *datasize len
- A macro
s8(s)envolve um literal de string C como uma strings8 s8é passado e retornado por valor, como um fat pointers8também funciona bem como prefixo de funções- Porque nomes da família
strsão reservados - Exemplos:
s8span,s8equals,s8compare,s8hash,s8trim,s8clone
- Porque nomes da família
- Para comparar literais, usa-se uma forma como
s8equals(tagname, s8("body")) - Também foi tentado o uso de flexible array member para agrupar tamanho e array em uma única allocation, mas a falta de flexibilidade pareceu pesar mais do que os benefícios
- Houve casos em que se pensou que um programa simples não precisava de um tipo de string, mas, em geral, essa avaliação se mostrou errada
s16também é usado como tipo compatível com UTF-16- Ele possui
c16 *dataesize len - Ainda não há total convicção sobre a abordagem de prefixar literais com
una macro
- Ele possui
Retorno de structs e forma de inicialização
- Prefere-se retornar structs em vez de usar parâmetros de saída
- Na prática, é uma forma de retornar vários valores, embora não haja destructuring
- O exemplo
i32parse(s8)retorna tanto ovalue, o resultado do parsing, quantook, o estado - O custo adicional de cópia não é visto como um grande problema na prática
- Porque a convenção de chamada pode transformar isso em um parâmetro de saída
restrictoculto - Ou, quando há inline, a sobrecarga do valor de retorno deixa de ser significativa
- Porque a convenção de chamada pode transformar isso em um parâmetro de saída
- Essa abordagem reduz a tentação de indicar erros por meio de sinais in-band, como um retorno null especial
- Prefere-se o padrão de criar, no topo da função, um valor de retorno inicializado com zero e usá-lo em todos os
return- Em caso de erro, retorna-se imediatamente o estado inicializado com zero
- No caminho de sucesso, define-se
okcomo true imediatamente antes do retorno
- Exceto por dados estáticos e pelas macros
s8es16, o uso de initializers também é reduzido- Evita-se designated initializer e inicializa-se por atribuições
- Inicialização por atribuição é legível e, como há um sequence point entre cada atribuição, fornece uma ordem explícita
- Em inicializações nas quais a ordem de chamada pode afetar o resultado, como em funções de geração de números aleatórios, não é preciso pensar nos casos possíveis de valores
Declarações Win32 e assembly inline
- Prefere-se
__attributeem vez de__attribute__- O sufixo
__final é considerado excessivo e desnecessário
- O sufixo
- Em programação de sistema Win32, não se inclui
windows.h; os protótipos necessários são escritos manualmente- Em geral, porque não há muitas declarações e definições necessárias
- Isso reduz o tempo de build e polui menos o namespace
- Também se encaixa de forma mais limpa com tipos customizados como
u32,b32euptr, em vez deDWORD,BOOLeULONG_PTR
- O exemplo de declarações Win32 usa a macro
W32(r) __declspec(dllimport) r __stdcall- Funções como
ExitProcess,GetStdHandle,VirtualAlloc,WriteConsoleAeWriteConsoleWsão declaradas diretamente
- Funções como
- Em assembly inline, os parênteses externos são tratados como se fossem chaves
- Assim como em
if, há um espaço antes do parêntese de abertura - Cada linha de constraint começa com dois-pontos
- Assim como em
- Como exemplo em que é possível conferir esse estilo em um programa pequeno, há
wordhist.c - Como exemplo um pouco maior, há
asmint.c, uma implementação de uma minilinguagem de programação
1 comentários
Opiniões do Hacker News
Parece que ele considerou que
#define sizeof(x) (size)sizeof(x)não precisava de parênteses externos, mas há uma exceção bem pequenaComo conversões têm precedência maior que multiplicação,
sizeof(x) * 3funciona com segurança como(size)sizeof(x) * 3Porém, em
(size)sizeof(x)[y], a indexação de array é aplicada antes da conversão, então vira(size)(sizeof(x)[y]), não((size)sizeof(x))[y]Em código real não há motivo para indexar
sizeof(x), mas C permiteinteger[pointer]com o mesmo significado depointer[integer], então esse macro pode compilar e se comportar incorretamente por falta de parêntesesMais fundamentalmente, também é difícil concordar com a afirmação de que signed size é melhor. O autor diz que tamanhos sem sinal são uma fonte de defeitos, mas o código apresentado também tem um bug que corrompe a memória se
countfor negativoCom inteiros sem sinal, contagens negativas não são representáveis e, se houver overflow, elas se tornam um valor positivo muito grande, sendo pegas pela verificação existente. Pessoalmente, prefiro usar inteiros sem sinal, mas sempre que possível com um wrapper de verificação de intervalo que interrompa a execução em caso de overflow
_Bool, na verdade, me agradaPorque uma expressão que funciona bem em
if (flags & FLAG_ALLOCATED)pode ser extraída para uma variável booleana, como em_Bool need_free = flags & FLAG_ALLOCATED;flags & FLAG_ALLOCATEDpode ser um valor arbitrário diferente de zero quando está definido, não necessariamente 1, e_Boolo normaliza para 1. Se você armazenar emint,if (need_free)passa, masif (need_free == true)pode falharTambém há desvantagens. Durante uma refatoração, se você deixar passar que a conversão implícita para
_Boolestava fazendo algo útil, pode acabar com código incorreto comoif ((flags & FLAG_ALLOCATED) == true)Além disso, ao ler uma struct do disco ou preenchê-la com bytes arbitrários, se um campo
_Boolnão for 0 nem 1, há risco de comportamento indefinido(size)(sizeof(x)[y])também deve surpreender muita gente, mas é o mesmo que(size)(sizeof ((x)[y]))sizeofnão é uma função, e sim um operador unário, enquanto indexação e chamada de função têm precedência maior quesizeof. Por isso, prefiro deixar um espaço depois desizeofe usar parênteses no operando só quando necessáriohttps://en.cppreference.com/w/c/language/operator_precedence
Para escrever o macro corretamente, ficaria
#define sizeof(x) ((size)(sizeof (x)))Definir os próprios tipos parece um passo além do necessário
Mesmo alguém já familiarizado com os tipos de C precisa aprender um sistema peculiar separado para entender um programa. Especificar o tamanho faz sentido, então dá para entender usar
uint32_tem vez deuintEsses tipos deveriam estar definidos em um header apropriado, e talvez eu esteja errado por não usar C há muito tempo
intde C é 32 bitsNão em targets de 16 bits, mas você realmente vai portar um programa de 5 MB para 16 bits? Esse tipo de preocupação geralmente não vale a pena
O problema é
long. Em algumas máquinas ele tem 32 bits; em outras, 64 bits, o que é confuso. Felizmente,long longé sempre 64 bits, então basta abandonarlongcharde 8 bits,shortde 16 bits,intde 32 bits,long longde 64 bits, e pronto. Em C, já se perdeu tempo demais infinitamente com o tamanho deintPara quem usa C com frequência, as abreviações mostradas aqui são familiares e, para um esquema de tipos customizado, é bastante elegante. Lembra Rust
stdint.hSempre me surpreende ver vários projetos recriarem esse arquivo com tanto esforço
Traduzir tipos padrão para nomes próprios é irritante para quem lê. Uma vez, em um projeto C++, perguntei por que usavam tantos
typedefpara coleções, referências e objetos compostos, e ouvi que isso tornava tudo mais fácil de entenderMais tarde vi que a pessoa tinha uma cola de typedefs colada ao lado do monitor
É comum ver tipos como
dim_t, que podem ser de 32 ou 64 bits dependendo do uso. Mesmo em plataformas de 64 bits, estruturas com compressão de ponteiros costumam usar inteiros de 32 bitsPor exemplo, se você alocar seu próprio heap e armazenar apenas offsets de 32 bits, em cargas de trabalho abaixo de 4 GB o uso de memória cai pela metade, e a localidade de cache também melhora, aumentando o desempenho
Abandonar convenções consolidadas de C por preferência pessoal parece um pouco exagerado
Usar
u8oui32em vez deuint8_touint32_treduz algumas letras, mas pode confundir outras pessoas ao lerem o códigoUsar um tipo de string customizado em vez de strings terminadas em nulo também parece aumentar a dificuldade de colaboração, considerando que C foi criado em torno desse tipo de string
Escrever diretamente os protótipos da API Win32 sem incluir
windows.hpode reduzir o tempo de compilação, mas é como trocar uma rodovia bem conservada por uma trilha na mata. Boa parte disso parece mais preferência pessoal do que código C fácil de todos lidaremu8oui32não servem para economizar digitação, mas para reduzir a carga sensorial na leituraO argumento da “quantidade de teclas pressionadas”, que sempre aparece no debate verbosidade versus concisão, tem um grande defeito. É falsa a crença de que a concisão só ajuda a digitar mais rápido, e que a verbosidade é sempre melhor para ler
A verbosidade também tem vantagens na leitura, mas a concisão também tem as suas, e nenhum dos lados é claramente vencedor. São apenas compromissos diferentes
u16são bastante usados, e é pouco provável que confundam programadoresO ponto em que a coisa realmente desmorona é quando dois programas diferentes definem cada um o seu
u16e o expõem em arquivos de cabeçalho, e então um terceiro programa inclui os dois cabeçalhos ao mesmo tempoTipos de biblioteca com namespace acabam ficando na forma
libname_u32, e, a essa altura, dá vontade de simplesmente usaruint32_tem vez do prefixolibname_u8oui32é, no máximo, teórica, e soa um tanto como um espantalho argumentativoEle pode até se irritar, mas não ficará confuso. Como disse Rich Hickey, tudo é difícil de ler antes de você aprender a ler
Dizem que usar booleanos de 32 bits pode parecer desperdício de memória para iniciantes; se for assim, acho que eu também sou iniciante
Já ouvi alguns casos em que não é pior do que um
boolde 8 bits, mas não vejo casos em que seja de fato melhor. Se houver booleanos adjacentes em uma struct, ou se uma variável booleana de uma função for expulsa dos registradores e parar na pilha, ainda assim há desperdício de memóriaMesmo que sejam só alguns bytes, não entendo por que pessimizá-lo de propósito. O que se ganha usando um tamanho maior?
Havia uma estrutura com um valor condicional no início de cada estrutura por ciclo, seguido por 512, 1024 ou 2048 valores de amostra; um júnior, tentando economizar espaço, empacotou a estrutura e transformou o valor condicional em 1 byte de 8 bits
Esse código “melhorado” reduziu a vazão em cerca de 10 vezes em chips Intel e gerou BUS ERROR na arquitetura RISC SPARC
Ao empacotar o cabeçalho da estrutura, o array de dados ficou desalinhado; a Intel teve de buscar silenciosamente duas palavras de 32 bits e combiná-las, enquanto a SPARC, corretamente, reclamou de dados desalinhados
Se for um cálculo em pipeline em que a vazão importa, e não armazenamento de arquivo de longo prazo, às vezes é melhor alinhar os dados ao alinhamento da arquitetura do que empacotá-los para “economizar espaço”
Quase todos os compiladores fazem isso, então basta procurar por “alinhamento/padding de structs”. Se o compilador vai deixar espaço vazio de qualquer forma, é melhor usar essa memória explicitamente; caso contrário, você pode perder desempenho
Mais precisamente, cada campo deve ficar em um endereço divisível pelo seu próprio tamanho ou pelo tamanho da linha de palavra, e a estrutura inteira também deve receber padding até um múltiplo do tamanho do maior campo. Na prática, isso normalmente significa alinhamento de 32 bits
Referência: http://www.catb.org/esr/structure-packing/
boolreal, o sanitizer avisa quando o valor não é 0, ou seja,false, nem 1, ou seja,trueNão concordo com o argumento sobre retorno de structs e parâmetros de saída
Isso torna muito mais difícil compor funções que podem retornar erros e faz os tipos se multiplicarem por toda parte. Na prática, quase qualquer função pode falhar, então, especialmente se você também trata falta de memória, um estilo previsível de retorno de erros é mais importante
É muito difícil e traz pouquíssimo benefício. Nesse ponto, surgem problemas muito diferentes e muito maiores do que escolhas de estilo de programação
errnoe parâmetros de saída, mas C não tem issoAinda assim, compor valores opcionais em C sempre foi um pouco doloroso. Se você não usa exceções, exceções e mônadas parecem ser as duas grandes opções em várias linguagens, mas nenhuma das duas se encaixa em C nem na filosofia da maioria dos programadores C
Para chamadas simples um-para-um, dá para tentar macros, mas há limites. Por mais terrível que C++ seja, C++ optional é mais agradável de usar do que
if(foo(x,y, out1, out2) != WHATEVER_LIBRARY_OK) { ... }O padrão de colocar
if (thing(...)) goto failem toda chamada de função também não parece lá muito excelente, embora o pessoal de Go pareça gostarOu então há
thread_local mylibrary_errno, que internamente na biblioteca pode ser de fato a abordagem correta, e nas fronteiras pode ser convertido para um valor de retorno enumEm “signed sizes are the way”, dá vontade de dizer que bastava ler até aí
signed size é um vazamento de abstração muito surpreendente e um jeito de chamar desastre
Também é difícil aceitar a afirmação de que
constnão tem papel prático e nunca pegou erros. As pessoas confundem buffers de entrada e de saída com frequência, econstexpõe isso imediatamenteA orientação de deixar todas as funções, exceto os pontos de entrada, como
statictambém pode fazer você amaldiçoar o autor quando, ao depurar, não consegue encontrar uma variável ou funçãoPreferir retorno de structs facilita devolver por engano um ponteiro para a stack e abrir uma grande brecha de segurança. Ao passar um buffer de saída, a semântica de propriedade fica clara
Esse conselho pode ser mais ou menos adequado para quem escreve principalmente código de sistema em 64 bits, mas no mundo embarcado de 32 bits pode rapidamente virar um problema
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p14...
constnunca vai ficar satisfeito; então useconstonde for necessário, propague tanto quanto precisar e ignore as reclamaçõesSe eles removerem, coloque de volta. Eles sempre se cansam primeiro. Faço isso há 25 anos e ainda estou aqui
staticpode depender das ferramentas. Uns 15 anos atrás passei a usarstaticpor padrão esize_tem tudo, e ainda não tive problemasÉ interessante que minha experiência tenha me levado em outra direção
https://dlang.org/blog/2023/10/02/crafting-self-evident-code...
O texto é centrado em D, mas os princípios também se aplicam a C
Achei interessante a parte de mover a expressão condicional para dentro de
doX()edoZ(). Não sei se isso é sempre correto; depende de onde a abstração fica e do modelo mental que se tem do códigoPor exemplo,
deleteRecords();não é melhor queif let x = deadRecords() deleteRecords(x);. O segundo parece mais bagunçado, mas tem o valor de mostrar logo de cara que se trata de poda, não exclusãoSe a função for renomeada de forma inteligente para algo como
pruneDeadProjects(), tudo bem, mas simplesmente mover a condição para dentro da função pode tornar o contexto perigoso e virar uma abstração vazadaSou a favor de usar
typedefem todas as structs, pois ajuda na concisãoAcho que dá para usar
typedefgenerosamente. Mas é melhor dartypedefapenas no alvo em si, não em ponteiros. Se precisar de um ponteiro, basta usar(type *)quando quiserEm especial, no caso de ponteiros de função, o correto é dar
typedefna própria função, não no ponteiro de função. Assim, também é possível usar esse typedef nas declarações de funções para ter checagem de tipo dos parâmetros, e não é preciso corrigir todas as declarações ao mudar a assinatura da funçãoA maioria das bases de código C faz isso errado: dá
typedefno ponteiro de função e ainda precisa escrever manualmente declarações de funções compatíveis com aquela definição de ponteiroAinda não estou convencido sobre usar structs como tipo de retorno. Prefiro deixar um código numérico de erro como valor de retorno, e receber os demais valores de retorno por parâmetros de saída
typedefpara imitar classes em que todos os campos são privados, e usarstructpara estruturas de dados simplesClasses devem ser acessadas apenas por funções, enquanto structs devem poder ser acessadas diretamente
Isso, em geral, fica mais próximo das convenções dos padrões C/POSIX. É a diferença, por exemplo, entre
pthread_testruct stattypedefno ponteiro em siCoisas como SDL_net fazem exatamente isso, e eu não gosto. Na prática é um ponteiro, mas foi tipado com
typedefcomo se fosse um tipo por valorEntendo a intenção, mas é um jeito bem incômodo
Muita coisa neste texto faz sentido
Comecei a escrever um SO bare-metal para Arm64; ainda está no início, mas estou fazendo coisas parecidas. Uso strings Pascal e também mudei os nomes dos tipos. Só que no estilo
int8, nãoi8Decidi rapidamente que não pretendia portar software real, então não preciso seguir as funções nem as convenções da biblioteca C padrão. Isso me permite experimentar com mais liberdade
C é uma linguagem antiga o bastante para que o peso da época em que cada byte era precioso ainda apareça até nos nomes das funções. É bom se livrar disso, e o conteúdo deste texto, junto com várias pequenas mudanças de nome, parece uma arrumação bem limpa
typedef float f32;etypedef double f64;parecem uma base perigosa, pois assumem quefloattem 32 bits edoubletem 64 bitsO OpenCV define
float16_t, o CUDA implementa ponto flutuante de meia precisão, e microcontroladores podem ter implementações própriasO C++23 introduz tipos de ponto flutuante de largura fixa, mas não sei como impor isso em C. Parece melhor ter uma macro que verifique, em tempo de compilação, se não há perda de dados
No geral, como outros disseram, talvez seja melhor deixar algumas coisas nos valores padrão em prol da legibilidade, mesmo que não fique tão conciso
[0] https://docs.opencv.org/4.x/df/dc9/classcv_1_1float16__t.htm...
[1] https://docs.nvidia.com/cuda/cuda-math-api/group__CUDA__MATH...
[2] https://en.cppreference.com/w/cpp/types/floating-point
_Floattypedef _Float32 f32;typedef _Float64 f64;https://gcc.gnu.org/onlinedocs/gcc/Floating-Types.html