O que a linguagem C obviamente deveria fazer
(digitalmars.com)- O C padrão continuou mudando até o C23, mas Walter Bright vê que incômodos antigos, como avaliação de expressões constantes, referências antecipadas e dependência de headers, ainda atrapalham o fluxo de desenvolvimento
- O compilador C embutido no compilador D, o ImportC, aproveita a vantagem de ser uma implementação nova para executar, em tempo de compilação, funções que atendem às condições em posições de
constant-expression - Quando CTFE se torna possível, testes unitários como
_Static_assert(sum(3, 4) == 7,...)podem ser executados a cada compilação sem um executável separado - A restrição de ordem das declarações no C padrão leva à repetição de declarações antecipadas e à organização do código em ordem inversa, mas o ImportC adota uma abordagem menos presa à ordem das declarações globais
- Ao importar diretamente as declarações de arquivos
.c, como em__import dex;, é possível reduzir arquivos.hseparados e também diminuir o esforço de depuração causado por divergências entre header e implementação
Incômodos que permanecem no C padrão
- O C padrão foi aprimorado regularmente até o C23, mas Walter Bright vê que ainda há partes que não foram corrigidas
- A comunidade Dlang embutiu um compilador C dentro do compilador da linguagem de programação D, e chama esse compilador de ImportC
- O ImportC foi criado do zero, então havia espaço para lidar com as deficiências do C usando tecnologias modernas de compiladores
- O texto aborda quatro itens
- avaliação de
constant-expression - testes unitários em tempo de compilação
- referências antecipadas em declarações
- importação de declarações
- avaliação de
Execução de funções em tempo de compilação e avaliação de expressões constantes
- C consegue calcular expressões simples em tempo de compilação por meio de constant folding, mas, no C padrão, não é possível executar funções em tempo de compilação
- Ao compilar com gcc o código de exemplo
enum E { A = 3, B = 4, C = sum(5, 6) };, ocorre um erro dizendo que o valor do enumeradorCnão é uma constante inteira - O ImportC consegue compilar o mesmo código
- A direção proposta é que o compilador consiga executar funções em tempo de compilação em todos os lugares da sintaxe C em que aparece uma
constant-expression - No entanto, essa função não deve realizar operações como I/O, acesso a variáveis globais mutáveis ou chamadas de sistema
Testes unitários executados a cada compilação
- Quando CTFE, a avaliação de funções em tempo de compilação, se torna possível, a forma de fazer testes unitários também pode mudar
- Um motivo para testes unitários serem raros em código C é o incômodo de ter de criar e executar um alvo de build e um executável separados
- Ao compilar com gcc o código de exemplo
_Static_assert(sum(3, 4) == 7, "test #1");, ocorre um erro dizendo que a expressão dentro da asserção estática não é constante - O ImportC consegue compilar esse código
- Essa abordagem simplifica os testes unitários de funções executáveis em tempo de compilação
- Não é necessário um build separado
- Não é necessário trabalho adicional
- Os testes são executados sempre que o código é compilado
- A suíte de testes do ImportC também usa amplamente essa abordagem
Repetição de declarações causada por referências antecipadas
- No C padrão, como o compilador só conhece declarações que aparecem antes lexicalmente, referências antecipadas não são permitidas
- No código de exemplo, se
floo()chama primeirodex(), que é definido depois, o gcc emite um erro dizendo que a declaração implícita dedexe o tipo da definição real entram em conflito - Ao trocar a ordem de
flooedex, o mesmo código compila normalmente - O ImportC consegue aceitar declarações globais em qualquer ordem
- Sem referências antecipadas, é preciso adicionar uma declaração separada para cada definição posterior
- Primeiro escrever uma declaração como
char dex(char *s, int i); - Depois escrever novamente a definição da função
- Primeiro escrever uma declaração como
- Essa abordagem é trabalho repetitivo desnecessário e faz o programador adotar uma organização em ordem inversa, colocando funções folha no topo e funções de interface global embaixo
- O texto critica essa organização como algo sem sentido, equivalente a ler uma notícia de jornal de baixo para cima
Importação de declarações sem arquivos header
- Em C, para usar um módulo externo, normalmente é preciso criar declarações em um arquivo
.he incluí-lo no arquivo.c - A estrutura de exemplo é a seguinte
floo.cincluidex.he chamadex()dex.hdeclarachar dex(char *s, int i);dex.cincluidex.he definedex()
- Criar um arquivo
.hpara cada módulo externo aumenta o trabalho repetitivo - Se o arquivo
.hnão corresponder exatamente ao arquivo.c, pode levar muito tempo para descobrir o que está errado - A abordagem proposta é importar diretamente
dex.c- Usar
__import dex;emfloo.c - Em
dex.c, deixar apenas uma definição comochar dexx(char *s, int i) { return s[i]; }
- Usar
- Com essa abordagem, não é necessário escrever nenhum arquivo
.h - O ImportC também oferece suporte a essa abordagem
Documentos relacionados
- ImportC: documentação do ImportC
- D Language: documentação da linguagem D
1 comentários
Comentários no Hacker News
Uma das coisas de que mais sinto falta quando uso linguagens que não são C são os arquivos de cabeçalho
Gosto especialmente, no código C, de como público/privado e interface/implementação ficam muito claramente separados. É bom conseguir entender como usar uma biblioteca apenas passando os olhos pelo arquivo
.h; normalmente o.hcontém a documentação de uso e não duplica o.c. Também dá para colocar a documentação no.c, mas, do ponto de vista de quem usa, ler a interface fica muito menos agradávelPor exemplo, em Rust, para ver a interface e o uso de uma biblioteca, basta
cargo doc --open. Toda a API pública é gerada automaticamente em formato pesquisável, sem necessidade de duplicar ou manter manualmente código entre cabeçalho e fonteEles só funcionam direito quando as convenções são seguidas e ficam pobres em comparação com linguagens como Ada, em que as especificações de interface e implementação foram bem projetadas e não exigem parsing repetido. Gosto de usar C, mas essa parte deveria ter sido mais bem projetada
Os arquivos de cabeçalho de C funcionam inserindo texto literalmente, então ficam no nível de “parece que funciona mais ou menos”. Já Ada tinha os conceitos de pacote e corpo de pacote; o pacote correspondia ao arquivo de cabeçalho, e o corpo do pacote, à implementação. Quando usei Ada há muito tempo, mesmo que a implementação do corpo do pacote ainda não estivesse pronta, todos podiam compilar com base no pacote, permitindo alinhar a interface antes da implementação. Em outra direção, gosto do
importdo Python como algo que cumpre o papel de “arquivo de cabeçalho”, porque ele mapeia naturalmente para o sistema de arquivos e não exige lidar com a semântica de include de CQuando existem arquivos de cabeçalho, você passa a pensar na interface como algo diferente da implementação. Por isso, quem está acostumado a esse modo se sente desconfortável com a ideia de a interface ser gerada por ferramentas. A interface não é um subproduto da implementação; ela é algo projetado separada e intencionalmente, e, para algumas pessoas, é mais importante que a implementação. Por outro lado, quem está acostumado com documentação gerada automaticamente se sente desconfortável quando a interface não é gerada a partir da única fonte da verdade, que é o código da implementação. Depois de usar por muito tempo linguagens com arquivos de interface separados e linguagens sem eles, as pessoas acabam se fixando em seus campos e esquecem a sensação de pensar do jeito oposto
Seja mantendo separado, como Modula-2, Modula-3, Ada, Standard ML, Caml Light, OCaml, F# e D, seja podendo gerar por ferramentas textuais ou gráficas, como Object Pascal, D, Haskell, Java, C#, F#, Swift, Go e Rust. Todas têm tipagem mais forte, compilação mais rápida e namespaces de verdade. As toolchains de Rust e Swift ainda têm pontos a ajustar, mas as ferramentas de C sempre foram primitivas em comparação com o que acontecia fora da Bell Labs. Se a AT&T tivesse podido explorar isso comercialmente, a história poderia ter sido diferente; no fim, recebemos limões grátis em vez de laranjas maduras. Ainda assim, criaram algo como um TypeScript para C, e hoje ele também oferece suporte a tipos de coleção com verificação de limites e módulos de verdade
Como o autor tem muito mais conhecimento e experiência nessa área, fico curioso sobre como ele resolveria os seguintes problemas
A avaliação de expressões constantes fica simples se for feita apenas dentro de uma unidade de tradução, mas isso limita bastante o que se pode fazer sem repetição de código. Testes unitários em tempo de compilação são possíveis até certo ponto se os testes puderem ser expressos como macros, e ficam mais fáceis com o item anterior. Referências antecipadas a declarações podem gerar bastante resistência, porque o compilador passaria de 1 passe para 2 passes, com impacto de desempenho. Para quem compila bases de código enormes e ainda paraleliza unidades de tradução, isso pode ser difícil de aceitar. Importar declarações é uma mudança que quebra compatibilidade. Em C, já implementei algo parecido com templates definindo uma variável, importando um arquivo
.c, alterando a variável e importando o mesmo.cde novo. Também já coloquei várias definições antes do SQLite C Amalgamation e adicionei funções para expor funções internas; todos esses usos parecem que seriam quebrados. Fico curioso se existe uma solução para esses problemasEm C, isso pode ser feito com
#include; em D, isso é tratado importando o módulo que contém o código necessário. A abordagem de expressar testes como macros não serve quando se quer testar funções. O exemplo foi simplificado para facilitar a compreensão, e o uso real pode ser muito mais complexo. Em termos de desempenho, D compila mais rápido que compiladores C, principalmente porque o pré-processador de C é uma estrutura fossilizada que exige vários passes, enquanto D usa import em vez de#includee não recompila arquivos.hrepetidamente. A estratégia de D é separar parsing e análise semântica e, embora possa ser um pouco mais lenta, não há o custo de recompilar declarações duplicadas e condensá-las em uma só. A execução de funções em tempo de compilação pode virar gargalo se usada em excesso, mas, com uso leve, o desempenho é bom. Se você está implementando templates em C por meio de hacks, já passou dos limites da linguagem e precisa de uma linguagem mais poderosa. D tem metaprogramação de primeira linha, e outras linguagens com templates frequentemente seguem o caminho aberto por DVocê deixa de poder acreditar que o grafo de dependências está disposto em ordem topológica
Também fico curioso se compiladores C modernos realmente ainda são de passe único
O exemplo de avaliação de expressão constante no artigo é bem simples, mas, em casos mais complexos, a velocidade do compilador e o uso de memória podem piorar bastante, e parece que seria necessária uma máquina virtual para aproveitar isso
Então dá para entender a possibilidade de ter sido considerado “complexo demais” para entrar no padrão. Eu preferiria que C++ ou C tivessem seguido na direção de importação de declarações, em vez daquela mistura estranha definida no C++20. Por exemplo, algo como
#import "string.c" as str, importando um módulo sob algum símbolo e permitindo acessar todos os símbolos não estáticos do arquivo como emstr.trim(" Hello World ");. Separadamente, não gosto de algo como__import dex;, em que o caminho do arquivo não é explícito. Nesse caso, não dá para saber se ele está importandodex.doudex.cSe usado bastante, naturalmente vai consumir tempo de compilação e memória. Quanto à máquina virtual, o próprio dobramento de constantes já é uma máquina virtual; seria uma extensão disso para tratar chamadas de função. A semântica de C é simples, então não é tão ruim. O modo de importação proposto é quase igual ao que o import de D faz: https://dlang.org/spec/module.html#import-declaration. O problema de ser
dex.doudex.crealmente acontece, e a resposta é a configuração dos caminhos de importação. É parecido com os caminhos de include de um compilador CC já tem expressões constantes. O obstáculo maior é que o compilador precisa ter acesso ao código-fonte da função, então provavelmente isso ficaria limitado a funções na mesma unidade de tradução. E também pode ser um problema humano bem maior: um comitê com representantes de vários compiladores teria que chegar a um acordo sobre a semântica dessa avaliação de constantes
constexprde C++Como quase todos os compiladores C já são também compiladores C++, fico em dúvida se dar suporte a funções e avaliação
constexprem C poderia ser tão ruim assimSempre escrevo testes unitários para código C
Com um bom sistema de build e aceitando um pouco de boilerplate, não é difícil. Os testes da biblioteca
npychamamnpy_load("tests/npy/uint8.npy")emtest_load_uint8(), fazemassertdas dimensões, tamanho e tipo, depois chamamnpy_free, e nomainrodam algo comoPRINT_RUN(test_load_uint8);. Seria possível gerar parte dos testes com o pré-processador, mas prefiro manter simplesOs exemplos de testes unitários do compilador ImportC são coisas como
_Static_assert(sizeof(struct S22079){1,2,3} == sizeof(int)*3, "ok");e_Static_assert(sizeof(struct S22079){1,2,3}.a == sizeof(int), "ok");. Como a semântica é verificada em tempo de compilação, não há necessidade de linkar e executar. Quanto mais testes houver, mais esse método fica consideravelmente rápido; e quanto mais rápido a suíte de testes roda, maior a produtividadeEm certo momento investiguei a fundo frameworks sofisticados de testes unitários, mas percebi que não havia muito ganho e acabei adotando uma forma quase idêntica. Um ponto que outras pessoas podem ver mal é que eu aceito fazer
#includedo arquivo.csob teste para chamar funções estáticas. Incluo apenas um único arquivo.c. Além disso, antes do#include, faço um pequeno tratamento com o pré-processador para impedir queNDEBUGfique definido, evitando o caso de alguém compilar em “modo release” e desativar osassertsUso algo parecido: https://github.com/ensisoft/detonator/blob/master/base/test_.... Peguei muita coisa emprestada de
boost.test.minimale originalmente era um único header, mas, com o passar dos anos, precisei adicionar uma única unidade de tradução. Minha conclusão é que, se você mantém a base de código em um estado em que os testes sempre passam, passa a precisar muito menos de complexidades como bons relatórios de erro da ferramenta de teste ou tolerância a falhasTestes unitários em tempo de compilação são uma ideia tão ruim quanto transformar “imports/variáveis/resultados não usados” em erros em vez de avisos
É um “recurso babá” que tira o controle do desenvolvedor e acaba fazendo a pessoa passar por procedimentos burocráticos só para terminar o trabalho. Esses testes que quebram o build são bons para builds do tipo “acho que agora está pronto”, mas não servem para os builds “ainda estou trabalhando”, que são 99% dos builds. É como dizer “você não pode usar a serra de mesa antes de guardar a furadeira”
Se você tentou expressar uma ideia e o teste em tempo de compilação diz que ela está errada, talvez essa ideia ainda esteja incompleta ou você não tenha pensado o suficiente em todas as consequências daquela expressão. É parecido com a verificação de tipos em Haskell. Um programa que não passa na verificação de tipos não pode ser compilado, e isso força o programador a expressar sempre apenas ideias completas. Em teoria, isso pode levar a programas mais bem refletidos. Por outro lado, torna a escrita mais difícil, porque força o programador a descobrir até os cantos que ele “sabe que são inválidos, mas não se importa”
static_asserte têm muito valor para detectar usos incompatíveis de funções de bibliotecaAcho uma ideia bastante boa
“funções leaf vêm primeiro e funções de interface global vêm por último” é o inverso para mim
Por vários motivos, prefiro escrever código em ordem topológica. Isso se parece com a forma como se escreve código dentro de uma função, deixa claro onde colocar as funções dentro de um módulo e, mais importante, torna muito evidentes as dependências circulares entre trechos de código internos do módulo. Não gosto muito de dependências circulares, porque elas deixam a base de código mais emaranhada e dificultam entender um módulo como uma unidade independente. Em Python, elas também podem criar problemas que só aparecem em tempo de execução[0], e imports circulares são tão comuns que parece que os verificadores de tipos atuais deixam esse diagnóstico desativado por padrão[1]. Linguagens como C, OCaml e SML, que não dão suporte a referências à frente, permitem aplicar o “princípio da menor surpresa” às dependências circulares. O OCaml até proíbe dependências recursivas entre funções, a menos que sejam declaradas como
let rec fn1 = .. and fn2 = ..; isso é um pouco incômodo ao escrever, mas se torna uma informação importante na leitura[0]: https://gist.github.com/Mark24Code/2073470277437f2241033c200...
[1]: https://microsoft.github.io/pyright/#/configuration?id=type-... (veja
reportImportCycles)Há a explicação de que, como o compilador só conhece o que vem antes lexicalmente, a ordem acaba sendo “de baixo para cima”, com funções leaf primeiro e funções de interface global por último, mas essa ordem também é comum em linguagens que permitem referências à frente, como Python[0]
Fico curioso se isso é um resquício de linguagens que não permitem referências à frente, ou se é uma abordagem que de fato faz mais sentido para certos tipos de código
[0] https://stackoverflow.com/a/73131538
O que acontece é apenas que identificadores dentro do corpo de uma função não são resolvidos até a própria função ser executada e, nesse momento, tudo no escopo do módulo já está definido. Dá para verificar isso diretamente colocando qualquer nome no corpo de uma função e carregando o módulo
Não é que eu tenha uma regra rígida, mas ler assim me parece mais limpo e faz mais sentido. Especialmente quando a implementação é grande, não quero ter que rolar por tudo isso para só então ver basicamente o que ela faz. Fico curioso para saber como outras pessoas fazem
Entre as “coisas óbvias” que eu gostaria que C fizesse estão suporte a tipos slice contendo ponteiro e comprimento, versões reentrantes e, se possível, thread-safe de APIs que usam estado global, padronização de algo como
deferde Go ou Zig, ou o atributocleanupdo GCC, e suporte portável a Unicode e UTF-8A ideia de que, em todos os lugares da sintaxe de C onde aparecem expressões constantes, o compilador deveria poder executar funções em tempo de compilação, desde que a função não faça I/O, não acesse variáveis globais mutáveis, não faça chamadas de sistema etc., pode quebrar facilmente
Basta escolher uma função pura que rode por muito tempo. Por exemplo, se
int busybeaver(int n) {...}for uma função pura que retorna o tempo máximo de vida de uma máquina castor ocupado de n estados, um código comoint x = busybeaver(99);vira um problemaC23 tem
constexpr, mas ele ainda não pode ser aplicado a funçõesPorém existe uma proposta: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf