1 puntos por GN⁺ 19 시간 전 | 1 comentarios | Compartir por WhatsApp
  • Las reglas del lenguaje C pueden hacer que incluso código que parece simple, como la comparación de punteros, aliasing, punteros nulos y valores sin inicializar, se convierta en comportamiento indefinido
  • Las constantes enteras y sizeof, las constantes de carácter y la aritmética de uint8_t pueden dar resultados distintos según la plataforma, la notación y el punto de asignación intermedia debido a la selección de tipos y las promociones enteras
  • foo() y foo(void) en declaraciones de funciones, la ausencia de prototipos, las promociones predeterminadas de argumentos y las funciones sin valor de retorno difieren en C y C++ en cuanto a legalidad o comportamiento
  • Los arreglos no son punteros; los parámetros de arreglo se ajustan a punteros; y a, &a y &a[0], aunque tengan la misma dirección, tienen tipos distintos, por lo que no pueden usarse indistintamente
  • La precedencia de operadores y el orden de evaluación son cosas distintas y, junto con la estructura del cuerpo de switch e incluso la vida útil de objetos temporales, la redacción del estándar determina el resultado real de la ejecución

Comportamiento indefinido y reglas de punteros

  • Comparación de punteros y regla estricta de aliasing

    • Aunque los punteros p y q del mismo tipo apunten a la misma dirección, si provienen de objetos distintos y no forman parte del mismo objeto aggregate o union, la comparación p == q puede ser comportamiento indefinido
    • La idea de que un puntero es más abstracto que una simple dirección numérica continúa en este artículo relacionado
    • Si se accede a un objeto int mediante un lvalue short, eso se convierte en comportamiento indefinido según la regla de strict aliasing
    • Un puntero unsigned char puede, como excepción, hacer alias de cualquier objeto, por lo que es legal acceder a un objeto int mediante un lvalue unsigned char
    • Se garantiza que unsigned char no tiene bits de relleno ni trap representation, y desde C11 también se garantiza que signed char no tiene bits de relleno
    • El análisis de aliasing basado en tipos se trata en este artículo relacionado
  • Puntero nulo y representación de punteros

    • La representación en bits de un puntero nulo no tiene por qué ser necesariamente todos los bits en 0
    • El estándar de C define una null pointer constant, pero no define la representación de un puntero nulo en tiempo de ejecución ni la representación de los punteros en general
    • La Symbolics Lisp Machine 3600 usa una tupla con forma <array-object, index> en lugar de punteros numéricos, y su representación de puntero nulo es <nil, 0>
    • Hay ejemplos adicionales en clc FAQ 5.17
    • La constante 0 puede ser un entero o un puntero nulo según el contexto, y (void *)0 se evalúa como un puntero nulo
    • Que una expresión e se evalúe a 0 no garantiza que (void *)e se convierta en un puntero nulo
    • Solo cuando una null pointer constant se convierte a un tipo puntero se garantiza que sea igual a un puntero nulo
    • La aritmética sobre un puntero nulo es comportamiento indefinido, por lo que aunque e sea un puntero nulo, no se garantiza que e + 0 siga siendo un puntero nulo
  • Valores sin inicializar

    • Al leer un objeto de duración de almacenamiento automática sin inicializar, si ese objeto puede ser de clase de almacenamiento register y nunca se tomó su dirección, entonces según C11 § 6.3.2.1 ¶ 2 eso se convierte en comportamiento indefinido
    • Esta regla está relacionada con la arquitectura Intel Itanium tratada en DR338
    • Los registros enteros generales de Itanium tienen 64 bits y un trap bit, que es NaT (not-a-thing), para indicar si el registro fue inicializado
    • Si se toma la dirección de la variable, esa condición desaparece, pero el valor sigue siendo indeterminado y puede ser una trap representation o un unspecified value
    • Leer una trap representation produce comportamiento indefinido según C11 § 6.2.6.1 ¶ 5
    • Si se trata de un unspecified value, el resultado de x != x puede ser tanto true como false, y si int x es unspecified, incluso después de x *= 0 no se garantiza que x sea 0
    • Los conceptos de indeterminate y unspecified value se discuten en DR260, DR451, N1793, N1818, N2012, N2013, N2221
  • unsigned char y memcpy

    • El tipo unsigned char no tiene trap representation según C11 § 6.2.6.1 ¶ 3, por lo que su valor inicial es unspecified
    • Una respuesta en StackOverflow de un miembro del comité de C sostiene que, tras llamar a la función de biblioteca estándar memcpy, el valor de x debería pasar a ser specified, y bajo esa interpretación x != x sería false
    • La base en el estándar de C para respaldar esto no es clara, y la respuesta del comité en DR451 entra en conflicto con esa interpretación al afirmar que usar funciones de biblioteca sobre un indeterminate value es comportamiento indefinido
    • Esta cuestión sigue abierta, y hay más discusión en Uninitialized Reads

Constantes enteras, promociones y sizeof

  • Notación y tipo de las constantes enteras

    • Las constantes enteras decimales sin sufijo siempre se eligen de la lista de tipos signed, mientras que las constantes octales y hexadecimales pueden ser de tipo signed o unsigned
    • Según C17 § 6.4.4.1, el tipo de una constante entera se determina como el primer tipo de la lista que puede representar ese valor
    • Cuando no hay sufijo, las constantes decimales siguen el orden int, long int, long long int; las octales y hexadecimales siguen el orden int, unsigned int, long int, unsigned long int, long long int, unsigned long long int
    • Las constantes entre INT_MAX+1 y UINT_MAX pueden tener distinto tipo según sean decimales o hexadecimales, y eso puede generar diferencias en código sensible al ABI, como llamadas a funciones con argumentos variables
    • En el ABI de arquitectura Arm de 32 bits, int y long se pasan como 32 bits en un solo registro, mientras que long long se pasa como 64 bits en dos registros
    • En plataformas donde int es de 32 bits, -1 < 0x8000 da true, y en plataformas donde int es de 16 bits da false, lo que puede generar problemas de portabilidad
    • La diferencia de tipos de las constantes también puede cambiar el resultado en expresiones como generic selection, funciones sobrecargadas de C++ y sizeof(0x80000000) == sizeof(2147483648)
  • sizeof(int) > -1

    • El operador sizeof devuelve un entero unsigned de tipo size_t
    • Según las usual arithmetic conversions de C11 § 6.3.1.8, si el operando signed tiene menor rank que el operando unsigned, se convierte al tipo unsigned del mismo rank
    • El entero signed correspondiente a -1, al convertirse a unsigned, pasa a ser el mayor entero unsigned de ese rank
    • Por lo tanto, sizeof(int) > -1 siempre se evalúa como false
  • Tipo de las constantes de carácter

    • En C, una constante de carácter es de tipo int según C11 § 6.4.4.4 ¶ 10
    • Por lo tanto, no hay garantía de que sizeof(char) == sizeof('x') sea siempre true; solo está garantizado sizeof(int) == sizeof('x')
    • Una integer character constant puede ser una secuencia de uno o más caracteres multibyte, así que 'abc' también es válido, y su representación está definida por la implementación
    • El valor de una integer character constant que contiene un solo carácter es igual a la representación entera de un objeto de tipo char que representa ese mismo carácter único
  • Aritmética con uint8_t y división

    • Aunque a, b y c estén inicializadas antes de leerse, los valores de x y z pueden diferir debido a la promoción de enteros y al lugar donde ocurre la asignación intermedia
    • El valor de cada variable se promociona al tamaño de int antes de realizar la suma y la división, y cada resultado asignado se trunca y se guarda en el tipo de la variable correspondiente
    • Por ejemplo, si a=255, b=1, c=2, entonces x será ((255 + 1) / 2) % 256 = 128
    • La variable intermedia y será (255 + 1) % 256 = 0, y luego z será (0 / 2) % 256 = 0, así que 128 != 0
    • El overflow de enteros unsigned es un comportamiento definido
    • Como la operación módulo se distribuye sobre la suma, si se reemplaza la división por una suma, x y z siempre serán iguales
    • Si la primera asignación se cambia a uint8_t x = ((uint8_t)(a + b)) / c;, entonces x y z también siempre serán iguales
  • Variables const y variable length array

    • Aunque se usen variables calificadas con const como n y m como tamaño de arreglo, estas no son una integer constant expression en C
    • En C11 § 6.6 ¶ 6, integer constant expression se limita a integer constant, enumeration constant, character constant, sizeof, _Alignof y el operando inmediato de un cast cuyo resultado sea una integer constant, entre otros casos similares
    • Si la expresión de tamaño del arreglo no es una integer constant expression, entonces según C11 § 6.7.6.2 ¶ 4 se convierte en un variable length array
    • Los variable length array no se permiten en file scope, por lo que la compilation unit que tiene el arreglo global x no compilará
    • En block scope sí se permiten los variable length array, así que la compilation unit que tiene el arreglo local y puede compilar
    • Los variable length array son una conditional feature que una implementación no está obligada a soportar, por lo que en compiladores que no los soportan, incluso el ejemplo de block scope podría no compilar
    • En C++, ambas compilation unit compilan, y como C++ no tiene el concepto de variable length array, y se compila como un arreglo normal de 42 elementos

Declaración de funciones, valores de retorno y linkage

  • foo() y foo(void)

    • Una declaración de función con la forma foo() declara una función cuyo número y tipo de argumentos se desconocen, mientras que foo(void) declara una función nular
    • Esta diferencia se trata en un artículo sobre declaraciones, definiciones y prototipos de funciones
    • Como una declaración sin lista de argumentos solo introduce el nombre de la función y no define la cantidad ni los tipos de argumentos, puede ser legal en combinación con una definición posterior de la función
    • Si se llama a una función sin prototipo, se aplican las promociones predeterminadas de argumentos y float se promueve a double
    • Si el tipo de la función después de la promoción no es compatible con el tipo de la definición real de la función, la combinación de declaración y definición no es válida
    • Una llamada a función sin declaración puede compilar en C porque se permitían funciones implícitas, pero en C++ es un error de compilación
    • Si se hace una llamada como bar(42) sin declaración, se aplican las promociones de argumentos enteros y 42 se representa como int, por lo que si bar no es compatible con T (*)(int) para algún tipo de retorno T, el comportamiento es indefinido
  • Funciones que devuelven valor pero no retornan un valor

    • Aunque una función con tipo de retorno int no devuelva un valor, en C puede ser legal siempre que no se use el valor del resultado de la llamada
    • En K&R C no existía el tipo void y si se omitía el tipo se asumía el tipo predeterminado int, por lo que históricamente las funciones que no devuelven valor y la regla de int implícito están relacionadas
    • La regla de int implícito fue eliminada en C99, y la discusión relacionada aparece en N661 y en el rationale de C99
    • C17 § 6.9.1 ¶ 12 establece que si se alcanza la } al final de la función y el llamador usa el valor de la llamada, eso es comportamiento indefinido
    • En C++98 § 6.6.3 ¶ 2, el simple hecho de llegar al final de una función que devuelve valor equivale a un return sin valor, y en una función que devuelve valor eso produce comportamiento indefinido
    • Como los compiladores de C++ generalmente no pueden demostrar en qué ramas abort_program() termina la ejecución, en estos casos normalmente solo pueden emitir un diagnóstico en vez de un error
  • linkage y extern

    • Si en un alcance donde es visible una declaración previa se vuelve a declarar el mismo identificador con extern, el linkage de la declaración posterior es el mismo que el de la declaración previa
    • C17 § 6.2.2 ¶ 4 especifica que si la declaración previa indicó linkage interno o externo, la declaración posterior con extern también tiene el mismo linkage
    • Si la declaración previa no es visible o no tenía linkage, el identificador con extern tiene linkage externo
    • La combinación de declaraciones en el orden inverso puede producir comportamiento indefinido, y GCC y Clang lo detectan

Calificadores y tipos incompletos

  • const en parámetros de función

    • Si en la declaración de una función el parámetro x está calificado con const pero en la definición de la función no lo está, y en el cuerpo de la función se escribe un valor en x, eso es legal
    • Según C11 § 6.7.6.3 ¶ 15, al determinar la compatibilidad de tipos de parámetros de función y el tipo compuesto, cada parámetro declarado con un tipo calificado se trata como su versión no calificada
    • El mismo tema también se trata en DR040
  • const en el tipo de retorno de una función

    • Si solo el tipo de retorno de la definición de la función está calificado con const y la declaración no lo está, la respuesta no puede considerarse simplemente correcta o incorrecta
    • El consenso general es que los calificadores de un rvalue deberían ignorarse, pero la redacción del estándar hasta C11 no lo trataba explícitamente
    • En C17 quedó claro que los calificadores de rvalue deben ignorarse en cast, conversión de lvalue y declaradores de función
    • C17 § 6.7.6.3 ¶ 5 especifica que el tipo que devuelve la función es la versión no calificada de T, y esta redacción se agregó en C17
    • La asignación de tipos de función puede ser legal incluso si difieren en el calificador const del tipo de retorno
    • La discusión adicional aparece en DR423 y DR481
  • struct incompleta y variables globales

    • Aunque struct foo sea un tipo incompleto al momento de la declaración de una variable global y por eso no se conozca su tamaño, en ciertos casos se permite si el tipo se completa más adelante en la misma translation unit
    • Una lógica parecida también se aplica a variables globales o arreglos de tipo incompleto
    • Esto también se trata en DR016
  • Objeto externo de tipo void

    • Una declaración de variable de tipo void con linkage interno no es legal, pero una declaración de variable de tipo void con linkage externo es legal desde el punto de vista sintáctico y no está prohibida explícitamente en ninguna parte del estándar C11
    • Según C11 § 6.2.5 ¶ 19, el tipo void es un tipo de objeto incompleto no completable compuesto por un conjunto vacío de valores
    • C11 § 6.3.2.1 ¶ 1 define un lvalue como una expresión de tipo objeto distinto de void, por lo que el nombre del objeto foo de tipo void no es un lvalue válido
    • Bajo C11 es difícil imaginar operaciones significativas y conformes sobre un objeto externo void
    • DR012 trata el caso en que el tipo se cambia a const void, donde tomar la dirección del objeto foo sí es legal, lo que parece más un descuido que una funcionalidad intencional
  • Conversión de puntero a const

Arreglos, literales de cadena y ajuste de punteros

  • Los arreglos no son punteros

    • Inicializar un arreglo e inicializar un puntero no es equivalente
    • La primera forma inicializa un arreglo modificable con duración de almacenamiento automática o estática
    • La segunda forma inicializa un puntero que apunta a un arreglo con duración de almacenamiento estática, y ese arreglo no necesariamente es modificable
    • Un arreglo no es un puntero, y los detalles se tratan en este artículo relacionado
  • a, &a, &a[0]

    • En int a[42];, a, &a y &a[0] se evalúan todos como la dirección del primer elemento del arreglo
    • Pero los tipos de las tres expresiones son distintos entre sí, por lo que no pueden usarse indistintamente
    • Los detalles se tratan en este artículo relacionado
  • Parámetros de arreglo y arreglos locales

    • Si el tipo de un parámetro de función es “arreglo de T”, se ajusta a “puntero a T
    • Aunque el parámetro x parezca ser int[42], en realidad se trata como int *
    • Si la variable local y es int[42], entonces sizeof(y) es 42 * sizeof(int)
    • Como en general el tamaño de un puntero a objeto no es igual al tamaño de 42 enteros, sizeof(x) == sizeof(y) normalmente es false
    • Los detalles se tratan en este artículo relacionado

Operadores, orden de evaluación y flujo de control

  • x+++y

    • En C no se pueden definir operadores nuevos como en C++, así que no existe ningún operador nuevo como +++
    • x+++y se interpreta como una combinación de operadores existentes y es equivalente a (x++) + y
    • --*--p tampoco es un operador nuevo, sino una combinación de operadores existentes
    • --*--p es equivalente a --(*(--p)) y, en el ejemplo, se evalúa como -1 y como efecto secundario asigna -1 a x[0]
  • Orden de evaluación de los operandos aritméticos

    • La precedencia de operadores está bien definida, pero el orden de evaluación de los operandos aritméticos no está definido
    • (x=1) + (x=2) es comportamiento indefinido porque el orden de las dos asignaciones no está definido, así que no se determina si el valor final de x es 1 o 2
    • Con la opción -std=c11 -O2, GCC 8.2.1 evalúa la expresión de ejemplo como 4, mientras que Clang 7.0.0 la evalúa como 3
  • Orden de evaluación de los operadores lógicos

    • En los operadores lógicos && y ||, el orden de evaluación de los operandos también está bien definido
    • En la terminología del estándar de C, existe un sequence point entre la evaluación del primer operando y la del segundo
    • En el ejemplo, primero se evalúa x=1, que da true, y luego se evalúa x=2, que también da true, por lo que la expresión completa da true
  • Estructura libre del cuerpo de switch

    • El cuerpo de una sentencia switch puede ser cualquier statement, así que también puede ser legal una estructura mezclada con un loop y un if
    • Incluso si está dentro de la rama true de una sentencia if cuya expresión de control siempre es false, si existe una etiqueta case, esa sentencia pasa a estar activa y printf("1"); no es código muerto
    • Si se salta a case 2, puede que no se ejecuten la clause-1 del loop ni la expresión de control, por lo que la variable i debe estar inicializada de antemano
    • Aunque ocurra fall through por no haber break en case 1, si case 1 está en la rama true del if y case 2 en la rama false, se puede omitir case 2 y continuar con case 3
    • Después de las tres llamadas foo(0); foo(1); foo(2);, la salida de la consola es 02313223
    • Un ejemplo real y famoso de mezclar loop y switch es Duff's device

Vida útil de objetos temporales y diferencias entre versiones del estándar C

  • Un fragmento de código específico es comportamiento indefinido en C11, pero puede no serlo en C99
  • En C11, la vida útil de cierto objeto se acorta, de modo que el objeto devuelto por una llamada a función solo vive mientras se evalúa el operando derecho
  • En C99, ese mismo objeto vive hasta el final del bloque contenedor
  • Referenciar un objeto cuya vida útil ya terminó es comportamiento indefinido según C11 § 6.2.4 ¶ 2
  • Incluso en C99, la vida útil de un objeto con automatic storage duration está ligada al bloque contenedor más cercano, así que referenciar el objeto fuera de ese bloque es comportamiento indefinido
  • C11 § 6.2.4 ¶ 8 establece que una expresión no-lvalue de tipo struct o union, si incluye un array member, hace referencia a un objeto con automatic storage duration y temporary lifetime
  • La vida útil de este objeto temporal comienza cuando se evalúa la expresión y termina al finalizar la evaluación de la full expression o full declarator contenedor
  • Intentar modificar un objeto con temporary lifetime es comportamiento indefinido
  • El ejemplo correspondiente fue tomado de N1285, donde también hay discusión adicional

1 comentarios

 
Opiniones en Lobste.rs
  • La pregunta 4 no es válida en C23, aunque antes sí lo era
    La pregunta 10 no es ni correcta ni incorrecta, así que molesta un poco para ser de opción múltiple
    La pregunta 15 es técnicamente incorrecta, especialmente en relación con la pregunta 13, y la pregunta 20 es “no especificado”, así que tampoco corresponde a ninguna respuesta
    La pregunta 30 es ambigua según cómo se lea
    Aun así, acerté 27 de 31, y el hecho de ser desarrollador de compiladores ayudó un poco

  • Después de resolver unas cuatro preguntas, desapareció la sensación que todavía me quedaba de que C es lo bastante simple como para usarlo en un proyecto personal

    • Si usas GCC o clang con -std=<language-standard> -pedantic -Wall -Wextra y de verdad corriges cada warning que salga, además de evitar en lo posible los casts de punteros y la manipulación de punteros, parece que no hay tantas trampas grandes
      Hoy en día los warnings de GCC/clang son bastante buenos, y en <language-standard> puedes usar c89, c99, c11 o c23
    • C es simple, pero las acrobacias alrededor del comportamiento indefinido no lo son
      Si usas un compilador como tcc, que no hace optimizaciones raras, te toparás con menos sorpresas extrañas
  • Simplemente elegí según el criterio de “¿cuál sería el comportamiento más absurdo aquí?” y acerté 21 de 32
    Casi todos mis errores fueron por no pensar lo bastante a fondo en el nivel de ese absurdo
    Hace más de 15 años que apenas toqué C, y ver este tipo de quiz no me da ganas de retomarlo

    • Como referencia, ChatGPT acertó 22 de 32 sin ver las explicaciones adicionales que aparecen después de cada respuesta
  • Según C23, la respuesta de la pregunta 4 no es válida

  • Curiosamente, no he usado C en bastante tiempo y aun así acerté 27 de 32
    Por cosas como esta he dependido de analizadores estáticos y linters

  • Ya desde la pregunta 1 me dio mala espina
    No se tomó en cuenta de dónde podían venir esos punteros, y para que el caso que mencionan se cumpla hacen falta condiciones muy específicas
    En la mayoría de los casos, el solo hecho de intentar crear el puntero ya es comportamiento indefinido, pero aun así supongo que puede considerarse justo
    La pregunta 3 sí me sorprendió de verdad, otra trampa más de C
    De entrada, que los literales enteros en C tengan un tipo fijado es bastante irritante
    Las reglas de promoción de enteros lo compensan en cierta medida, pero también son una fuente de errores
    Los lenguajes modernos deberían, en su mayoría o incluso todos, prohibir los casts numéricos implícitos y, cuando sea posible, inferir el tipo de los literales a partir del contexto; si no es posible, exigir un cast explícito
    Después de la pregunta 6 dejé de confiar en el test y lo abandoné
    Al principio fue porque la respuesta de la pregunta 5 parecía estar diseñada para hacer que prácticamente fallaras la 6, pero al revisarlo otra vez, da la impresión de que la propia pregunta 6 está mal
    La explicación dice que la llamada a la función es comportamiento indefinido, pero la pregunta era si la definición de la función era legal, y probablemente sí lo era

    • Esa situación se da si dos arreglos están contiguos en memoria y uno apunta al primer elemento de uno y al justo después del último elemento del otro
      Y no parece que eso sea un caso tan raro
  • El problema de switch() estuvo muy bueno
    Era complicado, pero el proceso de resolverlo mentalmente fue muy divertido