Unixtopia

main/ artigos/

Armadilhas de C

C é uma linguagem poderosa que oferece controle quase absoluto e desempenho máximo, o que necessariamente vem com responsabilidade e perigo de dar tiro no próprio pé. Sem conhecimento das armadilhas, você pode acabar caindo nelas. Este artigo será focado em armadilhas específicas e típicas de C, mas claro que C também vem com armadilhas gerais de programação, como aquelas relacionadas a ponto flutuante, simultaneidade, bugs como off by one e assim por diante. A menos que especificado de outra forma, este artigo usa o padrão C99 da linguagem C. Certifique-se de verificar seus programas com ferramentas como valgrind, splint, cppcheck, UBSan ou ASan, e ative as verificações automáticas do compilador, -Wall, -Wextra, -pedantic, é rápido, simples e revela bugs.

Comportamentos indefinidos e não especificados

Comportamento indefinido, imprevisível, não especificado, seguro, mas potencialmente diferente, e definido pela implementação, consistente dentro da implementação, mas potencialmente diferente entre elas, apresenta um tipo de comportamento imprevisível, não intuitivo e complicado de certas operações que podem diferir entre compiladores, plataformas ou execuções porque não são exatamente descritas pela especificação da linguagem, isso é feito de propósito para permitir alguma liberdade de implementação que permita implementar a linguagem de uma forma que seja mais eficiente em uma plataforma. É preciso ter muito cuidado para não deixar que tal comportamento quebre o programa em plataformas diferentes daquela em que o programa foi desenvolvido. Observe que ferramentas como cppcheck podem ajudar a encontrar comportamento indefinido no código. Há ferramentas para detectar comportamento indefinido, veja UBSan em clang.llvm.org/docs/UndefinedBehaviorSanitizer.html do clang.

Tamanhos de tipo de dados, incluindo int e char, podem não ser os mesmos em cada plataforma. Embora quase tomemos como certo que char tem 8 bits de largura, em teoria pode ser diferente, embora sizeof(char) seja sempre 1. A largura do tipo int, e unsigned int, deve refletir o tipo inteiro nativo da arquitetura, então hoje é principalmente 32 ou 64 bits. Para lidar com essas diferenças, podemos usar os cabeçalhos limits.h e stdint.h da biblioteca padrão. Nenhuma endianidade específica ou mesmo codificação de números é especificada. Hoje little endian e complemento de dois é o que você encontrará na maioria das plataformas, mas o PowerPC usa ordenação big endian. Ao contrário de variáveis globais, valores de variáveis locais não inicializadas não são definidos. Variáveis globais são inicializadas automaticamente para 0, mas não as locais, isso leva a bugs desagradáveis, pois às vezes as variáveis locais serão inicializadas com 0, mas param de ser em diferentes níveis de otimização, então tome cuidado. Demonstração:

int a; // Inicializado automaticamente para zero

int main(void)
{
  int b; // valor indefinido!
  return 0;
}

A ordem de avaliação de operandos e argumentos de função não é especificada. Em uma expressão ou chamada de função não é definido quais operandos ou argumentos serão avaliados primeiro, a ordem pode ser completamente aleatória e pode diferir mesmo ao avaliar a mesma expressão em outro momento. Isso é demonstrado pelo código a seguir:

#include <stdio.h>

int x = 0;

int a(void)
{
  x += 1;
  return x;
}

int main(void)
{
  printf("%d %d\n",x,a()); // pode imprimir 0 1 ou 1 1
  return 0;
}

O comportamento de estouro de operações de tipo assinado não é especificado. Às vezes supomos que a adição de 2 inteiros assinados que estão além do limite do tipo de dados produzirá estouro de complemento de dois, wrap around, mas, na verdade, o comportamento dessa operação é indefinido, o C99 não diz qual representação deve ser usada para números. Para portabilidade, previsibilidade e prevenção de bugs, é mais seguro usar tipos não assinados, mas a segurança pode ter o custo do desempenho, você impede o compilador de executar algumas otimizações com base no comportamento indefinido. Deslocamentos de bits por largura de tipo ou mais são indefinidos. Também deslocamentos de bits por valores negativos são indefinidos. Então x >> 8 é indefinido se a largura do tipo de dados de x for 8 bits ou menos.

A assinatura do tipo de dados char não é definida. A assinatura pode ser explicitamente forçada especificando signed char ou unsigned char. Os resultados de ponto flutuante não são especificados com precisão, nenhuma representação, como IEEE 754, é especificada e podem aparecer pequenas diferenças nas operações de ponto flutuante em diferentes máquinas ou nas configurações de otimização do compilador, isso pode levar ao não determinismo.

Insegurança de memória

Além de ser extremamente cuidadoso ao escrever código seguro de memória, é preciso saber também que algumas funções da biblioteca padrão são inseguras para memória. Isso se refere principalmente a funções de string como strcpy ou strlen que não verificam os limites de string, elas dependem de não receber uma string que não seja terminada em zero e podem potencialmente tocar a memória em qualquer lugar além, alternativas mais seguras estão disponíveis, elas têm um n adicionado no nome e permitem especificar um limite de comprimento.

Tenha cuidado com ponteiros, ponteiros são rígidos e propensos a erros, use-os com sabedoria e moderação, atribua NULLs a ponteiros liberados e assim por diante. Cuidado com vazamentos de memória, tente evitar alocação dinâmica, alocação estática e automática é suficiente, e se você tiver que usá-la, simplifique-a o máximo que puder e, adicionalmente, verifique tudo duas ou três vezes, manualmente, bem como com ferramentas como valgrind.

Comportamento diferente entre C e C++ e diferentes padrões C

C não é um subconjunto de C++, nem todo programa C é um programa C++, imagine um programa C no qual usamos a palavra classe como identificador, é um programa C válido, mas não um programa C++. Um programa C que é ao mesmo tempo também um programa C++ pode se comportar de maneira diferente quando compilado como C vs C++, pode haver uma diferença semântica. É claro que tudo isso também pode ser aplicado entre diferentes padrões de C, não apenas entre C e C++. Por questões de portabilidade, é bom tentar escrever código C que também será compilado como C++ (e se comportará da mesma forma). Para isso devemos conhecer algumas diferenças básicas de comportamento entre C e C++.

Uma diferença é nesse tipo de caractere, os literais são int em C, mas char em C++, então sizeof('x') provavelmente produzirá valores diferentes. Outra diferença está, por exemplo, nos ponteiros para literais de string. Enquanto em C é possível ter ponteiros não const, como:

char *s = "abc";

C++ exige que qualquer ponteiro seja const, ou seja:

const char *s = "abc";

C++ geralmente tem tipagem mais forte, por ex. C permite atribuir um ponteiro para void a qualquer outro ponteiro, enquanto C++ requer conversão de tipo explícita, normalmente vista com malloc:

int *array1 = malloc(N * sizeof(int)); //válido apenas em C
int *array2 = (int *) malloc(N * sizeof(int)); //válido em C e C++

C permite pular a inicialização, declarações de variáveis, como gotos ou switches, C++ proíbe isso. Há uma lista bastante detalhada está em en.wikipedia.org.

Otimizações do compilador

Compiladores C realizam otimizações automáticas e transformações do código, especialmente quando você lhes diz para otimizar agressivamente com -O3, o que é uma prática padrão para fazer os programas rodarem mais rápido. Isso faz com que os compiladores executem muita mágica e pode levar a comportamentos indesejados inesperados e não intuitivos, como bugs ou até mesmo a "não otimização do código". A otimização agressiva pode, em primeiro lugar, levar a pequenos bugs em seu código que se manifestam de maneiras muito estranhas; pode acontecer que uma linha de código em algum lugar que possa de alguma forma desencadear algum comportamento indefinido complicado possa fazer com que seu programa trave em algum lugar completamente diferente.

Compiladores exploram o comportamento indefinido para fazer todos os tipos de raciocínio cerebral e quando veem código que pode levar a um comportamento indefinido, muito raciocínio em cadeia pode levar a resultados compilados estranhos. Lembre-se de que comportamento indefinido, como estouro ao adicionar números inteiros assinados, não significa que o resultado seja indefinido, significa que qualquer coisa pode acontecer, o programa pode simplesmente começar a imprimir coisas sem sentido por conta própria ou seu computador pode explodir. Pode acontecer que a linha com comportamento indefinido se comporte como você espera, mas em algum momento mais tarde o programa simplesmente se cague. Por esses motivos, se você encontrar um bug muito estranho, tente desabilitar as otimizações e veja se ele desaparece, se acontecer, você pode estar lidando com esse tipo de coisa. Portanto, verifique seu programa com ferramentas como o cppcheck.

As otimizações automáticas também podem ser perigosas ao escrever código multithread ou de nível muito baixo, como um driver, no qual o compilador pode ter suposições erradas sobre o código, como a de que nada fora do seu programa pode alterar a memória do seu programa. Considere, por exemplo. o seguinte código:

while (x)
  puts("X is set!");
Normalmente o compilador poderia otimizar isso para:
if (x)
  while (1)
    puts("X is set!");

Como no código típico, isso funciona da mesma forma e é mais rápido. Porém se a variável x fizer parte da memória compartilhada e puder ser alterada por um processo externo durante a execução do loop, essa otimização não poderá mais ser feita, pois resulta em um comportamento diferente. Isso pode ser evitado com a palavra-chave volátil que informa ao compilador para não realizar tais otimizações. É claro que isso também se aplica a outras linguagens, mas C é especialmente conhecido por ter muitos comportamentos indefinidos, então tome cuidado.

Outros

Coisas básicas: = não é ==, | não é ||, & não é &&, índices de array começam em 0, e não 1. Há também pegadinhas mais profundas como a/*b não é a / *b, a primeira é comentário. Também fique atento a esta: != não é =!. if (x != 4) e if (x =! 4) são duas coisas diferentes, a primeira significa diferente e geralmente é o que você quer, a última são duas operações, = e !, o complicado é que também compila e pode funcionar como esperado em alguns casos, mas falhar em outros, levando a um bug muito desagradável. A mesma coisa com -= vs =- e assim por diante. Veja operador downto. Outro erro comum é um ponto e vírgula após a condição if ou while, isso compila, mas não funciona corretamente. Observe a diferença entre essas duas instruções if:

if (a == b);
  puts("aaa"); // imprimirá sempre
  
if (a == b)
  puts("aaa"); // imprimirá somente se a == b

Impulsionado por nada. Todo conteúdo é disponível sob CC0 1.0 domínio público. Envie comentários e correções para Mr. Unix em victor_hermian@disroot.org.