"Arreglos y punteros son lo mismo". "Internamente, los arreglos son punteros". Casi todos los que programamos en C y C++ nos hemos encontrado en más de una ocasión con alguna de estas afirmaciones. Lo cierto es que los arreglos y los punteros tienen varios puntos en común, pero no son lo mismo. En este post veremos algunas de sus diferencias, desde las más obvias hasta algunas un poco más sutiles, que suelen ser motivo de muchas confusiones.
Cuando haga referencia al estándar, me estaré refieriendo tanto al de C como el de C++, pues coinciden en los puntos tratados en este post.
He colocado algunas secciones marcadas como opcionales. Se trata de explicaciones complementarias que pueden ser de interés pero no son indispensables para entender el post.
Aunque esto va dirigido a quienes ya tienen una buena base en estos temas, definiremos, de forma muy breve y a manera de recordatorio, estos tipos de datos.
Los punteros son variables cuyo valor es una dirección de memoria. Así, si tenemos uno cuyo valor es 1234, decimos que apunta a la dirección 1234. Como cualquier variable, un puntero está almacenado en una dirección de la memoria, que además es distinta de aquella a la que apunta. En esta imagen tenemos un puntero localizado en la dirección 1000, que hace referencia, o apunta, a la dirección 1234:
Cuando declaramos un puntero, únicamente se reserva memoria para él. Antes de poder usarlo, debemos asegurarnos de que apunte a alguna dirección correcta, ya sea reservándola de forma dinámica:
o directamente haciendo que apunte a la dirección de alguna variable:
Un arreglo es una variable que contiene uno o más elementos. La dirección del arreglo es la misma que la de su primer elemento. Veamos un arreglo de 4 int almacenado en la dirección 1000:
Como vemos en la imagen, arreg es todo el arreglo completo, y su tamaño es el de todos sus elementos. A pesar de lo que a veces se lee, un arreglo no es un puntero disfrazado, ni nada por el estilo. Es simplemente eso, un arreglo, cuyo tipo viene determinado por la cantidad y tipo de elementos que contiene. Así, en la siguiente declaración:
arreg es una variable cuyo tipo es arreglo de 4 int. Es importante comprender bien esto, pues aunque en el lenguaje cotidiano podamos describirlo simplemente como un arreglo de int, su tipo completo (de manera que el compilador lo entienda) incluye el tamaño. A diferencia de los punteros, cuando declaramos un arreglo, se reserva suficiente memoria para todos sus elementos y está listo para usarse.
Tanto los arreglos como los punteros son lo que se conoce como tipos derivados, porque se forman a partir de otros tipos. De esta manera tenemos, por ejemplo, punteros a int, punteros a float, punteros a char, arreglos de 4 int, arreglos de 4 float, arreglos de 6 int, etc.
Algunos manuales dicen que el nombre de un arreglo es un puntero a su primer elemento, lo cual no es del todo cierto. Lo que en realidad sucede es que, en casi todas las circunstancias, cuando escribimos una expresión de tipo arreglo, el compilador la convierte a una expresión de tipo puntero, que hace referencia a su primer elemento.
Un ejemplo de expresión de tipo arreglo es el nombre del arreglo sin índices:
Lo anterior se convierte a algo así:
En algunos casos podemos usar notación de punteros con los arreglos y viceversa. Por ejemplo, en el siguiente código:
ptr apunta a un bloque de memoria que contiene 10 objetos int. Es válido hacer esto:
La línea anterior modifica el int que se encuentra 4 posiciones más allá de la dirección a la que apunta ptr. También podemos usar notación de punteros con arreglos:
es decir, arreg se convierte a puntero a la dirección a su primer elemento; luego, se le suman 4 posiciones; después, el operador * indica que deseamos acceder al valor de arreg+4 y no a su dirección; y finalmente lo modificamos.
De hecho, siempre que accedemos a algún elemento específico de un arreglo, el compilador calcula su dirección de esa misma forma: dirección_del_arreglo + índice. En el caso de un arreglo bidimensional, la fórmula para encontrar la dirección de un elemento es: dirección_del_arreglo + (num_de_fila * cantidad_de_columnas + num_de_columna).
Antes de seguir, vamos a dejar algo en claro: cuando se dice que el nombre del arreglo sin índices se convierte a puntero, nos referimos a un puntero "conceptual". Aclaro esto porque se podría pensar que el compilador añade punteros para cada uno de los arreglos que declaramos, lo cual no ocurre. Sería un desperdicio de memoria, ya que los punteros, como cualquier otra variable, ocupan espacio. Veremos a continuación lo que en realidad ocurre.
Los arreglos no se pueden modificar
Todo programador sabe que, aunque es posible modificar los elementos de un arreglo (siempre que no lo hayamos declarado como const), no podemos hacer lo mismo con el arreglo en sí. El estándar dice que, cuando una expresión tipo arreglo se convierte a expresión tipo puntero, la expresión resultante es un rvalue (rvalue puro en la terminología de C++), es decir, un valor no modificable y que no es ningún objeto (variable, función, etc.) del programa. Un ejemplo de rvalue es el número 5.
De lo anterior se desprende lo que ya habíamos anticipado: tal vez sería más preciso decir que el nombre de un arreglo (o cualquier expresión que haga referencia a un arreglo) se convierte, no a un puntero, sino a la dirección de su primer elemento. Es por eso que no podemos modificarlos:
A la izquierda del signo igual, tenemos a1, que es una expresión de tipo "arreglo de 5 int". El compilador la convierte a la dirección a su primer elemento:
Suponiendo que la dirección inicial del arreglo a1 sea 1000, quedaría algo parecido a esto:
que obviamente no es válido, pues estaríamos intentando modificar un valor constante.
De cualquier forma, considero adecuado seguir usando el término "puntero" al hablar de esta conversión. Simplemente, hay que tener presente que no es una variable puntero.
================================================================================
Opcional:
A veces se dice que el nombre de un arreglo se convierte a un puntero constante. Esto también es correcto, siempre y cuando no olvidemos que no se trata de un puntero "real", pues los punteros const también ocupan espacio, además de que son lvalues (objetos del programa) no modificables, pero el estándar dice que debe ser un rvalue. La dirección de un arreglo (así como cualquier constante literal), en cambio, sí es un rvalue.
Podríamos comprobar esto observando el código generado por un compilador. Veríamos que, en efecto, no se reserva más espacio que el ocupado por el arreglo, y las partes donde escribimos su nombre son simplemente reemplazadas por su dirección. La forma de ver y analizar el código objeto resultado de una compilación rebasa por mucho el objetivo de este post (por no hablar de que se necesitan conocimientos en lenguaje ensamblador), pero no quería dejar de mencionar que es posible verificar todo lo expuesto anteriormente.
================================================================================
¿Qué hay de los arreglos multidimensionales?
Todo lo visto anteriormente aplica también a los arreglos de más de una dimensión, pero tenemos que aclarar un par de detalles más. La conversión arreglo-puntero sólo se efectúa en la primera dimensión. Veamos un ejemplo:
matriz es un arreglo de 5 arreglos de 4 enteros. Cuando escribamos su nombre sin índices, el compilador lo convertirá a un puntero a arreglo(s) de 4 enteros. La parte en negrita es la única que sufre la conversión. Es por eso que esto no compilaría:
func espera un puntero a puntero a int. Lo que le estamos intentando pasar en la última línea es un arreglo de arreglos. Si la regla de conversión aplicara a todas las dimensiones, el compilador convertiría matriz en un puntero a puntero a int. Sabemos que no es así: el compilador sólo convierte la primera dimensión, así que matriz es convertido a puntero a arreglo de 4 int, que es un tipo diferente e incompatible.
Nota: es posible que algunos compiladores acepten el código anterior, pero el programa fallará durante la ejecución.
Para asegurarnos de haberlo comprendido, veremos un ejemplo más:
planos es un arreglo de 5 arreglos de 4 arreglos de 9 int. Cuando escribamos su nombre solo, se convertirá a un puntero a arreglos de 4 arreglos de 9 int.
Paso de arreglos a funciones
La regla de conversión también se cumple con los parámetros de una función. Tenemos tres formas de indicar que una función recibe un arreglo (supongamos que es de 5 elementos):
En la primera declaración, el parámetro a es de tipo arreglo de int. No especificamos el tamaño (lo que se conoce como un tipo incompleto) pero en este caso es válido, ya que de cualquier forma se perderá, dado que el compilador lo convertirá a puntero a int. En la segunda forma, el tipo de a es arreglo de 5 int y también se convierte a puntero a int. La última línea ya es directamente un puntero, por lo que no hay conversión. Podemos ver que las tres formas son equivalentes: sin importar cómo declaremos la función, el compilador la convertirá a la tercera.
Puesto que siempre se perderá el tamaño, es común pasarlo como un segundo parámetro
Nota: normalmente declararía el parámetro tam como int, pero en apego a las buenas prácticas, en este post usé size_t (definido en stddef.h, o cstddef en C++), que es el tipo recomendado para las variables que harán referencia a tamaños. En los siguientes ejemplos omitiré el parámetro del tamaño para simplificar el código.
Si lo que queremos es una función que reciba un arreglo bidimensional (digamos, de 5x4), podemos declararla de cualquiera de estas formas:
Igual que en el caso anterior, las dos primeras formas serán convertidas a la tercera (puntero a arreglo de 4 enteros). Es importante el paréntesis, porque sin él estaríamos indicando un arreglo de punteros, que es algo totalmente diferente.
La segunda dimensión no es opcional. Esto no es válido:
porque el compilador lo convertiría a esto (recordemos, la conversión se efectúa sólo en la primera dimensión):
y aquí sí nos encontraríamos con que a tiene un tipo incompleto, que el compilador no tiene forma de resolver.
Con un arreglo de 3 dimensiones haríamos esto:
y obviamente, sucede lo mismo que en los casos anteriores.
Resumiendo, la regla general para pasar arreglos a funciones es que únicamente la primera dimensión es opcional (y se pierde). Todas las demás son obligatorias.
No está de más decir que las dimensiones que escribamos tienen que coincidir con las reales. No podemos hacer esto:
Nuevamente, es posible que algún compilador no marque error, pero al ejecutar el programa vamos a tener problemas porque, cuando la función quiera acceder a los elementos de la matriz, sus cálculos serán erróneos.
================================================================================
Opcional:
¿"Expresiones" tipo arreglo/puntero?
En la regla de conversión que puse al inicio de este post, se habla de que una expresión de tipo arreglo se convierte a expresión de tipo puntero. Esto es porque la conversión no sólo tiene efecto cuando escribimos el nombre de un arreglo sin índices, sino con cualquier expresión que tenga este tipo. Para entender esto, supongamos que tenemos esta variable:
es decir, un arreglo de 5 arreglos de 4 int. Si en alguna parte de nuestro código escribimos esto:
nos estamos refiriendo al elemento 1 de la variable matriz. Los elementos de matriz son de tipo arreglo de 4 int, por lo tanto, matriz[1] es una expresión de tipo arreglo y se convertirá en una de tipo puntero. Esto significa que el siguiente código es válido:
porque la función espera un puntero a int, y le estamos pasando un arreglo de 4 int (específicamente, el segundo de los arreglos que contiene matriz), que es convertido justamente a puntero a int.
================================================================================
Cadenas de arreglos char y punteros a char
Vamos a ver un ejemplo de dos declaraciones de variables que pudieran parecer equivalentes pero no lo son en absoluto:
En la primera se reserva espacio para 11 caracteres (los 10 de la cadena de inicialización más el caracter ''), que son inicializados con los valores 'h', 'o', 'l', etc. Como sucede con cualquier arreglo no const, podemos modificar sus elementos sin ningún problema. Por ejemplo, cambiar la 'h' a mayúscula:
En el caso de ptr
ocurre algo diferente. Se reserva espacio para un puntero. Luego, el compilador construye la cadena literal "hola mundo" y la coloca en algún lugar de la memoria, y finalmente hace que ptr apunte a ella. El estándar dice que si intentamos modificar el contenido de una cadena literal, el resultado queda indefinido (traducción: jamás intentemos hacerlo), así que es común que, por cuestiones de eficiencia, los compiladores coloquen las cadenas literales en una parte de la memoria marcada como de sólo lectura. Entonces, si intentamos hacer esto:
el compilador lo permitirá, porque ptr no fue declarado como const; sin embargo, al ejecutar la instrucción anterior, muy probablemente se generará un error de violación de segmento (porque estamos tratando de modificar memoria de sólo lectura) y el programa se cerrará.
Algunos compiladores permiten, ya sea de forma predeterminada o cambiando alguna configuración, modificar estas cadenas, pero eso queda fuera de las reglas del lenguaje y no se recomienda. De hecho, GCC era uno de esos compiladores, pero en las versiones recientes ya se eliminó la opción que hacía posible usar cadenas literales modificables.
Por todo lo anterior, no debemos declarar una cadena de la forma en que lo hicimos en este último ejemplo. En su lugar, declararemos el puntero char como constante:
Si intentamos modificar los datos apuntados por ptr, el compilador detectará y marcará el error.
Si lo que queremos es una cadena modificable, necesitamos usar un arreglo char, o bien, un puntero a char inicializado correctamente mediante malloc o new, o, mejor aún, usemos la clase string si estamos programando en C++.
No siempre ocurre la conversión de arreglo a puntero
Al principio se dijo que, aunque casi siempre una expresión tipo arreglo se convierte a puntero, hay excepciones. Las dos más importantes son, cuando al arreglo le aplicamos el operador de dirección &, y sizeof.
Podemos hacer la siguiente prueba, que imprime la cantidad de bytes que ocupan un arreglo y un puntero:
Si el nombre del arreglo fuera un puntero, deberíamos obtener el mismo número, pero no es así, ya que, como se dijo, cuando le aplicamos el operador sizeof no hay conversión.
Veamos el otro caso:
Se imprime la misma dirección. En la primera línea, el nombre arreg se convierte a puntero a su primer elemento y se imprime su valor, es decir, la dirección del primer elemento. En la segunda línea no hay conversión, así que le estamos aplicando el operador & al arreglo en sí, por lo que se imprime la dirección del arreglo, que es la misma de su primer elemento.
Si lo intentamos con el puntero
obtendremos direcciones distintas. En el primero caso se imprime el valor del puntero (la dirección a la que apunta). En el segundo, mostramos la dirección en la que está almacenado el propio puntero.
================================================================================
Opcional:
Hay que tener cuidado al usar el operador &, como en el caso siguiente.
Al usar la función scanf, hay que pasarle la dirección de las variables en las que queremos que almacene valores. Por eso normalmente usamos el operador &:
Cuando queremos leer una cadena, no es necesario usar este operador:
porque, como ya sabemos, con tan sólo escribir el nombre del arreglo, éste es convertido a puntero. Aquí el & es redundante, pero no pasa nada, ya que le estamos enviando a scanf la dirección de cadena, que es la misma que la de su primer elemento. Si tratamos de hacer lo mismo con un char *
el programa va a fallar. Lo que queremos es que scanf lea una cadena y la almacene en el bloque de memoria al que apunta cadena, pero le estamos pasando la dirección del puntero cadena, así que scanf almacenará los datos en el lugar equivocado, probablemente sobrescribiendo otras variables del programa. Para evitar confusiones (y de paso no ser redundantes) es mejor no usar el operador de dirección al leer cadenas o, dicho de forma más general, al pasar cadenas a funciones, pues este problema no es exclusivo de scanf.
A los lectores observadores: sé que el scanf anterior es inseguro porque no especifica máximo de caracteres a leer, pero no compliquemos más este post.
================================================================================
Y con esto llegamos al final del post. Espero que les haya sido de utilidad. No duden en dejar comentarios con cualquier duda o sugerencia que tengan.
Muy pronto: Números flotantes: todo lo que siempre quisiste saber y no te atrevías a preguntar
.
Mis otros post:
Cuando haga referencia al estándar, me estaré refieriendo tanto al de C como el de C++, pues coinciden en los puntos tratados en este post.
He colocado algunas secciones marcadas como opcionales. Se trata de explicaciones complementarias que pueden ser de interés pero no son indispensables para entender el post.
Aunque esto va dirigido a quienes ya tienen una buena base en estos temas, definiremos, de forma muy breve y a manera de recordatorio, estos tipos de datos.
Los punteros son variables cuyo valor es una dirección de memoria. Así, si tenemos uno cuyo valor es 1234, decimos que apunta a la dirección 1234. Como cualquier variable, un puntero está almacenado en una dirección de la memoria, que además es distinta de aquella a la que apunta. En esta imagen tenemos un puntero localizado en la dirección 1000, que hace referencia, o apunta, a la dirección 1234:
Cuando declaramos un puntero, únicamente se reserva memoria para él. Antes de poder usarlo, debemos asegurarnos de que apunte a alguna dirección correcta, ya sea reservándola de forma dinámica:
// C
int *ptr = malloc(numBytes);
// C++
int *ptr = new int[numElementos];
o directamente haciendo que apunte a la dirección de alguna variable:
int n;
int *ptr = &n;
Un arreglo es una variable que contiene uno o más elementos. La dirección del arreglo es la misma que la de su primer elemento. Veamos un arreglo de 4 int almacenado en la dirección 1000:
Como vemos en la imagen, arreg es todo el arreglo completo, y su tamaño es el de todos sus elementos. A pesar de lo que a veces se lee, un arreglo no es un puntero disfrazado, ni nada por el estilo. Es simplemente eso, un arreglo, cuyo tipo viene determinado por la cantidad y tipo de elementos que contiene. Así, en la siguiente declaración:
int arreg[4];
arreg es una variable cuyo tipo es arreglo de 4 int. Es importante comprender bien esto, pues aunque en el lenguaje cotidiano podamos describirlo simplemente como un arreglo de int, su tipo completo (de manera que el compilador lo entienda) incluye el tamaño. A diferencia de los punteros, cuando declaramos un arreglo, se reserva suficiente memoria para todos sus elementos y está listo para usarse.
Tanto los arreglos como los punteros son lo que se conoce como tipos derivados, porque se forman a partir de otros tipos. De esta manera tenemos, por ejemplo, punteros a int, punteros a float, punteros a char, arreglos de 4 int, arreglos de 4 float, arreglos de 6 int, etc.
Algunos manuales dicen que el nombre de un arreglo es un puntero a su primer elemento, lo cual no es del todo cierto. Lo que en realidad sucede es que, en casi todas las circunstancias, cuando escribimos una expresión de tipo arreglo, el compilador la convierte a una expresión de tipo puntero, que hace referencia a su primer elemento.
Un ejemplo de expresión de tipo arreglo es el nombre del arreglo sin índices:
arreg
Lo anterior se convierte a algo así:
&arreg[0]
En algunos casos podemos usar notación de punteros con los arreglos y viceversa. Por ejemplo, en el siguiente código:
int *ptr = malloc(10 * sizeof(int));
ptr apunta a un bloque de memoria que contiene 10 objetos int. Es válido hacer esto:
ptr[4] = 15;
La línea anterior modifica el int que se encuentra 4 posiciones más allá de la dirección a la que apunta ptr. También podemos usar notación de punteros con arreglos:
int arreg[10];
*(arreg+4) = 15;
es decir, arreg se convierte a puntero a la dirección a su primer elemento; luego, se le suman 4 posiciones; después, el operador * indica que deseamos acceder al valor de arreg+4 y no a su dirección; y finalmente lo modificamos.
De hecho, siempre que accedemos a algún elemento específico de un arreglo, el compilador calcula su dirección de esa misma forma: dirección_del_arreglo + índice. En el caso de un arreglo bidimensional, la fórmula para encontrar la dirección de un elemento es: dirección_del_arreglo + (num_de_fila * cantidad_de_columnas + num_de_columna).
Antes de seguir, vamos a dejar algo en claro: cuando se dice que el nombre del arreglo sin índices se convierte a puntero, nos referimos a un puntero "conceptual". Aclaro esto porque se podría pensar que el compilador añade punteros para cada uno de los arreglos que declaramos, lo cual no ocurre. Sería un desperdicio de memoria, ya que los punteros, como cualquier otra variable, ocupan espacio. Veremos a continuación lo que en realidad ocurre.
Los arreglos no se pueden modificar
Todo programador sabe que, aunque es posible modificar los elementos de un arreglo (siempre que no lo hayamos declarado como const), no podemos hacer lo mismo con el arreglo en sí. El estándar dice que, cuando una expresión tipo arreglo se convierte a expresión tipo puntero, la expresión resultante es un rvalue (rvalue puro en la terminología de C++), es decir, un valor no modificable y que no es ningún objeto (variable, función, etc.) del programa. Un ejemplo de rvalue es el número 5.
De lo anterior se desprende lo que ya habíamos anticipado: tal vez sería más preciso decir que el nombre de un arreglo (o cualquier expresión que haga referencia a un arreglo) se convierte, no a un puntero, sino a la dirección de su primer elemento. Es por eso que no podemos modificarlos:
int a1[5], a2[5];
a1 = a2;
A la izquierda del signo igual, tenemos a1, que es una expresión de tipo "arreglo de 5 int". El compilador la convierte a la dirección a su primer elemento:
&a1[0] = a2;
Suponiendo que la dirección inicial del arreglo a1 sea 1000, quedaría algo parecido a esto:
1000 = a2;
que obviamente no es válido, pues estaríamos intentando modificar un valor constante.
De cualquier forma, considero adecuado seguir usando el término "puntero" al hablar de esta conversión. Simplemente, hay que tener presente que no es una variable puntero.
================================================================================
Opcional:
A veces se dice que el nombre de un arreglo se convierte a un puntero constante. Esto también es correcto, siempre y cuando no olvidemos que no se trata de un puntero "real", pues los punteros const también ocupan espacio, además de que son lvalues (objetos del programa) no modificables, pero el estándar dice que debe ser un rvalue. La dirección de un arreglo (así como cualquier constante literal), en cambio, sí es un rvalue.
Podríamos comprobar esto observando el código generado por un compilador. Veríamos que, en efecto, no se reserva más espacio que el ocupado por el arreglo, y las partes donde escribimos su nombre son simplemente reemplazadas por su dirección. La forma de ver y analizar el código objeto resultado de una compilación rebasa por mucho el objetivo de este post (por no hablar de que se necesitan conocimientos en lenguaje ensamblador), pero no quería dejar de mencionar que es posible verificar todo lo expuesto anteriormente.
================================================================================
¿Qué hay de los arreglos multidimensionales?
Todo lo visto anteriormente aplica también a los arreglos de más de una dimensión, pero tenemos que aclarar un par de detalles más. La conversión arreglo-puntero sólo se efectúa en la primera dimensión. Veamos un ejemplo:
int matriz[5][4];
matriz es un arreglo de 5 arreglos de 4 enteros. Cuando escribamos su nombre sin índices, el compilador lo convertirá a un puntero a arreglo(s) de 4 enteros. La parte en negrita es la única que sufre la conversión. Es por eso que esto no compilaría:
void func(int **mat);
...
int matriz[5][4];
funcion(matriz);
func espera un puntero a puntero a int. Lo que le estamos intentando pasar en la última línea es un arreglo de arreglos. Si la regla de conversión aplicara a todas las dimensiones, el compilador convertiría matriz en un puntero a puntero a int. Sabemos que no es así: el compilador sólo convierte la primera dimensión, así que matriz es convertido a puntero a arreglo de 4 int, que es un tipo diferente e incompatible.
Nota: es posible que algunos compiladores acepten el código anterior, pero el programa fallará durante la ejecución.
Para asegurarnos de haberlo comprendido, veremos un ejemplo más:
int planos[5][4][9];
planos es un arreglo de 5 arreglos de 4 arreglos de 9 int. Cuando escribamos su nombre solo, se convertirá a un puntero a arreglos de 4 arreglos de 9 int.
Paso de arreglos a funciones
La regla de conversión también se cumple con los parámetros de una función. Tenemos tres formas de indicar que una función recibe un arreglo (supongamos que es de 5 elementos):
void func(int a[]);
void func(int a[5]);
void func(int *a);
En la primera declaración, el parámetro a es de tipo arreglo de int. No especificamos el tamaño (lo que se conoce como un tipo incompleto) pero en este caso es válido, ya que de cualquier forma se perderá, dado que el compilador lo convertirá a puntero a int. En la segunda forma, el tipo de a es arreglo de 5 int y también se convierte a puntero a int. La última línea ya es directamente un puntero, por lo que no hay conversión. Podemos ver que las tres formas son equivalentes: sin importar cómo declaremos la función, el compilador la convertirá a la tercera.
Puesto que siempre se perderá el tamaño, es común pasarlo como un segundo parámetro
void func(int *a, size_t tam);
Nota: normalmente declararía el parámetro tam como int, pero en apego a las buenas prácticas, en este post usé size_t (definido en stddef.h, o cstddef en C++), que es el tipo recomendado para las variables que harán referencia a tamaños. En los siguientes ejemplos omitiré el parámetro del tamaño para simplificar el código.
Si lo que queremos es una función que reciba un arreglo bidimensional (digamos, de 5x4), podemos declararla de cualquiera de estas formas:
void func(int a[][4]);
void func(int a[5][4]);
void func(int (*a)[4]);
Igual que en el caso anterior, las dos primeras formas serán convertidas a la tercera (puntero a arreglo de 4 enteros). Es importante el paréntesis, porque sin él estaríamos indicando un arreglo de punteros, que es algo totalmente diferente.
La segunda dimensión no es opcional. Esto no es válido:
void func(int a[][]);
porque el compilador lo convertiría a esto (recordemos, la conversión se efectúa sólo en la primera dimensión):
void func(int (*a)[]);
y aquí sí nos encontraríamos con que a tiene un tipo incompleto, que el compilador no tiene forma de resolver.
Con un arreglo de 3 dimensiones haríamos esto:
void func(int a[][4][9]);
void func(int a[5][4][9]);
void func(int (*a)[4][9]);
y obviamente, sucede lo mismo que en los casos anteriores.
Resumiendo, la regla general para pasar arreglos a funciones es que únicamente la primera dimensión es opcional (y se pierde). Todas las demás son obligatorias.
No está de más decir que las dimensiones que escribamos tienen que coincidir con las reales. No podemos hacer esto:
void func(int a[][4]);
...
int matriz[5][6];
func(matriz);
Nuevamente, es posible que algún compilador no marque error, pero al ejecutar el programa vamos a tener problemas porque, cuando la función quiera acceder a los elementos de la matriz, sus cálculos serán erróneos.
================================================================================
Opcional:
¿"Expresiones" tipo arreglo/puntero?
En la regla de conversión que puse al inicio de este post, se habla de que una expresión de tipo arreglo se convierte a expresión de tipo puntero. Esto es porque la conversión no sólo tiene efecto cuando escribimos el nombre de un arreglo sin índices, sino con cualquier expresión que tenga este tipo. Para entender esto, supongamos que tenemos esta variable:
int matriz[5][4];
es decir, un arreglo de 5 arreglos de 4 int. Si en alguna parte de nuestro código escribimos esto:
matriz[1]
nos estamos refiriendo al elemento 1 de la variable matriz. Los elementos de matriz son de tipo arreglo de 4 int, por lo tanto, matriz[1] es una expresión de tipo arreglo y se convertirá en una de tipo puntero. Esto significa que el siguiente código es válido:
void func(int *a);
...
int matriz[5][4];
func(matriz[1]);
porque la función espera un puntero a int, y le estamos pasando un arreglo de 4 int (específicamente, el segundo de los arreglos que contiene matriz), que es convertido justamente a puntero a int.
================================================================================
Cadenas de arreglos char y punteros a char
Vamos a ver un ejemplo de dos declaraciones de variables que pudieran parecer equivalentes pero no lo son en absoluto:
char arreg[] = "hola mundo";
char *ptr = "hola mundo";
En la primera se reserva espacio para 11 caracteres (los 10 de la cadena de inicialización más el caracter ''), que son inicializados con los valores 'h', 'o', 'l', etc. Como sucede con cualquier arreglo no const, podemos modificar sus elementos sin ningún problema. Por ejemplo, cambiar la 'h' a mayúscula:
arreg[0] = 'H';
En el caso de ptr
char *ptr = "hola mundo";
ocurre algo diferente. Se reserva espacio para un puntero. Luego, el compilador construye la cadena literal "hola mundo" y la coloca en algún lugar de la memoria, y finalmente hace que ptr apunte a ella. El estándar dice que si intentamos modificar el contenido de una cadena literal, el resultado queda indefinido (traducción: jamás intentemos hacerlo), así que es común que, por cuestiones de eficiencia, los compiladores coloquen las cadenas literales en una parte de la memoria marcada como de sólo lectura. Entonces, si intentamos hacer esto:
ptr[0] = 'H';
el compilador lo permitirá, porque ptr no fue declarado como const; sin embargo, al ejecutar la instrucción anterior, muy probablemente se generará un error de violación de segmento (porque estamos tratando de modificar memoria de sólo lectura) y el programa se cerrará.
Algunos compiladores permiten, ya sea de forma predeterminada o cambiando alguna configuración, modificar estas cadenas, pero eso queda fuera de las reglas del lenguaje y no se recomienda. De hecho, GCC era uno de esos compiladores, pero en las versiones recientes ya se eliminó la opción que hacía posible usar cadenas literales modificables.
Por todo lo anterior, no debemos declarar una cadena de la forma en que lo hicimos en este último ejemplo. En su lugar, declararemos el puntero char como constante:
const char *ptr = "hola mundo";
Si intentamos modificar los datos apuntados por ptr, el compilador detectará y marcará el error.
Si lo que queremos es una cadena modificable, necesitamos usar un arreglo char, o bien, un puntero a char inicializado correctamente mediante malloc o new, o, mejor aún, usemos la clase string si estamos programando en C++.
No siempre ocurre la conversión de arreglo a puntero
Al principio se dijo que, aunque casi siempre una expresión tipo arreglo se convierte a puntero, hay excepciones. Las dos más importantes son, cuando al arreglo le aplicamos el operador de dirección &, y sizeof.
Podemos hacer la siguiente prueba, que imprime la cantidad de bytes que ocupan un arreglo y un puntero:
int arreg[20];
int *ptr;
cout << sizeof arreg << endl;
cout << sizeof ptr << endl;
Si el nombre del arreglo fuera un puntero, deberíamos obtener el mismo número, pero no es así, ya que, como se dijo, cuando le aplicamos el operador sizeof no hay conversión.
Veamos el otro caso:
cout << arreg << endl;
cout << &arreg << endl;
Se imprime la misma dirección. En la primera línea, el nombre arreg se convierte a puntero a su primer elemento y se imprime su valor, es decir, la dirección del primer elemento. En la segunda línea no hay conversión, así que le estamos aplicando el operador & al arreglo en sí, por lo que se imprime la dirección del arreglo, que es la misma de su primer elemento.
Si lo intentamos con el puntero
cout << ptr << endl;
cout << &ptr << endl;
obtendremos direcciones distintas. En el primero caso se imprime el valor del puntero (la dirección a la que apunta). En el segundo, mostramos la dirección en la que está almacenado el propio puntero.
================================================================================
Opcional:
Hay que tener cuidado al usar el operador &, como en el caso siguiente.
Al usar la función scanf, hay que pasarle la dirección de las variables en las que queremos que almacene valores. Por eso normalmente usamos el operador &:
int n;
scanf("%d", &n);
Cuando queremos leer una cadena, no es necesario usar este operador:
char cadena[20];
scanf("%s", &cadena);
porque, como ya sabemos, con tan sólo escribir el nombre del arreglo, éste es convertido a puntero. Aquí el & es redundante, pero no pasa nada, ya que le estamos enviando a scanf la dirección de cadena, que es la misma que la de su primer elemento. Si tratamos de hacer lo mismo con un char *
char *cadena = malloc(20);
scanf("%s", &cadena);
el programa va a fallar. Lo que queremos es que scanf lea una cadena y la almacene en el bloque de memoria al que apunta cadena, pero le estamos pasando la dirección del puntero cadena, así que scanf almacenará los datos en el lugar equivocado, probablemente sobrescribiendo otras variables del programa. Para evitar confusiones (y de paso no ser redundantes) es mejor no usar el operador de dirección al leer cadenas o, dicho de forma más general, al pasar cadenas a funciones, pues este problema no es exclusivo de scanf.
A los lectores observadores: sé que el scanf anterior es inseguro porque no especifica máximo de caracteres a leer, pero no compliquemos más este post.
================================================================================
Y con esto llegamos al final del post. Espero que les haya sido de utilidad. No duden en dejar comentarios con cualquier duda o sugerencia que tengan.
Muy pronto: Números flotantes: todo lo que siempre quisiste saber y no te atrevías a preguntar

.
Mis otros post: