wandres.dev
PRACTICANTE · Tipos y novedades de C23

Promociones y conversiones: las reglas que muerden

El rango de conversión, las promociones enteras y las conversiones aritméticas usuales paso a paso, los truncamientos silenciosos y por qué size_t decide la corrección de tus bucles.

⏱ 17 min

C convierte tipos por ti constantemente, sin pedir permiso y sin dejar rastro en el código fuente. Esas conversiones implícitas obedecen a un algoritmo preciso que casi nadie recuerda de memoria, y de él salen los bugs más caros del lenguaje: los que no rompen nada, solo devuelven el resultado equivocado con ciertos valores. Aprender el algoritmo es dejar de programar por superstición.

🎯 Al terminar esta lección sabrás
  • Aplicar el rango de conversión y las promociones enteras sin adivinar.
  • Ejecutar mentalmente las conversiones aritméticas usuales, paso a paso.
  • Reconocer los truncamientos y cambios de signo que el compilador no señala por defecto.
  • Usar size_t y ptrdiff_t con la disciplina que su naturaleza sin signo exige.

Rango y promoción entera

Cada tipo entero tiene un rango de conversión: bool por debajo de char, este por debajo de short, luego int, long, long long. Un tipo sin signo tiene el mismo rango que su compañero con signo, y los _BitInt se ordenan entre sí por anchura, siempre por debajo de cualquier tipo estándar de anchura igual o mayor.

Sobre ese orden se define la promoción entera, que actúa antes de casi cualquier operación aritmética: todo tipo de rango inferior al de int se convierte a int si int puede representar todos sus valores, y a unsigned int en caso contrario. Afecta a char, short, bool, los campos de bits estrechos y los enumerados; deja intactos a int y superiores.

unsigned char a = 200, b = 100;
a + b;                 // ambos se promocionan a int: vale 300, no 44

unsigned short x = 65535;     // en LP64, short mide 16 bits e int 32
x * x;                 // se promocionan a int con signo: 4294836225 NO cabe
                       // en int → desbordamiento con signo → comportamiento indefinido

El segundo caso es el que sorprende: dos operandos sin signo producen comportamiento indefinido porque la promoción los convirtió a un tipo con signo. Se le conoce como la trampa de la promoción sin signo, y es la razón de escribir (unsigned)x * x cuando se trabaja con hashes de 16 bits.

📝
Las promociones no son un detalle académico

La promoción explica por qué ~ sobre un unsigned char de valor 0 da -1 y no 255, por qué los desplazamientos de tipos pequeños ocurren en 32 bits y por qué las funciones variádicas nunca reciben un char. También explica por qué _BitInt de C23 se diseñó fuera del sistema de promociones: sin esa excepción, un _BitInt(12) haría su aritmética en 32 bits y perdería su razón de ser.

Las conversiones aritméticas usuales

Cuando un operador binario recibe dos tipos aritméticos distintos, C los lleva a un tipo común siguiendo un algoritmo fijo. Tras aplicar las promociones enteras a ambos operandos:

  1. Si hay coma flotante, gana el tipo flotante de mayor rango y se acabó.
  2. Si ambos tienen la misma condición de signo, gana el de mayor rango.
  3. Si el tipo sin signo tiene rango mayor o igual, gana él.
  4. Si el tipo con signo puede representar todos los valores del sin signo, gana el con signo.
  5. En otro caso, gana la versión sin signo del tipo con signo.
flowchart TD
A[Operandos de tipos distintos] --> B[Promocion entera de ambos]
B --> C[Alguno es de coma flotante]
C -->|si| D[Gana el flotante de mayor rango]
C -->|no| E[Misma condicion de signo]
E -->|si| F[Gana el de mayor rango]
E -->|no| G[El sin signo tiene rango mayor o igual]
G -->|si| H[Gana el tipo SIN signo: aqui nacen los bugs]
G -->|no| I[El con signo representa todo el rango del otro]
I -->|si| J[Gana el con signo]
I -->|no| K[Gana la version sin signo del con signo]
style H fill:#f38ba8,color:#11111b
style D fill:#89b4fa,color:#11111b

El paso 3 es el que muerde. En LP64, comparar un int con un unsigned int aplica la regla y convierte el int a sin signo:

int s = -1;
unsigned u = 1;
s < u;                 // false: -1 se vuelve 4294967295

int n = -1;
n < sizeof(int);       // false por lo mismo: sizeof devuelve size_t

El compilador sabe detectarlo: -Wsign-compare, incluido en -Wextra, señala exactamente estas comparaciones. Actívalo y trátalo como error.

Conversiones que muerden en silencio

Las conversiones de asignación son otra familia distinta y tienen sus propias trampas.

Estrechamiento entero. Al asignar un valor que no cabe en el tipo destino, el resultado se reduce módulo el rango del destino. Antes de C23, hacerlo hacia un tipo con signo era comportamiento definido por la implementación; al fijar el complemento a dos, C23 lo convierte en un truncamiento de bits bien definido. Definido no significa correcto: sigue perdiendo información.

int grande = 300;
uint8_t chico = grande;        // 44, silenciosamente
int32_t medio = 5000000000LL;  // truncado, sin aviso salvo con -Wconversion

El signo de char. Si es con signo o sin signo lo decide la implementación: con signo en x86, sin signo en ARM por defecto. De ahí el error clásico con ctype.h, cuyas funciones exigen un valor representable como unsigned char o EOF; pasarles un char negativo es comportamiento indefinido.

char c = leer();
if (isspace(c)) { }                    // mal: c puede ser negativo
if (isspace((unsigned char)c)) { }     // bien

De flotante a entero. La conversión trunca hacia cero, pero si la parte entera no cabe en el destino el comportamiento es indefinido, no un valor saturado. Comprueba el rango antes de convertir.

El centinela de getchar. Devuelve int, no char, y no por descuido: necesita distinguir los 256 valores posibles de un byte del centinela EOF, que vale -1. Guardar su resultado en un char colapsa ambos mundos, y el fallo depende del signo del char de la plataforma: donde es sin signo, EOF no se detecta jamás y el bucle no termina; donde es con signo, el byte 0xFF se confunde con EOF y el fichero se corta antes de tiempo.

int c;                          // int, siempre
while ((c = getchar()) != EOF)
    putchar(c);
⚠️
Los avisos que de verdad importan aquí

-Wall no basta para esta clase de errores. La combinación mínima es -Wextra -Wconversion -Wsign-conversion, y conviene añadir -Wdouble-promotion en código con coma flotante. Generan ruido al principio en bases de código viejas; ese ruido es exactamente el inventario de conversiones que nadie revisó nunca.

Por qué size_t decide la corrección

size_t es el tipo sin signo que devuelve sizeof y que usan malloc, strlen y todas las funciones de memoria. Es lo bastante ancho para medir el objeto más grande posible, y ser sin signo contamina toda expresión donde aparezca.

size_t n = 0;

for (size_t i = 0; i < n - 1; i++) { }   // n - 1 vale SIZE_MAX: bucle infinito

for (size_t i = n - 1; i >= 0; i--) { }  // i >= 0 es siempre cierto: nunca termina

Ambos fallos son de manual y ninguno provoca un aviso por defecto. Las formas correctas de recorrer hacia atrás son for (size_t i = n; i-- > 0;) o usar ptrdiff_t, el tipo con signo que resulta de restar dos punteros y el candidato natural para índices que pueden ir en ambos sentidos.

Elegir bien dentro de esta familia es la mitad del trabajo:

📏

size_t

Sin signo, es lo que devuelve sizeof. Tamaños de objetos, longitudes e índices que solo crecen. Se imprime con %zu y su máximo es SIZE_MAX.

↔️

ptrdiff_t

Con signo, es lo que resulta de restar dos punteros. Diferencias, desplazamientos e índices que pueden decrecer o volverse negativos. Se imprime con %td.

🔢

uintptr_t

Entero sin signo capaz de guardar un void* y devolverlo intacto. Para alinear direcciones o etiquetar punteros; nunca para aritmética de propósito general.

La otra cara de size_t es el cálculo de tamaños antes de reservar memoria: n * sizeof(elem) puede envolver módulo SIZE_MAX y devolver un número pequeño, con lo que el malloc tiene éxito y la escritura posterior se sale del bloque. Es el patrón de vulnerabilidad conocido como desbordamiento de entero previo a la reserva, y se cierra con ckd_mul de C23.

Las conversiones implícitas son una fuga en el sistema de tipos

Un sistema de tipos sirve para que ciertas frases sean imposibles de escribir. Las conversiones implícitas de C abren un boquete en esa promesa: int y unsigned son tipos distintos, con rangos distintos y aritméticas distintas, y sin embargo el lenguaje los mezcla en la misma expresión sin decir nada, resolviendo el conflicto con una regla que favorece al sin signo. El resultado es un lenguaje en el que la corrección de una comparación depende de tipos que no aparecen escritos en la línea que estás leyendo. La respuesta profesional no es memorizar la tabla, es eliminar la ambigüedad en el origen: un solo tipo por dominio de valores, size_t para tamaños e índices, ptrdiff_t para diferencias, enteros de anchura declarada para datos externos, y ningún cruce entre familias sin un cast explícito que documente la intención. Añade -Wconversion -Wsign-conversion como error de compilación y el compilador pasa de ser cómplice silencioso a auditor. Esa disciplina no es pedantería: es lo que separa el código que funciona con los valores que probaste del código que funciona con todos.

⚔️ Caza tus conversiones
  1. Multiplica dos unsigned short de valor 65535 y explica por qué el sanitizador de enteros protesta.
  2. Escribe if (-1 < sizeof(int)) y razona el resultado antes de compilarlo con -Wsign-compare.
  3. Recorre un array hacia atrás con size_t de las dos formas incorrectas y corrígelo con i-- > 0.
  4. Pasa un char con el bit alto activo a isalpha y observa qué cambia al convertirlo a unsigned char.
  5. Recompila un proyecto tuyo con -Wconversion -Wsign-conversion y clasifica los avisos en benignos y reales.