wandres.dev
ADEPTO · Arrays y strings

El decaimiento a puntero

Por qué un array no es un puntero pero se comporta como tal en casi toda expresión, qué contextos preservan su tipo real, y exactamente qué información se pierde cuando lo pasas a una función.

⏱ 16 min

Escribes int v[10] y crees tener un objeto de diez enteros. Lo tienes, pero apenas dura: en cuanto ese nombre aparece en casi cualquier expresión, el compilador lo sustituye en silencio por la dirección de su primer elemento. El array no es un puntero —tienen tipos distintos, tamaños distintos y semánticas distintas—, pero se comporta como tal casi siempre. Esa asimetría entre lo que el tipo declara y lo que la expresión produce está detrás de la mitad de los malentendidos sobre C, y de una fracción nada despreciable de sus vulnerabilidades.

🎯 Al terminar esta lección sabrás
  • La regla exacta de conversión de array a puntero y el tipo que resulta.
  • Los contextos donde el array conserva su tipo original y no decae.
  • Qué información se pierde por el camino y qué deja de poder comprobar el compilador.
  • Las convenciones de firma que reconstruyen lo perdido, incluida static en el índice.

La regla exacta del decaimiento

El estándar lo dice con precisión quirúrgica: una expresión de tipo array de N elementos de T se convierte en una expresión de tipo puntero a T cuyo valor es la dirección del elemento cero. La conversión es implícita, ocurre en la evaluación de la expresión, y no modifica el objeto: el array sigue midiendo lo que medía en memoria.

int v[10];

int  *p     = v;    /* v decae: puntero al primer elemento, tipo int*      */
int (*fp)[10] = &v; /* &v NO decae: puntero al array entero, tipo int(*)[10] */

sizeof(v);          /* 40 con int de 4 bytes: el array completo */
sizeof(p);          /* 8: el tamano de un puntero, no del array  */

v + 1;              /* avanza 4 bytes: un int  */
&v + 1;             /* avanza 40 bytes: un array de 10 int */

v + 1 y &v + 1 apuntan a direcciones distintas aunque v y &v valgan lo mismo numéricamente. Ese es el resumen del tema en dos líneas: el valor coincide, el tipo no, y en C la aritmética la dicta el tipo.

Por eso v[i] es literalmente *(v + i): primero decae, luego suma escalada, luego desreferencia. El corchete no es un operador de arrays, es azúcar sintáctico sobre punteros. De ahí sale la curiosidad de que i[v] compile —la suma es conmutativa— y de ahí sale también algo mucho más importante: la indexación en C no sabe nada de arrays, así que no puede comprobar nada sobre ellos.

📝
Por qué existe el decaimiento

No es un accidente ni una chapuza: es la decisión que hace que pasar un array cueste lo mismo que pasar un entero. En el modelo de llamada de C todo se pasa por valor, y copiar un array de un megabyte en cada llamada habría sido inviable en un PDP-11 y sigue siéndolo hoy. El decaimiento convierte el paso de arrays en el paso de una dirección, gratis y uniforme. Lo que C nunca añadió fue el mecanismo complementario —transportar la extensión junto a esa dirección—, y ese hueco es exactamente el que rellenan span en C++ y los slices de Rust.

Los contextos que preservan el tipo

El decaimiento tiene excepciones, y son exactamente las situaciones en las que el compilador necesita el array entero, no su primer elemento.

📏

Operando de sizeof

sizeof v devuelve el tamaño del array completo. Es el único lugar donde el tamaño sobrevive de forma fiable, y por eso el idioma sizeof v / sizeof v[0] solo funciona donde el array está declarado.

📍

Operando del ampersand

&v produce un puntero al array, de tipo distinto al puntero al primer elemento. Es la puerta de entrada a los arrays multidimensionales bien tipados.

🧬

typeof y alignof en C23

typeof(v) recupera el tipo array íntegro, así que typeof(v) w; declara otro array de diez enteros. C23 convierte en estándar lo que era una extensión ubicua.

✍️

Literal que inicializa un array

En char s[] = "hola"; el literal no decae: se copia carácter a carácter en s. En const char *p = "hola"; sí decae y solo copias la dirección.

Esa última distinción no es cosmética. char s[] = "hola" crea un array modificable de cinco bytes en el marco de pila; const char *p = "hola" te da un puntero a un objeto estático que casi con seguridad vive en una sección de solo lectura. Escribir en p[0] es comportamiento indefinido y en la práctica un fallo de segmentación.

El parámetro que miente

Aquí está la consecuencia que más daño hace. C permite escribir un parámetro con sintaxis de array, pero el estándar ajusta ese tipo a puntero antes de compilar nada:

void f(int arr[10]);   /* el compilador lo reescribe como int *arr */
void g(int arr[]);     /* identico al anterior */
void h(int *arr);      /* la misma funcion, sin disfraz */

Las tres declaran exactamente la misma función; puedes prototipar con una y definir con otra. El 10 de la primera es documentación para el lector humano y nada más: el compilador lo descarta y no comprueba que le pases diez elementos.

⚠️
sizeof dentro de la función responde a otra pregunta

Si escribes sizeof arr dentro de f, no obtienes 40: obtienes el tamaño de un puntero. No es un bug del compilador, es que arr ya no es un array, es un int *. GCC y Clang avisan con -Wsizeof-pointer-div y -Wsizeof-array-argument porque el error es tan frecuente que mereció diagnósticos propios. Actívalos y trátalos como errores: ese aviso casi siempre señala un cálculo de longitud roto, y un cálculo de longitud roto es un desbordamiento esperando su turno.

flowchart TD
A[Declaracion int v de 10 elementos] --> B[Objeto de 40 bytes con tipo completo]
B --> C[Aparece en una expresion]
C --> D[Decae a puntero al primer elemento]
D --> E[Se pierde la extension]
D --> F[Se pierde sizeof del array]
D --> G[Se pierde la comprobacion de limites del compilador]
E --> H[La funcion recibe solo una direccion]
F --> H
G --> H
H --> I[Hay que pasar la longitud por separado]
style I fill:#a6e3a1,color:#11111b

C23 ofrece una forma de recuperar algo de contrato: el calificador static dentro del índice del parámetro promete un mínimo de elementos.

/* Promesa: arr apunta a AL MENOS 4 enteros validos */
int suma4(int arr[static 4]) {
    return arr[0] + arr[1] + arr[2] + arr[3];
}

int par[2] = {1, 2};
suma4(par);   /* diagnostico en compiladores modernos */

No genera comprobación en tiempo de ejecución, pero habilita diagnósticos y permite al optimizador asumir que el puntero no es nulo. Es un contrato barato y expresivo, muy infrautilizado.

Reconstruir lo perdido

Tres estrategias, en orden creciente de rigor:

/* 1) La convencion universal: puntero mas longitud, siempre juntos */
size_t suma(const int *arr, size_t n);

/* 2) Puntero al array: el tipo lleva la extension consigo */
void procesa(int (*bloque)[64]);   /* exige exactamente 64 elementos */

/* 3) Envolver en struct: el array viaja por valor con su tamano */
struct buffer { size_t len; unsigned char datos[256]; };
void consume(struct buffer *b);    /* b->len siempre acompana a b->datos */

La opción 2 es la única en la que el compilador comprueba de verdad la extensión, y es la razón por la que las matrices bien tipadas del próximo capítulo son más seguras de lo que parecen. La opción 3 es la que usan los protocolos de red y los formatos binarios serios: el tamaño deja de ser un argumento que alguien puede olvidar y pasa a ser un campo que siempre está ahí.

Hay una cuarta vía moderna, incompleta pero útil: los atributos de la implementación. GCC y Clang entienden __attribute__((access(read_only, 1, 2))) para declarar que el primer parámetro es un buffer de solo lectura cuya longitud viene en el segundo, y _FORTIFY_SOURCE usa esa información para insertar comprobaciones cuando el tamaño es conocido en compilación. No es portable ni cubre todos los casos, pero convierte parte de la convención en algo que la herramienta puede verificar.

/* Un idioma util solo donde el array sigue siendo array */
#define ELEMENTOS(a) (sizeof (a) / sizeof (a)[0])

int v[10];
ELEMENTOS(v);      /* 10: correcto, v es un array en este ambito */

void f(int *p) {
    ELEMENTOS(p);  /* BASURA: sizeof(int*) / sizeof(int) */
}

Ese macro es correcto y es una trampa a la vez, porque su uso indebido no falla al compilar sino que produce un número pequeño y plausible. Por eso GCC ofrece -Wsizeof-pointer-div: la división de tamaños sobre un puntero es casi siempre este error.

El puntero dice dónde, jamás cuánto

Detente en la magnitud de lo que ocurre en el decaimiento, porque es la decisión de diseño más consecuente del lenguaje. En el momento en que un array cruza la frontera de una función, C tira a la basura la única pieza de información que hacía segura la indexación: la extensión. Lo que llega al otro lado es una dirección desnuda, un número sin memoria de su propio origen, indistinguible de un puntero a un solo elemento o a un objeto liberado. El compilador, que dentro de la función declarante podía haber comprobado los límites, se queda ciego, y la responsabilidad se transfiere íntegra al programador. Esta es la razón profunda de que las convenciones de C sean como son: memcpy, fread, snprintf, qsort, todas llevan un size_t pegado al puntero, y no por manía sino porque el tipo del puntero es incapaz de transportarlo. Interiorízalo hasta que sea reflejo: en C, un puntero a datos es medio dato. La otra mitad —la longitud— vive fuera del sistema de tipos, en tu disciplina, en tus firmas y en tus invariantes. Cada vez que un puntero y su longitud se separan, aunque sea por dos líneas, has abierto la puerta a un desbordamiento; cada vez que viajan juntos en la misma struct o en la misma firma, la has cerrado. Los lenguajes que vinieron después de C —Rust con sus slices, C++ con span— no inventaron nada conceptual: se limitaron a meter en el tipo lo que C dejó fuera. Programar C con solvencia es hacer a mano, con rigor, ese mismo emparejamiento.

⚔️ Cázale el tipo al array
  1. Declara int v[10] e imprime sizeof v, sizeof &v, sizeof v[0] y el valor numérico de v y &v. Explica por qué coinciden los valores y no los tipos.
  2. Compara la aritmética de v + 1 con la de &v + 1 restando las direcciones resultantes. Justifica la diferencia con el tipo de cada expresión.
  3. Pasa v a una función declarada como void f(int arr[10]) e imprime sizeof arr dentro. Compila con -Wall -Wextra y anota qué diagnóstico emite.
  4. Reescribe esa función con int arr[static 10] y llámala con un array de 3 elementos. Observa el aviso y razona qué garantiza y qué no garantiza static.
  5. Implementa la misma operación con las tres estrategias de la última sección y argumenta cuál usarías en una API pública y por qué.