1 pontos por GN⁺ 19 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • As regras da linguagem C podem fazer até códigos que parecem simples, como comparação de ponteiros, aliasing, ponteiro nulo e valores não inicializados, se tornarem comportamento indefinido
  • Constantes inteiras, sizeof, constantes de caractere e aritmética com uint8_t podem produzir resultados diferentes dependendo da plataforma, da notação e do ponto de atribuição intermediária por causa da escolha de tipos e das promoções inteiras
  • Em declarações de função, foo() e foo(void), a ausência de protótipo, as promoções padrão de argumentos e funções sem valor de retorno fazem a legalidade e o comportamento diferirem entre C e C++
  • Arrays não são ponteiros, parâmetros de array são ajustados para ponteiros, e a, &a e &a[0], embora tenham o mesmo endereço, têm tipos diferentes, então não podem ser usados de forma intercambiável
  • Precedência de operadores e ordem de avaliação são coisas distintas e, incluindo a estrutura do corpo de switch e até o tempo de vida de objetos temporários, a redação da norma determina o resultado real da execução

Comportamento indefinido e regras de ponteiros

  • Comparação de ponteiros e regra de aliasing estrito

    • Mesmo que ponteiros p e q do mesmo tipo apontem para o mesmo endereço, se eles se originaram de objetos diferentes e não fazem parte do mesmo objeto aggregate ou union, a comparação p == q pode ser comportamento indefinido
    • A ideia de que ponteiros são mais abstratos do que simples endereços numéricos é desenvolvida neste artigo relacionado
    • Se um objeto int for acessado por meio de um lvalue short, isso se torna comportamento indefinido pela regra de strict aliasing
    • Um ponteiro unsigned char é uma exceção e pode fazer alias de qualquer objeto, então acessar um objeto int por meio de um lvalue unsigned char é legal
    • Há garantia de que unsigned char não tem bits de padding nem trap representation, e desde C11 também há garantia de que signed char não tem bits de padding
    • A análise de aliasing baseada em tipo é abordada neste artigo relacionado
  • Ponteiro nulo e representação de ponteiros

    • A representação em bits de um ponteiro nulo não precisa necessariamente ser todos os bits em 0
    • O padrão C define uma null pointer constant, mas não define a representação de um ponteiro nulo em tempo de execução nem a representação de ponteiros em geral
    • O Symbolics Lisp Machine 3600 usa uma tupla no formato <array-object, index> em vez de ponteiros numéricos, e a representação de ponteiro nulo é <nil, 0>
    • Há exemplos adicionais no clc FAQ 5.17
    • A constante 0 se torna inteiro ou ponteiro nulo dependendo do contexto, e (void *)0 é avaliado como ponteiro nulo
    • O fato de uma expressão e ser avaliada como 0 não garante que (void *)e será um ponteiro nulo
    • Somente quando uma null pointer constant é convertida para um tipo de ponteiro há garantia de equivalência a um ponteiro nulo
    • A aritmética com ponteiro nulo é comportamento indefinido, então mesmo que e seja um ponteiro nulo, não há garantia de que e + 0 continue sendo um ponteiro nulo
  • Valores não inicializados

    • Ao ler um objeto com duração de armazenamento automática não inicializado, se esse objeto puder ter a classe de armazenamento register e seu endereço nunca tiver sido obtido, isso será comportamento indefinido conforme C11 § 6.3.2.1 ¶ 2
    • Essa regra se conecta à arquitetura Intel Itanium tratada em DR338
    • Os registradores inteiros gerais do Itanium têm 64 bits e um trap bit, e esse trap bit é o NaT (not-a-thing), que indica se o registrador foi inicializado
    • Se o endereço da variável for obtido, essa condição desaparece, mas o valor continua indeterminado e pode ser uma trap representation ou um valor não especificado
    • Ler uma trap representation é comportamento indefinido segundo C11 § 6.2.6.1 ¶ 5
    • Se for um valor não especificado, até o resultado de x != x pode ser true ou false, e se int x for não especificado, não há garantia de que x será 0 mesmo após x *= 0
    • Valores indeterminados e valores não especificados são discutidos em DR260, DR451, N1793, N1818, N2012, N2013, N2221
  • unsigned char e memcpy

    • O tipo unsigned char não tem trap representation segundo C11 § 6.2.6.1 ¶ 3, então o valor inicial é não especificado
    • Uma resposta de um membro do comitê de C no StackOverflow afirma que, após a chamada da função da biblioteca padrão memcpy, o valor de x deve se tornar especificado, e nessa interpretação x != x seria false
    • A base no padrão C para sustentar isso não é clara, e a resposta do comitê em DR451 afirma que usar uma função de biblioteca com um valor indeterminado é comportamento indefinido, o que entra em conflito com essa interpretação
    • Essa questão continua em aberto, e há discussão adicional em Uninitialized Reads

Constantes inteiras, promoção e sizeof

  • Notação e tipo de constantes inteiras

    • Constantes inteiras decimais sem sufixo são sempre escolhidas a partir da lista de tipos signed, mas constantes octais e hexadecimais podem se tornar tipos signed ou unsigned
    • De acordo com a C17 § 6.4.4.1, o tipo de uma constante inteira é definido como o primeiro tipo da lista capaz de representar aquele valor
    • Sem sufixo, constantes decimais seguem a ordem int, long int, long long int, enquanto constantes octais e hexadecimais seguem int, unsigned int, long int, unsigned long int, long long int, unsigned long long int
    • Constantes entre INT_MAX+1 e UINT_MAX podem ter tipos diferentes dependendo de serem decimais ou hexadecimais, e isso pode causar diferenças em código sensível à ABI, como chamadas de funções variádicas
    • Na Arm 32-bit architecture ABI, int e long são passados como 32 bits em um registrador, enquanto long long é passado como 64 bits em dois registradores
    • Em plataformas onde int tem 32 bits, -1 < 0x8000 resulta em true; em plataformas onde int tem 16 bits, resulta em false, o que pode gerar problemas de portabilidade
    • Em expressões como generic selection, funções sobrecarregadas de C++ e sizeof(0x80000000) == sizeof(2147483648), a diferença de tipo das constantes também pode mudar o resultado
  • sizeof(int) > -1

    • O operador sizeof retorna um inteiro unsigned do tipo size_t
    • De acordo com as usual arithmetic conversions da C11 § 6.3.1.8, se um operando signed tiver rank menor que o operando unsigned, ele será convertido para um tipo unsigned de mesmo rank
    • Um inteiro signed correspondente a -1, ao ser convertido para unsigned, torna-se o maior inteiro unsigned daquele rank
    • Portanto, sizeof(int) > -1 sempre é avaliado como false
  • Tipo de constantes de caractere

    • Em C, constantes de caractere têm tipo int, de acordo com a C11 § 6.4.4.4 ¶ 10
    • Portanto, não há garantia de que sizeof(char) == sizeof('x') seja sempre true; apenas sizeof(int) == sizeof('x') é garantido
    • Uma integer character constant pode ser uma sequência de um ou mais caracteres multibyte, então 'abc' também é válido, e sua representação é definida pela implementação
    • O valor de uma integer character constant contendo um único caractere é igual à representação inteira de um objeto do tipo char que representa esse mesmo caractere único
  • Aritmética com uint8_t e divisão

    • Mesmo que a, b e c tenham sido inicializados antes da leitura, os valores de x e z podem ser diferentes por causa da promoção inteira e do ponto onde ocorre a atribuição intermediária
    • O valor de cada variável é promovido para o tamanho de int, depois a soma e a divisão são executadas, e cada resultado de atribuição é truncado e armazenado no tipo da variável correspondente
    • Por exemplo, se a=255, b=1, c=2, então x se torna ((255 + 1) / 2) % 256 = 128
    • A variável intermediária y se torna (255 + 1) % 256 = 0, e depois z se torna (0 / 2) % 256 = 0, portanto 128 != 0
    • Overflow de inteiro unsigned é um comportamento definido
    • Como a operação módulo se distribui sobre a adição, se a divisão for trocada por adição, x e z sempre serão iguais
    • Se a primeira atribuição for alterada para uint8_t x = ((uint8_t)(a + b)) / c;, x e z também sempre serão iguais
  • Variáveis const e variable length array

    • Mesmo usando as variáveis qualificadas com const n e m como tamanho de array, elas não são integer constant expression em C
    • Na C11 § 6.6 ¶ 6, integer constant expression é limitada a integer constant, enumeration constant, character constant, resultado de sizeof, _Alignof e cast cujo resultado seja inteiro e cujo operando imediato seja uma floating constant, entre outros casos restritos
    • Se a expressão de tamanho do array não for uma integer constant expression, então, de acordo com a C11 § 6.7.6.2 ¶ 4, ela se torna uma variable length array
    • Variable length array não é permitida em file scope, então a compilation unit com o array global x não compila
    • Em block scope, variable length array é permitida, então a compilation unit com o array local y pode ser compilada
    • Variable length array é uma conditional feature cujo suporte pela implementação não é obrigatório, então, em compiladores que não a suportam, até o exemplo em block scope pode não compilar
    • Em C++, as duas compilation units compilam, e como C++ não tem o conceito de variable length array, y é compilado como um array comum com 42 elementos

Declaração de função, valor de retorno, linkage

  • foo() e foo(void)

    • Uma declaração de função na forma foo() declara uma função cujo número e tipos de argumentos são desconhecidos, enquanto foo(void) declara uma função nulária sem argumentos
    • Essa diferença é tratada em um texto sobre declarações, definições e protótipos de função
    • Como uma declaração sem lista de argumentos apenas introduz o nome da função e não define a quantidade nem os tipos dos argumentos, ela pode ser legal ao se combinar com a definição posterior da função
    • Se uma função for chamada sem protótipo, aplicam-se as promoções padrão de argumentos, e float é promovido para double
    • Se o tipo da função após a promoção não for compatível com o tipo da definição real da função, a combinação entre declaração e definição não é válida
    • Uma chamada de função sem declaração pode ser compilada em C porque funções implícitas podiam ser permitidas, mas em C++ isso é erro de compilação
    • Se você fizer uma chamada como bar(42) sem declaração, as promoções de argumentos inteiros serão aplicadas e 42 será representado como int; portanto, se bar não for compatível com T (*)(int) para algum tipo de retorno T, isso resulta em comportamento indefinido
  • Função com retorno de valor que não retorna valor

    • Mesmo que uma função com tipo de retorno int não retorne um valor, isso pode ser legal em C desde que o valor resultante da chamada não seja usado
    • No K&R C não existia o tipo void, e quando o tipo era omitido assumia-se o tipo padrão int; por isso, funções que não retornam valor e a regra de int implícito têm uma ligação histórica
    • A regra de int implícito foi removida no C99, e a discussão relacionada aparece em N661 e na rationale do C99
    • C17 § 6.9.1 ¶ 12 estabelece que, se a execução chegar ao } no fim da função e o chamador usar o valor da chamada da função, isso é comportamento indefinido
    • Em C++98 § 6.6.3 ¶ 2, simplesmente alcançar o fim de uma função com retorno de valor equivale a um return sem valor, e isso se torna comportamento indefinido em uma função que retorna valor
    • Como compiladores C++ em geral não conseguem provar que abort_program() termina a execução em algum ramo, nesses casos eles normalmente só emitem um diagnóstico, em vez de um erro
  • linkage e extern

    • Se o mesmo identificador for redeclarado com extern em um escopo onde uma declaração anterior está visível, então o linkage da declaração posterior será o mesmo da declaração anterior
    • C17 § 6.2.2 ¶ 4 determina que, se a declaração anterior especificou linkage interno ou externo, então a declaração extern posterior também terá o mesmo linkage
    • Se a declaração anterior não estiver visível, ou se ela não tiver linkage, o identificador com extern terá linkage externo
    • Combinações de declarações na ordem inversa podem resultar em comportamento indefinido, e GCC e Clang detectam isso

Qualificadores e tipos incompletos

  • const em parâmetros de função

    • Em uma declaração de função, o parâmetro x pode ser qualificado com const e, na definição da função, não ser; ainda assim, atribuir um valor a x dentro do corpo da função é legal
    • De acordo com C11 § 6.7.6.3 ¶ 15, ao determinar a compatibilidade de tipos de parâmetros de função e o tipo composto, cada parâmetro declarado com tipo qualificado é tratado como sua versão não qualificada
    • O mesmo tema também é tratado em DR040
  • const no tipo de retorno da função

    • Quando apenas o tipo de retorno da definição da função é qualificado com const, mas a declaração não é, a resposta não é algo que se possa classificar simplesmente como certo ou errado
    • O consenso geral é que qualificadores em rvalues devem ser ignorados, mas a redação do padrão até o C11 não tratava disso explicitamente
    • No C17, ficou claro que qualificadores de rvalue devem ser ignorados em cast, conversão de lvalue e declarador de função
    • C17 § 6.7.6.3 ¶ 5 afirma que o tipo retornado pela função é a versão não qualificada de T, e essa redação foi adicionada no C17
    • A atribuição entre tipos de função pode ser legal mesmo que a qualificação const do tipo de retorno seja diferente
    • Discussões adicionais estão em DR423 e DR481
  • Struct incompleta e variável global

    • No momento da declaração de uma variável global, struct foo pode ser um tipo incompleto e seu tamanho pode ser desconhecido; ainda assim, isso pode ser permitido em certas situações se o tipo for completado depois, na mesma translation unit
    • Uma lógica parecida também se aplica a variáveis globais ou arrays de tipo incompleto
    • Esse conteúdo também é tratado em DR016
  • Objeto externo de tipo void

    • Uma declaração de variável de tipo void com linkage interno não é legal, mas uma declaração de variável de tipo void com linkage externo é sintaticamente legal e não é explicitamente proibida em nenhum ponto do padrão C11
    • Segundo C11 § 6.2.5 ¶ 19, o tipo void é um tipo de objeto incompleto que não pode ser completado, composto pelo conjunto vazio de valores
    • C11 § 6.3.2.1 ¶ 1 define lvalue como uma expressão de tipo objeto que não seja void; portanto, o nome de um objeto foo de tipo void não é um lvalue válido
    • Com base no C11, é difícil imaginar operações significativas e conformes sobre um objeto externo de tipo void
    • DR012 trata do caso em que o tipo é alterado para const void, tornando legal obter o endereço do objeto foo, o que parece mais uma falha de supervisão do que um recurso intencional
  • Conversão de ponteiro-const

Arrays, literais de string e ajuste de ponteiros

  • Array não é ponteiro

    • Inicialização de array e inicialização de ponteiro não são equivalentes
    • A primeira forma inicializa um array modificável com duração de armazenamento automática ou estática
    • A segunda forma inicializa um ponteiro que aponta para um array com duração de armazenamento estática, e esse array não é necessariamente modificável
    • Array não é ponteiro, e mais detalhes podem ser vistos neste texto relacionado
  • a, &a, &a[0]

    • Em int a[42];, a, &a e &a[0] são todos avaliados como o endereço do primeiro elemento do array
    • No entanto, como os tipos das três expressões são diferentes, elas não podem ser usadas de forma intercambiável
    • Mais detalhes estão neste texto relacionado
  • Parâmetros de array e arrays locais

    • Se o tipo de um parâmetro de função for “array de T”, ele será ajustado para “ponteiro para T
    • Mesmo que o parâmetro x pareça ser int[42], na prática ele é tratado como int *
    • Se a variável local y for int[42], então sizeof(y) será 42 * sizeof(int)
    • Como, em geral, o tamanho de um ponteiro para objeto não é igual ao tamanho de 42 inteiros, sizeof(x) == sizeof(y) normalmente é false
    • Mais detalhes estão neste texto relacionado

Operadores, ordem de avaliação, fluxo de controle

  • x+++y

    • Em C, não é possível definir novos operadores como em C++, então não existe um novo operador como +++
    • x+++y é interpretado como uma combinação de operadores existentes e é equivalente a (x++) + y
    • --*--p também não é um novo operador, mas uma combinação de operadores existentes
    • --*--p é equivalente a --(*(--p)) e, no exemplo, é avaliado como -1, com o efeito colateral de atribuir -1 a x[0]
  • Ordem de avaliação de operandos aritméticos

    • A precedência de operadores é bem definida, mas a ordem de avaliação dos operandos aritméticos não é definida
    • (x=1) + (x=2) é um comportamento indefinido porque a ordem das duas atribuições não é definida, então o valor final de x não é determinado como 1 ou 2
    • Com a opção -std=c11 -O2, o GCC 8.2.1 avalia a expressão de exemplo como 4, enquanto o Clang 7.0.0 a avalia como 3
  • Ordem de avaliação de operadores lógicos

    • Nos operadores lógicos && e ||, a ordem de avaliação dos operandos também é bem definida
    • Na terminologia do padrão C, existe um sequence point entre a avaliação do primeiro operando e a do segundo
    • No exemplo, primeiro x=1 é avaliado e resulta em true; em seguida, x=2 é avaliado e também resulta em true, então a expressão inteira resulta em true
  • Estrutura livre do corpo de switch

    • O corpo de uma instrução switch pode ser qualquer statement, então uma estrutura misturando loop e if também pode ser válida
    • Mesmo no branch true de uma instrução if cuja expressão de controle é sempre false, se houver um rótulo case, essa instrução se torna live, e printf("1"); não é dead code
    • Ao saltar para case 2, a clause-1 e a expressão de controle do loop podem não ser executadas, portanto a variável i precisa estar inicializada previamente
    • Mesmo que ocorra fall through em case 1 por não haver break, se case 1 estiver no branch true do if e case 2 no branch false, ele pode pular case 2 e continuar em case 3
    • Após as três chamadas foo(0); foo(1); foo(2);, a saída no console será 02313223
    • Um exemplo real famoso que mistura loop e switch é o Duff's device

Diferenças entre versão do padrão C e tempo de vida de objetos temporários

  • Um determinado trecho de código é comportamento indefinido em C11, mas pode não ser em C99
  • Em C11, o tempo de vida de certos objetos foi reduzido, de modo que o objeto retornado por uma chamada de função sobrevive apenas até durante a avaliação do operando à direita
  • Em C99, o mesmo objeto sobrevive até o fim do bloco envolvente
  • Referenciar um objeto cujo tempo de vida terminou é comportamento indefinido, de acordo com C11 § 6.2.4 ¶ 2
  • Mesmo em C99, o tempo de vida de objetos com automatic storage duration está vinculado ao bloco envolvente mais próximo, então referenciar o objeto fora desse bloco é comportamento indefinido
  • C11 § 6.2.4 ¶ 8 especifica que, se uma non-lvalue expression de tipo struct ou union incluir um membro array, ela se refere a um objeto com automatic storage duration e temporary lifetime
  • O tempo de vida desse objeto temporário começa quando a expressão é avaliada e termina quando acaba a avaliação da full expression ou do full declarator que o contém
  • Tentar modificar um objeto com temporary lifetime é comportamento indefinido
  • Esse exemplo foi extraído de N1285, onde também há discussões adicionais

1 comentários

 
Opiniões no Lobste.rs
  • A questão 4 não é válida em C23, mas era válida antes
    A questão 10 não é nem certa nem errada, então incomoda um pouco chamá-la de múltipla escolha
    A questão 15 está tecnicamente errada, especialmente em relação à questão 13, e a questão 20 é “não especificado”, então também não corresponde a nenhuma resposta
    A questão 30 é ambígua dependendo da leitura
    Ainda assim, acertei 27 de 31, e ser desenvolvedor de compiladores ajudou um pouco

  • Depois de resolver umas quatro questões, desapareceu a sensação que eu ainda tinha de que C é simples o bastante para valer a pena usar em projetos paralelos

    • Se você usar -std=<language-standard> -pedantic -Wall -Wextra no GCC ou no clang, corrigir de fato todo aviso que aparecer e evitar ao máximo casts de ponteiro e manipulação de ponteiros, parece que dá para escapar das grandes armadilhas
      Hoje em dia os avisos do GCC/clang são bem bons, e em <language-standard> você pode usar c89, c99, c11 ou c23
    • C é simples, mas o malabarismo em torno de comportamento indefinido não é
      Se você usar um compilador como o tcc, que não faz otimizações esquisitas, vai ter menos surpresas bizarras
  • Fui só pelo critério de “qual seria o comportamento mais absurdo aqui?” e acertei 21 de 32
    A maioria dos erros aconteceu porque eu não pensei fundo o bastante no nível desse absurdo
    Faz mais de 15 anos que só encostei um pouco em C, e ver um quiz desses não me dá vontade de voltar a usar

    • Para constar, o ChatGPT acertou 22 de 32 sem ver as explicações adicionais que vinham depois de cada resposta
  • Pelo padrão do C23, a resposta da questão 4 não é válida

  • Curiosamente, faz um tempo que não uso C, mas acertei 27 de 32
    É por essas e outras que venho dependendo de verificadores estáticos e linters

  • Já comecei desconfiado desde a questão 1
    Não levaram em conta de onde aqueles ponteiros poderiam vir, e o caso mencionado ali exige condições muito específicas para se sustentar
    Na maioria dos casos, o próprio ato de tentar criar o ponteiro já é comportamento indefinido, mas ainda assim dá para considerar justo
    A questão 3 realmente me surpreendeu, mais uma armadilha de C
    O simples fato de literais inteiros em C terem tipo fixo já é extremamente irritante
    As regras de promoção de inteiros até ajudam em certa medida, mas também são fonte de erros
    Linguagens modernas, em sua maioria — ou talvez todas — deveriam proibir casts numéricos implícitos, inferir o tipo do literal a partir do contexto quando possível e, quando não for, exigir cast explícito
    Depois da questão 6, parei porque já não confiava no teste
    No começo foi porque a resposta da questão 5 parecia ter sido basicamente projetada para fazer você errar a questão 6, mas revendo agora, a própria questão 6 parece estar errada
    A explicação diz que a chamada da função é comportamento indefinido, mas a pergunta era se a definição da função era legal, e provavelmente era

    • Isso acontece se dois arrays estiverem adjacentes na memória, e um ponteiro apontar para o primeiro elemento de um e outro para logo após o último elemento do outro
      E isso não me parece um caso tão raro assim
  • A questão de switch() foi realmente muito boa
    Era complicada, mas o processo de resolver mentalmente foi muito divertido