Infdev #01 - C é uma linguagem com problemas, e porquê eles são importantes.
Muito se fala sobre como C é uma linguagem difícil de se masterizar, que é perigosa de ser utilizada e que é muito fácil gerar problemas de memória e segurança. E tudo isso está correto. C é uma linguagem muito fácil de se aprender, porém muito difícil de se masterizar, e é uma linguagem que pode muito facilmente corromper memória e/ou vazar memória e consumir 100% dos seus recursos fazendo o seu sistema inteiro crashar. E todo ano surge uma linguagem com o objetivo de substituir C. Rust é a mais famosa, porém além dessas existem Nim, Zig, C3, Go… Todas prometendo uma forma mais segura, de fazer o que se faz com C, e elas possuem suas vantagens e funcionam para a grande maioria dos casos. No entanto, essas linguagens nunca poderão substituir C por completo, e o motivo disso é que C possui problemas.
Breve historia
C é uma linguagem velha, ela surgiu em 1972, porém sua história começa em 1963 na Universidade de Cambridge, com CPL. CPL foi criado tendo como inspiração ALGOL 60, com escrever compiladores como um dos principais objetivos, porém tinha alguns problemas.
- CPL era uma linguagem muito pesada para a época.
- CPL foi projetada em 1963, porém demorou quase 10 anos para que um compilador fosse criado.
Resultado, CPL nunca foi amplamente utilizada, e em 1967 uma nova versão mais simples e leve de CPL foi criada, BCPL. BCPL, diferentemente de CPL, teve um uso considerável na época. BCPL tinha 1 grande problema, o único tipo disponível era word, uma word é um tipo que ao invés de dizer o tamanho, e como o valor deve ser interpretado, diz apenas o tamanho, e a forma de interpretar o valor é de responsabilidade do desenvolvedor. A diferença entre um int32 e um float32 é apenas como ele é interpretado pelo compilador, uma word apenas diz qual o tamanho, e o tamanho é definido pela arquitetura processador, um processador 64 bits possui words de 64 bits, um de 32 bits tera words de 32 bits. Em resumo, uma word é o tamanho máximo que um valor pode ter em uma arquitetura específica. BCPL era bem mais leve que CPL, porém ainda existia um certo peso que segundo Ken Thompson era desnecessário, então baseado em BCPL ele criou o B. B era uma versão ainda mais reduzida de CPL, com menos gordura, algumas mudanças de sintaxe e outros detalhes para fazer B rodar na maioria dos computadores da época, porém B ainda tinha o problema de apenas ter o tipo word.
Possuir apenas o tipo word é um problema, e para entender isso vamos ter uma rápida aula de Arquitetura de Computadores. A memória de um computador nada mais é do que uma grande lista consecutiva de valores, imagine uma estante, que possui N prateleiras, e cada prateleira é dividida em M partes com 1 byte(8 bits) de espaço, um processador de 32 bits consegue lidar com valores de no máximo 4 bytes(32 bits), portanto, cada prateleira possui 4 espaços. Agora imagine que o seu computador de 32 bits tem uma memória com 1000 prateleiras, cada uma com 4 espaços, teoricamente seria possível guardar até 4000 elementos de 1 byte nela, e a única regra é que um valor não pode ser dividido entre prateleiras, um valor de 3 bytes, precisa ser armazenado em 3 bytes consecutivos na mesma prateleira, um valor de 5 bytes por exemplo não poderia ser armazenado de forma nessa prateleira. Existe a flexibilidade para lidar com valores de diferentes tamanhos, se você só precisa de 2 bytes, encontre uma prateleira com 2 lugares consecutivos disponíveis e guarde-o lá, se precisa de 4 utilize 4. Ter apenas word como tipo, é como se você jogasse fora as divisórias da prateleira, e apenas 1 valor pudesse ser guardado por prateleira independente do tamanho, se você só precisa de apenas 1 byte, vai ocupar toda a prateleira e desperdiçar 3 bytes, isso gerava um grande desperdício de memória, pois se você quiser salvar 4 valores de 1 byte, iria ocupar 16 bytes ao invés de 4. Algo precisava ser feito para resolver esse desperdício, e algo foi feito, em 1972 Dennis Ritchie e Ken Thompson criaram o sucessor de B, o C.
A grande diferença entre B e C, é que C possui tipos de diferentes tamanhos, com o menor tipo tendo 1 byte, e o maior tendo o tamanho de uma word. Agora não existe mais o desperdício de guardar um valor de 1 byte e ocupar 4 bytes, tudo poderia ter o tamanho necessário para melhor proveito da memória. E dá para imaginar que C teve mais sucesso que seus antecessores, especialmente que hoje em dia, 53 anos depois do surgimento de C, falar “A linguagem que veio antes de C foi B”, parece uma piada ruim.
Resultado
C é o resultado de 3 níveis de simplificação de CPL, onde cada nível foi reduzindo a complexidade da linguagem e aumentando a dificuldade de uso por consequência. C é tão difícil de ser utilizada por ser feita para gerar o código mais otimizado possível em uma época onde os compiladores não eram nem perto do que são hoje em questão de otimizações, e para atingir essa otimização, muitas das qualidades de vida que temos hoje em dia não existem, especialmente em relação a gerenciamento de memória, e mesmo os compiladores atuais são incapazes de otimizar certos tipos de código sem mudar a execução esperada. O maior problema causado por toda essa simplificação já era conhecido desde dessa época: os Undefined Behaviors.
Undefined Behavior
Um Undefined Behavior (Comportamento Indefinido) é qualquer evento cujo resultado não pode ser determinado apenas pela leitura do código fonte. Como por exemplo:
Não existe garantia sobre o valor de uma variável declarada e não inicializada, pode ser qualquer coisa aleatória.
1
2
3
4
5
_Bool p; // Variavel declarada mas não inicializada
if (p)
puts("p is true");
else
puts("p is false");
Ler itens fora do range de um array causa leitura de memória não inicializada.
1
2
3
int lista[] = {1, 2, 3};
for (int i = 0; i < 4; i++) // Array com 3 elementos, porem lemos ate o 4º
printf("%i \n", lista[i]);
Ler o valor de um ponteiro depois dele sofrer free irá ler qualquer lixo que tenha na memória.
1
2
3
4
5
6
int *x = malloc(sizeof(int));
*x = 20;
printf("%i \n", *x); // 20
free(x);
printf("%i \n", *x); // qualquer lixo que tenha na memoria
Existem mais de 100 casos de UB, e boa parte deles se baseia no uso de memória não inicializada. Muitos dos UBs poderiam ser detectados durante a compilação, e os que não podem são detectáveis em runtime para serem tratados, como uma exceção ou algo do tipo. É uma dúvida comum o porquê desses problemas não terem sido resolvidos depois de tanto tempo, já que eles são conhecidos desde a origem da linguagem, e hoje em dia não temos as mesmas limitações que tínhamos em 1972. E a resposta é simples e única: eficiência.
ABS: Atualmente existem ferramentas para detectar boa parte dos UBs mais comuns, especialmente as que lidam com memória e ponteiros. Irei falar mais sobre isso no próximo post.
Tudo vem com um custo. Cada verificação de limite de array, cada sanitização de valores não inicializados, cada verificação de null pointer, cada detalhe que tantas linguagens tratam automaticamente vem com um custo. Pode ser um custo mínimo, como no exemplo abaixo: a diferença entre um valor não inicializado e um inicializado é literalmente uma linha a mais no Assembly final: 
Mas outras verificações de UB podem ser mais custosas, especialmente quando se trata de memória, pois gerenciamento automático de memória com garbage collector é algo muito pesado que pode causar problemas em certos cenários, ao ponto de ser proibido o uso de linguagens com garbage collector como C# ou Go em alguns cenários de software crítico, pois você nunca se tem 100% de certeza de como o Garbage Collector vai funcionar.
Para 90% dos casos, o custo de adicionar verificações para UB não será algo particularmente notável ou crítico. Porém, nos outros 10%, o custo adicional de verificação se torna notável, e dentro desses 10% existem casos ainda mais extremos onde isso se torna um problema. Por exemplo, se você está escrevendo um sistema web que não terá um fluxo grande de visitantes, as vantagens de se utilizar uma linguagem de alto nível como Python ou JavaScript são mais valiosas do que as vantagens de C. Porém, mudando de cenário, você foi contratado para escrever um driver para um novo microfone, e você precisa do mínimo possível de delay entre o recebimento do sinal e o processamento. Nesse caso, linguagens interpretadas não são uma opção, pois você precisa de algo compilado, e as vantagens do uso de C se tornam mais apreciáveis. Para um caso mais extremo: você quer fazer um jogo pra Nintendo 64 por hobby, com 4MB de RAM, um processador de 93MHz com no máximo 94MB de espaço no cartucho para guardar tudo que seu jogo precisa. De repente, C se torna a única opção viável para o trabalho.
Conclusão
Todas as linguagens que tentam ser uma versão mais moderna de C pecam no momento em que a primeira coisa a ser “resolvida” são os Undefined Behaviors e o Gerenciamento de Memória manual. Tudo em computação possui trade-off, e isso não seria diferente para performance. Você pode usar Python, que é famoso por ter péssima performance, se o que você está fazendo não precisa ser performático. Existem inúmeros casos onde a performance disponibilizada por C simplesmente não é necessária. Porém, há casos onde é impossível resolver o problema de forma correta e prática sem o uso de C, justamente pelos problemas de C.