Rarezas de C: comportamiento indefinido y otras sorpresas

Rarezas de C: dieciséis comportamientos que sorprenden a quien viene de otros lenguajes, y a quien lleva años programando en C. Índices al revés, sizeof que no evalúa, enteros que cambian de signo al compararlos, macros que calculan mal y el temido comportamiento indefinido, todo comprobado con GCC 15.

C se diseñó en los años setenta para escribir un sistema operativo en máquinas con unos pocos kilobytes de memoria, y muchas de sus decisiones solo se entienden desde ahí: dejar al compilador libertad para generar el código más rápido, no comprobar nada que cueste tiempo y fiarse del programador. Medio siglo después, esas decisiones producen sorpresas. Estas son las más conocidas, todas comprobadas con GCC 15.3. Primero, un concepto que aparece en muchas de ellas.

Qué es el comportamiento indefinido

El estándar de C distingue tres grados de libertad. El comportamiento definido por la implementación varía de un compilador a otro, pero cada uno documenta el suyo (por ejemplo, si char tiene signo). El no especificado tiene varias opciones válidas y el compilador elige sin decir cuál (el orden en que se evalúan los argumentos de una función). Y el comportamiento indefinido (undefined behavior, UB) es lo que el estándar no regula en absoluto: salirse de un array, desbordar un entero con signo, usar memoria liberada, desreferenciar un puntero nulo.

Ante un comportamiento indefinido, el programa puede hacer cualquier cosa, y lo más peligroso es que suele hacer lo que esperabas… hasta que cambias de compilador o de opciones. Además, el compilador tiene permiso para suponer que nunca ocurre, y optimiza el código con esa suposición. En Seguridad en C hay un ejemplo de una comprobación que desaparece al compilar con -O2. La herramienta para encontrarlos al ejecutar es -fsanitize=undefined.

Arrays, punteros y sizeof

1. a[i] es lo mismo que i[a]

#include <stdio.h>

int main(void)
{
    int a[] = {10, 20, 30};
    printf("%d %d %d\n", a[2], 2[a], *(a + 2));
    printf("%c\n", 3["decodigo"]);
    return 0;
}
salida30 30 30
o

El estándar define a[i] como *(a + i), y la suma es conmutativa, así que i[a] es *(i + a): lo mismo. Nadie escribe así en serio, pero muestra lo que es de verdad un índice en C: aritmética de punteros con otra sintaxis.

2. sizeof no evalúa lo que mide

#include <stdio.h>

int main(void)
{
    int contador = 5;
    size_t t = sizeof(contador++);
    printf("%zu %d\n", t, contador);
    printf("%zu %zu\n", sizeof 'A', sizeof(char));
    return 0;
}
salida4 5
4 1

sizeof es un operador, no una función: el compilador calcula el tamaño mirando el tipo, y la expresión de dentro nunca se ejecuta, así que contador++ no suma nada. La segunda línea es otra rareza: en C, una constante de carácter como 'A' es de tipo int, no char, y mide 4 bytes (en C++ es char y mide 1).

3. Un array no se puede asignar, pero una struct con un array dentro, sí

#include <stdio.h>

typedef struct { int v[3]; } Trio;

int main(void)
{
    Trio a = {{1, 2, 3}};
    Trio b = a;
    b.v[0] = 99;
    printf("%d %d\n", a.v[0], b.v[0]);
    return 0;
}
salida1 99

Escribir int x[3], y[3]; x = y; no compila: un array no es asignable, porque en casi cualquier expresión se convierte en un puntero a su primer elemento. Pero envuelto en una struct, se copia entero en cada asignación, y también al pasarlo a una función por valor. Es un truco viejo para copiar arrays de tamaño fijo sin memcpy.

Enteros

4. -1 no es menor que sizeof(int)

#include <stdio.h>

int main(void)
{
    if (-1 < sizeof(int)) {
        printf("-1 es menor\n");
    } else {
        printf("-1 NO es menor que %zu\n", sizeof(int));
    }
    return 0;
}
salida-1 NO es menor que 4

sizeof devuelve un size_t, que no tiene signo. Al compararlo con -1, el -1 se convierte también a size_t y pasa a ser 18,446,744,073,709,551,615. Es la regla de las conversiones aritméticas habituales: si se mezclan un entero con signo y uno sin signo del mismo tamaño o mayor, gana el que no tiene signo. Con -Wall -Wextra, GCC lo avisa:

gcc: avisomenos_uno.c: In function 'main':
menos_uno.c:5:12: warning: comparison of integer expressions of different signedness: 'int' and 'long unsigned int' [-Wsign-compare]
    5 |     if (-1 < sizeof(int)) {
      |            ^

5. char puede tener signo o no

#include <stdio.h>
#include <limits.h>

int main(void)
{
    char c = (char) 200;
    printf("CHAR_MIN = %d, (char) 200 = %d\n", CHAR_MIN, c);
    return 0;
}
salida en x86-64CHAR_MIN = -128, (char) 200 = -56

Que char sea con o sin signo lo decide cada plataforma. En los PC (x86) tiene signo, va de -128 a 127 y 200 se convierte en -56; en ARM, el procesador de los móviles y de la Raspberry Pi, no tiene signo y 200 sigue siendo 200. Un programa que guarda bytes en un char y los compara con números puede funcionar en tu portátil y fallar en un servidor ARM. Para bytes, usa unsigned char o uint8_t.

6. Las cuentas con tipos pequeños se hacen en int

#include <stdio.h>
#include <stdint.h>

int main(void)
{
    uint8_t a = 200, b = 100;
    printf("%d\n", a + b);
    uint8_t suma = a + b;
    printf("%d\n", suma);
    printf("%zu\n", sizeof(a + b));
    return 0;
}
salida300
44
4

Antes de operar, todo lo que sea más pequeño que int se convierte en int: es la promoción de enteros. Por eso a + b da 300 aunque los dos sean de un byte, y la suma mide 4 bytes. El recorte solo llega al guardar el resultado en otro uint8_t, que se queda con 300 menos 256: 44.

7. La división de negativos y el signo del resto

#include <stdio.h>

int main(void)
{
    printf("%d %d\n", -7 / 2, -7 % 2);
    printf("%d %d\n", 7 / -2, 7 % -2);
    return 0;
}
salida-3 -1
-3 1

Desde C99, la división entera trunca hacia cero (-3,5 da -3, no -4) y el resto tiene el signo del dividendo. Python, en cambio, redondea hacia abajo y daría -4 y 1. Comprobar si un número es impar con n % 2 == 1 falla con los negativos: usa n % 2 != 0.

8. Desplazar demasiado es comportamiento indefinido

#include <stdio.h>

int main(int argc, char *argv[])
{
    (void) argv;
    int bits = 30 + argc;
    printf("%d\n", 1 << bits);
    return 0;
}
salida con errordesplazar.c:7:22: runtime error: left shift of 1 by 31 places cannot be represented in type 'int'

Desplazar un 1 hasta el bit del signo de un int, o desplazar tantos bits como tiene el tipo o más, es comportamiento indefinido. En x86, 1 << 32 suele dar 1, porque el procesador solo mira los 5 bits bajos de la cantidad, pero otro procesador puede dar 0. Para manipular bits, usa tipos sin signo: 1u << 31 está bien definido.

9. 010 vale 8

#include <stdio.h>

int main(void)
{
    int codigos[] = {101, 010, 042};
    for (int i = 0; i < 3; i++) {
        printf("%d ", codigos[i]);
    }
    printf("\n");
    return 0;
}
salida101 8 34 

Un número que empieza por 0 está en octal, base 8, una herencia de las máquinas de los años sesenta. Alinear una lista de códigos con ceros a la izquierda cambia sus valores sin ningún aviso. Los permisos de archivos de Unix (0644) son de los pocos usos que quedan.

Expresiones y evaluación

10. i = i++ + 1

#include <stdio.h>

int main(void)
{
    int i = 5;
    i = i++ + 1;
    printf("%d\n", i);
    return 0;
}
gcc: avisosecuencia.c: In function 'main':
secuencia.c:6:7: warning: operation on 'i' may be undefined [-Wsequence-point]
    6 |     i = i++ + 1;
      |     ~~^~~~~~~~~

Modificar la misma variable dos veces en una expresión sin un punto que ordene las operaciones es comportamiento indefinido: el resultado puede ser 6, 7 o cualquier otra cosa según el compilador. GCC lo avisa con -Wall (-Wsequence-point). La regla práctica: si una variable cambia en una expresión, que no aparezca más veces en ella.

11. El orden de los argumentos no está definido

#include <stdio.h>

int siguiente(void)
{
    static int n = 0;
    return ++n;
}

int main(void)
{
    printf("%d %d %d\n", siguiente(), siguiente(), siguiente());
    return 0;
}
salida con GCC 15 en x86-643 2 1

¿Esperabas «1 2 3»? El estándar no dice en qué orden se evalúan los argumentos de una función, y GCC en x86-64 suele hacerlo de derecha a izquierda. No es indefinido, solo no especificado: el programa funciona, pero no sabes qué imprimirá con otro compilador. Si el orden importa, guarda cada valor en una variable antes de la llamada.

12. La coma que no separa

#include <stdio.h>

int main(void)
{
    int x = (1, 2, 3);
    printf("%d\n", x);

    double precio = 3,50;
    printf("%.2f\n", precio);
    return 0;
}

Dentro de una expresión, la coma es un operador: evalúa lo de la izquierda, lo descarta y se queda con lo de la derecha, así que x vale 3. La segunda declaración es el error de quien escribe decimales a la española: no compila, por suerte, porque la coma ahí separa declaraciones y 50 no es un nombre válido:

gcc: errorcoma.c: In function 'main':
coma.c:5:15: warning: left-hand operand of comma expression has no effect [-Wunused-value]
    5 |     int x = (1, 2, 3);
      |               ^
coma.c:5:18: warning: left-hand operand of comma expression has no effect [-Wunused-value]
    5 |     int x = (1, 2, 3);
      |                  ^
coma.c:8:23: error: expected identifier or '(' before numeric constant
    8 |     double precio = 3,50;

13. Macros que calculan mal

#include <stdio.h>

#define CUADRADO(x) x * x
#define CUADRADO_BIEN(x) ((x) * (x))
#define MAYOR(a, b) ((a) > (b) ? (a) : (b))

int main(void)
{
    printf("%d %d\n", CUADRADO(1 + 2), CUADRADO_BIEN(1 + 2));

    int i = 5, j = 3;
    int m = MAYOR(i++, j);
    printf("m=%d, i=%d\n", m, i);
    return 0;
}
salida5 9
m=6, i=7

Una macro sustituye texto antes de compilar, sin entender la expresión. CUADRADO(1 + 2) se convierte en 1 + 2 * 1 + 2, que da 5. La solución son los paréntesis alrededor de cada parámetro y del resultado. Pero ni eso salva a MAYOR(i++, j): el argumento aparece dos veces en la macro, así que i se incrementa dos veces y m vale 6 en vez de 5. Para casi todo, una función static inline es mejor que una macro: tiene tipos, evalúa cada argumento una vez y se puede depurar.

14. Cadenas que se pegan solas

#include <stdio.h>

int main(void)
{
    const char *colores[] = {
        "rojo",
        "verde"
        "azul",
        "amarillo",
    };
    size_t n = sizeof colores / sizeof colores[0];
    printf("%zu colores\n", n);
    for (size_t i = 0; i < n; i++) printf("%s\n", colores[i]);
    return 0;
}
salida3 colores
rojo
verdeazul
amarillo

Dos literales de cadena seguidos se unen en uno: "verde" "azul" es "verdeazul". Es útil para partir textos largos en varias líneas, pero una coma olvidada en una lista crea un elemento menos sin ningún aviso de GCC. (Clang tiene un aviso para esto, -Wstring-concatenation.)

15. NaN no es igual a sí mismo

#include <stdio.h>
#include <math.h>

int main(void)
{
    double x = sqrt(-1.0);
    printf("%f %d %d\n", x, x == x, isnan(x));
    return 0;
}
salida-nan 0 1

No es una rareza de C sino del estándar de coma flotante IEEE 754, que usan casi todos los lenguajes: NaN («no es un número»), el resultado de operaciones imposibles, nunca es igual a nada, ni a sí mismo. Para detectarlo, isnan. (Que salga -nan con signo es cosa de cómo lo imprime glibc.)

Funciones

16. f() y f(void) significaban cosas distintas, hasta C23

#include <stdio.h>

void saludar()
{
    printf("Hola\n");
}

int main(void)
{
    saludar(1, 2, 3);
    return 0;
}
gcc -std=c17 parentesis.c && ./a.out
salida con -std=c17Hola

Hasta C17, void saludar() no significaba «sin parámetros», sino «con parámetros sin especificar», una herencia del C anterior a 1989, y se le podía pasar cualquier cosa. Por eso los buenos manuales siempre escribían (void). C23 lo cambió por fin: ahora () y (void) son lo mismo, y con el estándar por omisión de GCC 15, el mismo programa ya no compila:

gcc: errorparentesis_c23.c: In function 'main':
parentesis_c23.c:10:5: error: too many arguments to function 'saludar'; expected 0, have 3
   10 |     saludar(1, 2, 3);
      |     ^~~~~~~ ~

Herencias del pasado

Algunas rarezas ya no existen, pero siguen apareciendo en código antiguo, en libros y en respuestas de asistentes de IA entrenados con ese código:

  • gets, que lee una línea sin límite de tamaño, se eliminó en C11. Usa fgets.
  • Los trígrafos, secuencias como ??/ que el compilador convertía en \ para teclados que no tenían esas teclas, se eliminaron en C23. Antes, un comentario terminado en ??/ podía «comerse» la línea siguiente.
  • Las definiciones de funciones al estilo K&R, con los tipos de los parámetros entre el paréntesis y la llave (int suma(a, b) int a, b; { ... }), se eliminaron en C23.
  • El int implícito (declarar main() o una variable static x; sin tipo) se eliminó en C99. GCC lo siguió admitiendo con un aviso hasta la versión 13; desde GCC 14 es un error.
  • register pedía al compilador guardar una variable en un registro del procesador. Los compiladores lo ignoran desde hace décadas: deciden ellos, y mejor.

Todo el código de esta página se compiló con GCC 15.3 en x86-64 (glibc 2.41) y se ejecutó en un contenedor limpio; las salidas y los avisos están copiados de esa ejecución. Las que dependen de la plataforma o del compilador lo indican.