Arreglos dinámicos de variables, y regreso de funciones por referencia
Introducción
¡Hola de nuevo!
La clase anterior seguramente ha sido algo desconcertante, esto de entender los apuntadores es un tanto confuso, miren que acceder a los datos que se guardan en la memoria a través de la dirección donde están guardados suena más al trabajo de un cartero que entrega cartas a domicilio que a un programador en C. Pero no desesperen, en esta clase veremos su GRAN utilidad y seguramente coincidirán en que los apuntadores son una gran idea.
Seguramente nos hemos hecho la pregunta ¿para qué sirven los apuntadores? En esta clase estudiaremos dos de las grandes aplicaciones prácticas del uso de apuntadores:
- Generación de arreglos dinámicos
- Regreso de información entre funciones.
Te recomendamos mucho repasar las clases 11 (Arreglos estáticos) y 12 (funciones) para comprender a profundidad estas aplicaciones
Pues bueno, ¡Manos a la obra!
Desarrollo del tema
Comencemos recordando que es un apuntador: Un apuntador es una variable que tiene como intención almacenar una dirección en memoria. Como su objetivo fundamental es guardar direcciones, el tamaño de una variable apuntadora será determinado por la capacidad de la computadora para almacenar información en la memoria dinámica, hoy en día la arquitectura de las computadoras está determinada por 64 bits, esto es 8 bytes, así pues, TODA variable apuntadora tendrá un tamaño de 8 bytes.
Pero también recordemos que existen distintos tipos de variables apuntadoras (todas ellas de 8 bytes) las hay apuntadoras a datos double, float, int, etc. Un tipo importante es el apuntador a void, que significa que esa variable apuntará a datos sin un tipo en particular. No olvidemos que el tipo de apuntador para lo que nos sirve es para poder realizar operaciones aritméticas entre apuntadores.
Pongamos un ejemplo, supongamos que hemos declarado la variable PtrChar del tipo char:
char *PtrChar;
PtrChar=0x100;
Y también hemos declarado otra variable PtrFloat del tipo float:
float *PtrFloat;
PtrFloat=0x200;
Ambas variables tienen un tamaño de 8 bytes pero como cada apuntadora sabe a qué tipo de dato está apuntando y las hemos hecho apuntar a una dirección en particular, vea lo que pasa con las líneas de código :
PtrChar++;
PtrFloat++;
Ahora la variable PtrChar tiene almacenada la dirección 0x101, y la variable PtrFloat almacenará la dirección 0x204, ¡ven! El apuntador a char da un salto al siguiente char y PtrFloat también da un salto al siguiente float, pero ese float se encuentra 4 posiciones alejado en vez de una posición que se encuentra alejado el dato char. Así pues, el tipo de apuntador solo es útil para realizar la aritmética apropiada.
Pues bien, con lo anterior en mente estudiemos a la función malloc, que se encuentra localizada en la biblioteca stdlib, así que nunca olviden incluir el encabezado stdlib.h en su código. Veamos la declaración de la función malloc:
void* malloc ( unsigned numero_de_bytes );
La función malloc tiene como objetivo localizar memoria libre en la memoria RAM de la computadora. El argumento de la función es el número de bytes consecutivos que se solicitan apartar, la función regresa por la izquierda un apuntador a la dirección del primer byte. Malloc negocia con el sistema operativo el apartado de ese bloque de memoria, una vez asignado, el sistema operativo identifica esa sección de la memoria como “ocupada” y no la asignará a ningún otro usuario o solicitud hasta que se libere, de alguna manera el programa en donde se ejecuta la función malloc, se vuelve “dueño” de esa parte de la memoria y nadie más tiene permiso de leer o escribir en ella. Si otro proceso intenta acceder a esa sección de la memoria, aparecerá el famoso error de ejecución Segmentation Fault.
Como habrán visto malloc nos permite, en el momento de ejecución apartar memoria, ¿pero ¿cómo se comporta malloc si lo que pido no puede ser apartado? Pues muy sencillo, la función regresa una dirección inválida etiquetada con la constante NULL (dicha definición se encuentra dentro del encabezado stdlib.h).
Así pues, cada vez que pedimos memoria es MUY buena idea verificar si nos la dieron, si el apuntador regresado es distinto a NULL querrá decir que el sistema operativo si pudo localizar memoria y se nos asignó, pero si el regreso es NULL querrá decir que la petición falló.
Si la petición fue aceptada y se nos apartó memoria nunca olvidemos que nuestra responsabilidad como programadores es liberar esa memoria al desocuparla, para desocupar memoria usamos la función free:
void free (void* ptr);
El argumento de entrada de la fusión es el apuntador que apunta al bloque de memoria que se quiere liberar.
Estudiemos con detenimiento la línea 20:
PtrArreglo=(double *)malloc(sizeof(double)*N);
En el argumento de entrada de la función malloc tenemos la función sizeof que, recordemos, nos regresa el tamaño en bytes de un tipo de dato o de una variable, este sizeof seguramente regresará un 8 (las variables de tipo double usan 8 bytes), es mucho mejor usar la función que poner directamente el número 8 porque esta función responde le valor específicamente para el tipo de computadora en que se está corriendo el programa. Muy bien, si sabemos que tenemos 8 bytes por datos y pedimos N datos, el número de bytes será el producto.
Hay otra anotación importante que hacer en esta línea, el cast que realiza (double *), recordemos que malloc regresa un apuntador de tipo genérico void, este cast indica que la variable que almacenará la posición de memoria es de tipo double. Este cast es el responsable de poder usar el apuntador tan fácilmente como se ve en la línea 27, no poner este cast haría fallar el programa.
Tanto en la línea 27 como en la línea 31, observen que el apuntador PtrArreglo se usa como usamos los arreglos estáticos, maravilloso, ¿no?
La línea 33 es muy importante ejecutarla al terminar de usar la memoria, si no se realiza el proceso terminará y no le avisará al sistema operativo que la memoria ya la puede etiquetar como disponible, de quedar ocupada y muerto el proceso, solo el colector de basura del sistema operativo será capaz de identificar esta sección de memoria abandonada y recuperarla, algo así como que uno tirará basura en la calle y el pobre barrendero tuviera que levantarla y ponerla en su lugar. ¡Pues ya aprendimos a usar memoria dinámica! Bueno, al menos para arreglos de una dimensión, como se usarían los apuntadores para formar arreglos bidimensionales (un arreglo matricial, por ejemplo).
Existen varias alternativas, estudiaremos la que se nombra apuntador a un apuntador, para consultar distintas formas de construir este arreglo puedes consultar:
Comencemos discutiendo un par de temas teóricos, ¿Qué significa una declaración como la siguiente?:
double **Array;
esto quiere decir que declaramos la variable Array como un apuntador que apunta a otro apuntador (usualmente se le llama un doble apuntador), pues bueno, recordando que todo arreglo que no tiene índice es un apuntador podemos entender a la variable Array como un arreglo de apuntadores, entonces, si queremos medir memoria para una arreglo 2Dimensional de M renglones y N columnas, lo que hacemos es que primero generamos un arreglo de M apuntadores, una vez teniendo este arreglo, para cada valor de este arreglo se asigna la memoria solicitada para un arreglo de N datos, veamos:
Estudiemos con detalle la línea 23:
Contrastemos la línea con la equivalente del caso de arreglos de 1D:
Noten que dentro del sizeof ahora está un double * en vez de solo el double, esto se debe a que queremos localizar apuntadores a double, recuerden que su tamaño es de 8 bytes, casualmente coincide con los 8 bytes necesarios para almacenar un dato double, pero otra cosa sería si los datos fueran float, por ejemplo. La segunda diferencia importante está en el cast que cambia el apuntador de tipo void ahora a un doble apuntador de tipo double, antes el cast se realizaba a un apuntador sencillo tipo double.
La línea lo que consigue es realizar un arreglo (apuntador) de apuntadores a double.
Las líneas 30 a la 34 lo que estamos haciendo es estar llenando de arreglos de double (apuntadores) a cada elemento del arreglo de arreglos, podemos imaginar que cada renglón del arreglo matricial es un arreglo vectorial 1D.
La línea 39 demuestra que, una vez creado el arreglo, su uso es idéntico al de un arreglo estático:
En las líneas de código 49 y 50 se programa el proceso de limpieza de memoria liberándola en cada renglón y finalmente en la línea 52 se libera el segundo apuntador a arreglos, terminando con la liberación de cada bloque de memoria.
¿Qué tal? ¡Ven que los apuntadores son realmente útiles! Ahora abordaremos la necesidad de usar apuntadores en el argumento de funciones, pero para entender este tema debemos estar seguros de comprender como se comportan las variables dentro de funciones.
Volvamos a recordar el concepto de alcance de una variable, el alcance de una variable se define como la sección del código donde una variable es reconocida. La regla de alcance dice que una variable es visible (o alcanzable) a partir de su declaración y hasta el momento en que se localiza la llave cerrada }.
Con toda la información anterior hagamos un pequeño ejercicio, escribamos un programa que pregunte cuantos datos del tipo double queremos almacenar, los aparte, los manipule y luego los libere. Analicemos nuevamente el programa de la lección 12 (funciones):
Ejemplo de función con argumentos con retorno de valor
Las variables base, exponente, resultado tienen una vida que nace a partir de la línea 8 y son liberadas de memoria hasta la línea 16, cuando el flujo del programa entra a la línea 14, sucede algo muy interesante, el flujo salta a la línea 17 donde se declaran las variables x y y, a partir de esta línea y hasta terminar la función estas dos variables tendrán su alcance. Pero concentrémonos en el brinco de la línea 14 a la 17, ¿Cómo le hacemos para que valores de una variable de la función principal pasen a variables de la función invocada? Pues C lo que hace es un cruce por valor, esto es, el valor guardado en la variable base se copia a la variable x y el valor guardado en la variable exponente se copia a la variable y, en ese momento la variable exponente y base se esconden (pierden visibilidad) y solo podemos acceder a valores de las variables x y y. En la línea 19 se declara la variable restpow que será visible hasta la línea 22 donde termina la función y desaparecen las variables x, y, restpow perdiendo todo valor guardado en ellas. De la línea 22 el flujo del programa regresa a la línea 14, la variable resultado recibe el valor enviado en la línea 21 a través de restpow. Finalmente, al llegar a la línea 16 las variables base, exponente, resultado se liberan y los datos guardados en ellas se pierden.
Noten, el flujo de información del programa principal hacia la función potencia es fácil, basta con poner dentro del paréntesis del encabezado de la función una variable que capture valores, pero el flujo de regreso solo puede darse por un lugar, el valor de retorno, la palabra reservada return solo permite regresar el valor de UNA variable, si quisiéramos regresar 2 o más variables no podemos hacerlo por el return. Ufff ahora si estamos en problemas.
Bien, con todo esto en mente aparece una pregunta importante ¿Cómo podríamos diseñar una función que sea capaz de regresar más de un valor?, imaginemos que desarrollamos una función que encuentra el valor mínimo y el valor máximo de una lista de números y queremos que la función regrese ambos valores no solo uno ¿Qué hacemos?
¡Usamos apuntadores! Van a ver el truco tan inteligente que podemos realizar, resumamos la situación, en C todos los intercambios de información entre funciones se realizan mediante la copia de valores, podemos llevar a la función cualquier tipo de valor incluida la dirección donde se localiza una variable que fue declarada en la función principal. El chiste es que la función llamada capture el valor de la dirección donde está localizada la variable y luego, con el operador indirección cambiemos el contenido de esa variable, ¡y voila! El truco de magia se ha realizado, hemos copiado el valor calculado dentro de una función y lo hemos guardado indirectamente en una variable que no teníamos acceso ¿No les parce brillante la idea? Escribamos el código.
Estudiemos el comportamiento del programa anterior, verán cómo operan los apuntadores, y espero que queden sorprendidos con la astucia de quienes lo usan.
La línea 18, tal como lo vimos en esta clase, solicita memoria dinámica para generar un arreglo de 1D con N datos del tipo double. La línea 20 es una condicional que verifica si la memoria se pudo conseguir, recuerden, si no se puede conseguir el resultado será un apuntador a Nulo (NULL). El ciclo for de las líneas 22 a 26 solamente llena con valores aleatorios los N datos del arreglo, aquí vale la pena detenerse en la línea 24, como verán el apuntador llamado Arreglo se puede usar con los corchetes de la misma manera en que se usan los arreglos estáticos; pero lo interesante es el lado derecho del igual, la función rand() regresa un número entero pseudoaleatorio que puede tomar valores entre 0 y RAND_MAX, RAND_MAX es una constante que está definida en stdlib.h, para poder convertir este número aleatorio entre 0 y 1 (double) lo que hacemos es un cast del resultado de la función y luego una división por RAND_MAX, como el número con rango mayor es el numerador que es double, la división se hace entre dobles (hay un cast implícito) y el resultado es un número del tipo double.
La línea 28 tiene una parte MUY interesante, es la llamada a la función MaxMin, mejor, antes de estudiar la llamada a la función analicemos la función misma, en la línea 41 tenemos el encabezado, vean que se declaran las variables Arreglo que es un apuntador a double, la variable N que es del tipo int, y las variables Max y Min, que ambas son del tipo apuntador a double; aunque Arreglo, Max y Min son variables del mismo tipo las usaremos con intenciones diferentes; Arreglo lo que recibirá es la dirección de memoria donde se localiza el arreglo de datos, aquí no hay sorpresa, pero Max recibirá la dirección de memoria de la variable donde vamos a guardar el valor más grande que encontremos en el arreglo, y Min tendrá la dirección de memoria donde almacenaremos el valor más pequeño encontrado, ¿ven la diferencia? Mientras que en Arreglo vamos a consultar datos, en Max y en Min vamos a modificar indirectamente los valores de las variables a las que apuntan. De la línea 47 a la 57 tenemos un clásico algoritmo de búsqueda de máximos y mínimos, repasemos, Ma es la variable donde se guardará el valor máximo y Mi el mínimo, asignamos el primer valor del arreglo a Ma y a Mi (esto lo hacemos con el siguiente razonamiento, si el arreglo solo tuviera un valor, ese valor sería el máximo y el mínimo), pero como no solo tenemos un valor sino N, entonces comenzamos la búsqueda a partir del segundo valor, si Arreglo[i] vemos que es más grande que Ma, hemos encontrado un nuevo máximo y se lo asignamos a Ma, igualmente si Arreglo[i] es más pequeño que Mi, habremos encontrado un nuevo mínimo y se lo asignamos a Mi, al llegar al final de la lista de datos podemos asegurar que Ma tendrá el máximo y Mi tendrá el mínimo ¡Pero atención en las líneas 62 y 63! Aquí es donde se opera la magia, vean, lo que decimos en la línea 62 es que el valor máximo Guardado en Ma lo guardamos indirectamente en el ligar a donde apunta el apuntador Max (esto lo hacemos usando el operador de asignación indirecta *, revisen la clase 13 de apuntadores si tienen dudas), lo mismo pasa en la línea 63 pero para Mi. ¡Ven, indirectamente pusimos el valor encontrado en la variable que tiene alcance solo en la función main, hicimos un poco de trampa, más bien fuimos astutos, no podíamos tener acceso a las variables Máximo y Mínimo de la función principal porque estamos fuera de su campo de acción, pero a cambio declaramos las variables Max y Min, que son apuntadoras, por lo que pueden guardar una dirección y le pedimos que guarden la dirección de las variables Máximo y Mínimo, e indirectamente cambiamos el valor de estas variables guardando lo que queríamos, ¡brillante!, ¿no? Finalmente, todas las variables locales a la función desaparecen, pero el cambio indirecto a Máximo y Mínimo de la función principal ya está hecho y ese no desaparece.
Ahora si regresemos a la línea 28, veamos, recuerden, cuando en C se invoca a una función el cruce de información se hace mediante la copia de valores, así que Arreglo de main copia su valor (dirección donde comienza el arreglo de datos) a la variable Arreglo de la función, el valor de N de la función principal se copia a la variable N de la función, pero aquí viene lo bueno, ¡atención! Lo que se copia a la variable Max de la función no es el valor de Máximo sino la dirección de Máximo (recuerden que el operador & es el operador de dirección y lo que hace es responder la dirección donde vive la variable) así que Max recibe la dirección de Máximo (no su valor), lo mismo pasa con Min y Mínimo
¿Lo entendieron? Una jugada realmente magistral, resumamos, como en la función MaxMin NO, repito NO, se puede cambiar los valores de las variables de la función main, lo que hacemos es pasar la dirección de las variables que queremos modificar, y luego utilizando el operador de indirección (*) modificamos el valor de esas variables a las que no teníamos acceso.
YA solo don comentarios más, no olvidemos lo importante de la línea 30, al terminar de usar la memoria dinámica que solicitamos, la liberamos. El segundo comentario es que vean que en la línea 31 y 36 asignamos el valor de 0 o de -1 a la variable estado y en la línea 38 regresamos ese valor. Es una convención que el valor que regresa la función main es un 0 si la ejecución del programa se logró sin falla y algún valor negativo si hubo alguna falla, vean que la función main regresará un 0 si se logró asignar memoria dinámica y regresará un -1 si la asignación falló. En clases anteriores siempre regresábamos un 0, ahora decidimos que regresar.
Vean, con el truco de los apuntadores pudimos hacer una llamada a una función que, para fines prácticos, no se intercambió el valor de variables sino la referencia de la dirección donde están localizadas variables A este tipo de llamada se le conoce por llamada por referencia (estrictamente es un cruce por valor, pero el valor intercambiado es la dirección de una variable a la que modificaremos su contenido indirectamente).
Un documento que recomendamos ampliamente consultar es el siguiente:
En él podrán repasar el tema de apuntadores y sus usos en programación, para aquellos que quieran profundizar en este tema tan interesante, no se la pierdan.
Conclusión
Esta clase es una de las más retadoras del curso, se analizaron 2 aplicaciones típicas de los apuntadores;
- Manejo de memoria dinámica de arreglos en una y en varis dimensiones
- El cruce de información por referencia entre funciones
Estos dos ejemplos de uso de apuntadores son los más usados en C, por favor, no desesperes si tienes que repetir varias veces el estudio de esta clase, eso sí, si logras comprender lo involucrado en esta clase te aseguramos que tu nivel de programación es bueno.
Fuentes de información
- C Programming Language, Brian W. Kernighan Dennis M. Ritchie. Prentice Hall; 2 ed. https://pdos.csail.mit.edu/6.828/2017/readings/pointers.pdf
- Programación C/punteros https://en.wikiversity.org/wiki/C_Programming/Pointers
- Programación C/Punteros y matrices https://en.wikibooks.org/wiki/C_Programming/Pointers_and_arrays