Punteros vs Arrays (parte 1 de 2)

marzo 4, 2011

En C y, por herencia, en C++ los punteros y los arrays tienen una curiosa relación: son parecidos, en muchos casos intercambiables, pero no son iguales. Y muchos programadores los confunden, aun después de considerable experiencia. En este artículo me propongo aclarar de una vez por todas las diferencias entre estos dos conceptos.

Punteros

Mucho se ha escrito ya sobre punteros de C, por lo que no voy insistir demasiado en ellos: un puntero es la posición de memoria en la que se guarda una variable.
Gran parte de los problemas en entender los punteros que se encuentran los principiantes de C se deben, en mi opinión, a que la palabra puntero se utiliza indistintamente para referirse a la dirección de una variable, al tipo de esa dirección o a una variable de ese tipo. La diferencia es sutil pero importante. Para comprenderla se puede hacer un símil con el tipo más sencillo ‘entero’.

  • 3: es un entero.
  • int: es un tipo, de hecho es el tipo del entero anterior.
  • Si declaro int n; entonces n es una variable de tipo entero. El contenido de esta variable, es decir, el resultado de evaluar la expresión n es un entero. La diferencia entre la variable n y su contenido es análoga a la diferencia entre l-valor y r-valor, de la que ya hablé en alguna ocasión.

Las mismas observaciones se pueden hacer con punteros. Asumiendo la declaración int n; anterior:

  • &n es un puntero.
  • int* es un tipo de puntero, de hecho es el tipo del puntero anterior.
  • Si declaro int *p; entonces p es una variable de tipo puntero-a-entero. No debes confundir la variable con su contenido.

Algunos estudiantes de C se preguntan por qué se declara un puntero con * y no con &. Al fin y al cabo el asterisco se utiliza para acceder al contenido del puntero:

int a, b;
int &p1 = &a; //Error!
int *p2 = &a; //Ok: inicialización
*p2 = 1; //Asigna a 'b' no a 'p2'
p2 = &b; //Asigna a 'p2'

La sintaxis de C cobra sentido si nos damos cuenta de dos cosas.

  • La inicialización no es asignación. Sintácticamente son dos conceptos separados, que resulta que tienen un efecto práctico similar (más en C que en C++).
  • La declaración int *p se compone de dos partes: un especificador de tipo (type specifier), que es int, seguido de un declarador (declarator), *p.

Es decir, esta declaración afirma que *p es de tipo int; de esto se deduce que p debe ser de tipo puntero-a-entero.

Este hecho se revela de forma obvia con el ejemplo clásico:

int* p, q;

¿De qué tipo es q? Los blancos están puestos para despistar, pues no tienen valor sintáctico, así que esa declaración se lee como:

  • Especificador de tipo: int.
  • Declarador: hay dos, separados por una coma, *p y q.

Por lo tanto, el tipo de p es puntero-a-entero mientras que el de q es simplemente un entero.

Arrays

Un array es un tipo (o una variable, véase la discusión anterior) consistente en una sucesión de valores del mismo tipo de cierta longitud.

La declaración de un array es sencilla:

int r[10];

Aquí, int es el especificador de tipo y r[10] es el declarador. Es esto se deduce que el tipo de r es array-de-10-enteros.

Antes decía que había que distinguir entre una expresión, un tipo y una variable. Pues bien, lo más peculiar de los arrays en C es que no existen como expresión, aunque sí como variables y como tipos. Más técnicamente, sí existen expresiones, pero no r-valores de tipo array.
Este dato es importante, así que lo voy a repetir:

No existen r-valores de tipo array.

Pero entonces, ¿qué ocurre cuando se evalúa una expresión de tipo array? Que se convierte en un puntero al primer elemento del array. Se dice que el array “decae” (decays) en un puntero. Pero fíjate que el tipo del puntero decaído no es puntero-a-array, sino puntero-a-elemento.

1
2
3
4
int a[10];
int *p1;
p1 = a; //'a' decae a puntero
p1 = &a[0]; //igual que el anterior

Las asignaciones de las líneas 3 y 4 son en todo equivalentes.
Puedes pensar que la línea 4, en la que se obtiene expresamente el puntero al primer elemento del array, es ingeniosa porque evita la decadencia del array. ¡Nada más lejos de la realidad! Observemos la expresión &a[0] paso a paso:

  • a: Es una variable de tipo array-de-10-enteros. Es un l-valor.
  • a[0]: El operador “corchetes” se define como *(a + 0).
  • a + 0: El operador “suma” requiere dos r-valores: el 0 ya lo es, pero a no, así que se convierte a r-valor. ¡Ay! no, que es un array, entonces decae a puntero a primer elemento del array. La suma se realiza con aritmética de punteros, pero como el número es 0 no suma nada.
  • a[0] = *(a + 0): El operador “referencia” recibe un puntero y devuelve un r-valor, así que el resultado es el r-valor del primer elemento del array.
  • &a[0]: El operador “dirección” recibe un r-valor y devuelve un puntero. El resultado es el mismo puntero de dos pasos atrás.

Como puedes comprobar, el array decae exactamente igual. La única forma de evitar la decadencia del puntero es con la siguiente expresión:

int a[10];
int *p1 = (int*)&a;

El operador “dirección” sobre el array devuelve un puntero-a-array-de-10-enteros, que se convierte con un cast en puntero a entero. Ojo, escribo esto solo por interés ilustrativo, no lo hagas en programas de verdad: es feo y rebuscado.
Una cosa curiosa de este ejemplo es que un array-de-X decae de forma automática e implícita a un puntero-a-X, pero un puntero-a-array-de-X no decae nunca a nada. Así que si alguna vez quieres evitar la decadencia de un array (¿por qué?) utiliza un puntero a array.

int a[10];
int (*pa)[10] = &a;
int *e1 = pa; //error
int **e2 = pa; //error también
int *p = *pa; //ok, el array decae

Un efecto secundario del hecho de que no existan r-valores de tipo array es que los arrays no se pueden copiar:

int a[10], b[10];
a = b; //Error: no se puede convertir int* a int[10]

Si necesitamos copiar arrays lo más fácil es envolverlos en una estructura, pues las estructuras sí son copiables aun cuando incluyan arrays:

struct S
{
    int r[10];
} a, b;
a = b; //Ok

Parámetros de funciones

En C los parámetros de funciones se pasan siempre por copia, y puesto que los arrays no son copiables, pues ¡no pueden usarse como parámetros! En su lugar, se pasa un puntero al primer elemento del array y como la conversión es automática todo funciona bien.

void foo(int *r)
{ /*...*/
}
 
int a[10];
foo(a);

De hecho, como ayuda sintáctica, y confundiendo aún más al estudiante del lenguaje, C permite declarar el parámetro de la función como si fuera un array, y entiende que donde dices array quieres decir puntero. El siguiente ejemplo es exactamente equivalente al anterior.

void foo(int r[10])
{ /*...*/
}
 
int a[10];
foo(a);

El parámetro r de la función no es de tipo array-de-10-enteros, aunque lo parezca, sino de tipo puntero-a-entero. El tamaño del no-array (el 10) es puramente decorativo, es decir, sirve para indicar al usuario de la función cuál es el tamaño esperado del array que se pase como parámetro, pero el compilador no lo utilizará para nada. De hecho, si lo prefires, puedes omitir el número por completo (void foo(int r[])).
Esto explica que haya gente que declara la función main como main(int argc, char **argv) mientras que otros la declaran main(int argc, char *argv[]). Son idéndicas.
Si te cuesta creer que el compilador reinterprete así tu parámetro cuidadosamente declarado, prueba el siguiente programa:

#include <stdio.h>
 
void fun(int r[10])
{ 
    printf("en fun: %d\n", (int)sizeof(r));
 
    printf("antes: %p\n", r);
    r = NULL; //r es asignable!
    printf("después: %p\n", r);
}
 
int main()
{
    int a[10];
    printf("en main: %d\n", (int)sizeof(a));
    fun(a);
    return 0;
}

La forma más fácil de comprobar si el compilador lo considera un array o un puntero (aparte de con un depurador) es con sizeof: aplicado a un array da el tamaño del tipo base multiplicado por el número de elementos; pero aplicado a un puntero devuelve siempre el mismo tamaño (4 en sistemas de 32 bits, 8 en sistemas de 64 bits).

Continúa leyendo en la 2ª parte.

Artículos relacionados:

  1. Punteros vs Arrays (parte 2 de 2) En el post anterior describí las diferencias principales entre array...
  2. Referencias a referencias ¿Es posible en C++ declarar una referencia a referencia? Y...
  3. Referencias a R-valor (1ª parte) En un post anterior comentaba que en C++ no se...
  4. Referencias a R-valor (2ª parte) En el post anterior describía cómo las referencias a R-valor...
  5. L-valores y R-valores error: se requiere un l-valor como operando izquierdo de la...

Deja un comentario