Punteros, Referencias y Direcciones

Variables y Memoria

Esto es más un repaso, o introducción para punteros, ya que se supone que ya vieron este tema (variables) en programación I.

Una variable es un elemento de nuestro programa, que nos permite representar un dato y manipularlo. El lenguaje mismo se encarga de asociar el nombre de la variable con el valor. Y además nos dá operaciones sobre esa variable para manipularlo, por ejemplo para cambiar ese valor.

No estamos diciendo nada loco:

int edad = 23;

Acá vemos que la variable tiene:

    • un tipo: que identifica el tipo de dato del valor que contiene.
    • un nombre: que nos permite utilizarla en el código.
  • un valor

Ahora, este valor, debe estar guardado en algún lado, si puedo consultarlo y modificarlo. Esto es, en la memoria. En la página Tipos de Memoria de un Programa describimos los diferentes lugares donde se guardan las cosas.

Entonces, vamos a tratar de pensar a más bajo nivel, cómo el lenguaje manipula la memoria. Veamos un ejemplo.

#include <stdio.h>
#include <stdlib.h>
float aplicarDescuento(float costo, float descuento);
int main() {
    float precio = 2.35;
    int cantidad = 3;
    float descuento = 0.2;
    float resultado = aplicarDescuento(precio * cantidad, descuento);
    printf("Total: %f", resultado);
    return 0;
}
float aplicarDescuento(float costo, float descuento) {
    float resultado;
    resultado = costo * (1 - descuento);
    return resultado;
}

Este pedacito de código calcula el costo total de la compra de una cantidad de elementos, que tienen un precio (unitario), aplicando un descuento que se expresa como un porcentaje.

Lo primero que vemos es que se declaran las variables para: cantidad, precio y descuento.

Precio y descuento son de tipo float (es decir que ocupan 4 bytes -ver abajo la tabla de tipos), mientras que cantidad es un int, que requiere también 4 bytes.

Cuando declaramos las variables (incluso sin asignarles un valor inicial), ya se reserva en memoria un slot o espacio para guardar su valor, de acuerdo al tipo.

Acá vemos, con este diagrama, que la variable actúa como una referencia a la posición en memoria que contiene al valor. Así, al modificar el valor de una variable (por ejemplo con otra lina "precio = 3.25" estaríamos cambiando el contenido de la memoria por un nuevo valor, mas la posición en memoria, y por lo tanto la referencia/variable, no se alterarían. Claro, porque el nuevo valor, es del mismo tipo, por lo tanto "ocupa" lo mismo que el valor original.

Más adelante veremos cómo resolver problemas en los cuales, lo que quiero modificar ocupa diferente tamaño en memoria. De eso justamente se trata la programación de memoria dinámica.

pSiguiend con nuestro ejemplo, luego, se invoca a la función aplicarDescuento. Al entrar a la función, se crea un nuevo contexto en el stack (es decir en la pila). Este contexto va a contener las variables locales a la función. Esto incluye a los parámetros.

Inicialmente resultado no contiene ningún valor. Pero dentro del cuerpo de la función se calcula, en nuestro caso a 5.64

Ahora qué sucede al retornar teniendo en cuenta que la linea es "return resultado" ?

Realmente se retorna esa misma referencia ?

La respuesta es no. Porque como dijimos, al retornar de una función se "desapila" el contexto, y así se pierde complétamente todo el espacio de memoria de ese contexto. En nuestro caso perdemos "costo, descuento y resultado". Pero, en el camino de retorno, el lenguaje se ocupó de retornar el valor "5.64". Y como en nuestro main declaramos otra variable descuento y la asignamos al valor de retorno de la función, el resultado es que a esa nueva variable (a esa nueva dirección de memoria, se le guarda el valor 5.64)

Pasaje de parámetros

Acabamos de ver que al retornar de una función, lo que sucede es que se hace una copia del valor. Es decir que no se devuelve la misma referencia o posición a memoria, ya que todo el contexto que contiene todo lo que se reservó dentro de la función, se descarta.

Esto mismo es lo que sucede con los parámetros de entrada a una función (lo de la copia)

float costo = 2.5;
float descuento = 0.15;
float resultado = aplicarDescuento(costo, descuento);

Al momento de ingresar a la función, se crea un nuevo contexto reservando memoria con referencias propias a los parámetros (más allá de que en este caso tengan el mismo nombre que las variables del main, no tienen nada que ver). Además de reservar los slots, se copiaron los valores (linea punteada).

Acá vemos que ya los valores pasan a ser independientes. Si dentro de la función se altera el valor de "costo", estaríamos alterando el valor de esa posición de memoria, y no de la que está apuntada por la variable costo que está "fuera".

A este tipo de pasaje se le denomina pasaje de parámetros por valor. Más adelante veremos un "truco" de C que emula otro tipo de pasaje.

Longitudes de los tipos de datos

En la siguiente tabla resumimos los tipos de datos y el tamaño que ocupan en memoria

Ya veremos a continuación qué es un puntero.

Punteros

Hasta ahora repasamos y trabajamos con variables de tipos básicos. Vimos que todos los pasajes de parámetros en C se hacen por valor, es decir que se hace una copia del valor hacia una nueva posición de memoria.

C provee transparencia en el uso y manipulación de la memoria. Y esto se utiliza para resolver ciertos casos o problemas que veremos más adelante.

Para esto podemos ver que:

    • Toda variable tiene una dirección de memoria la cual podemos obtener programáticamente (es decir desde el mismo programa)
    • Las direcciones de memoria se pueden almacenar a su vez en variables
    • Data una dirección de memoria, podemos acceder a su contenido o modificarlo

Las variables que contienen direcciones a memoria, se denominan de tipo puntero.

Veamos un ejemplo:

    int precio = 23;
    printf("Precio: %i\n", precio);
    int *punteroAPrecio = &precio;
    printf("Puntero a precio: %i", punteroAPrecio);

Esto genera:

Precio: 23
Puntero a precio: 1295794828

En este ejemplo:

    • precio es una variable de tipo int normal. Nada nuevo hasta ahí
    • punteroAPrecio: es una variable de tipo puntero que inicializamos con el valor de la dirección de memoria de la variable "precio". Es decir que hicimos que punteroAPrecio apunte al mismo "23" que precio.

Y así aparecen dos cosas nuevas...

Declarando una variable de tipo puntero

Se declara como una variable normal, solo que se precede su nombre con un asterisco. En este caso no tiene un tipo especial como "pointer" o algo así, sino que se deduce que es un puntero por el asterisco. Y en la declaración, el tipo, se sigue utilizando, y representa al tipo del valor al que apunta.

Ejemplos:

    • int * : es un puntero a una dirección de memoria alocada para contener un entero
    • float * : idem pero para un float
    • int * variable[]: es un arreglo de punteros a enteros

Obtener la dirección de una variable (&)

El operador & precede a una variable y nos permite obtener la dirección de memoria a la que apunta.

Así es que en nuestro ejemplo (de más arriba) creamos un puntero que apunta la misma posición de memoria que la variable antes declarada.

Acceder al valor referenciado (*)

Continuando con el ejemplo, vimos que al imprimir la variable del puntero, no veíamos el valor "23" sino un número raro. Ese número es la dirección de memoria, es decir la referencia.

Para acceder al valor apuntado por el puntero, utilizamos el asterisco nuevamente.

    int precio = 23;
    printf("Precio: %i\n", precio);
    int *punteroAPrecio = &precio;
    printf("Puntero a precio: %i\n", punteroAPrecio);
    printf("Puntero a precio (valor): %i\n", *punteroAPrecio);

Y ahora:

Precio: 23
Puntero a precio: 1807452956
Puntero a precio (valor): 23

Y si agregamos:

    precio = 55;
    printf("Puntero a precio (valor): %i\n", *punteroAPrecio);

Eso da:

Precio: 23
Puntero a precio: -1192081428
Puntero a precio (valor): 23
Puntero a precio (valor): 55

Diferencia entre variable y Puntero

El siguiente diagrama demuestra la diferencia entre una variable y un puntero.

Como vemos, el puntero es básicamente una forma de referencia "indirecta". Sigue teniendo una variable que apunta a una dirección de memoria (podríamos acceder a la dirección de memoria del puntero a su vez con &punteroAPrecio), solo que allí no está el valor final, sino que contiene una dirección de memoria que apunta al valor real.

Es por esto que todos los punteros "ocupan" lo mismo en memoria, 4 bytes. Que es lo necesario para guardar una referencia a memoria (independientemente del tipo de dato real)

Casos de uso comunes de punteros

Vamos a ver aquí algunos casos comunes donde se utilizan punteros:

Función que altera el valor de uno o varios de sus parámetros (Pasaje por referencia)

Como vimos más arriba el pasaje de parámetros en C es siempre por valor. Es decir que se hace una copia del valor a una nueva dirección de memoria del contexto de la función, por lo que si éste cambia, al terminar la ejecución de la función los cambios no se aplican a las variables originales.

Entonces, cómo resolver el caso en el cual queremos que la función altere el valor de uno o varios de las variables que pasamos por parámetro ?

Para esto se simula el pasaje por referencia utilizando punteros. En lugar de pasar la variable como parámetro, pasamos su dirección de memoria. Con ésta, la función podrá alterar el contenido/valor.

Veamos un ejemplo:

int main() {
    int a = 2;
    int b = 4;
    printf("a = %i & b = %i\n", a, b);
    swap(&a, &b);
    printf("a = %i & b = %i", a, b);
    return 0;
}

Con dos variables int, invocamos a la función swap cuyo objetivo es que intercambie los valores de las variables. Como vimos esto no se puede hacer normalmente pasando las variables a y b. Por eso la magia está en el llamado, utilizando el operador &

Y la función swap es:

void swap(int *x, int *y) {
    int yOriginal = *y;
    *y = *x;
    *x = yOriginal;
}

Otro ejemplo clásico de uso es el método scanf. Seguramente ya la hayan utilizado, incluso antes de saber qué eran los punteros. Para leer un valor por consola, utilizamos esta función, que en lugar de retornar el valor leído, espera que le pasemos una dirección de memoria donde va a guardar el valor leído.

int main(void) {
    int cantidadSaludos = 0;
    printf("Cuantos saludos querés?");
    scanf("%i", &cantidadSaludos);
    printf("!Hola Mundo! x %i\n", cantidadSaludos);
    return 0;
}

Nota:

En general, salvo para funciones puntuales como estas, otra forma mas elegante de resolver estos casos es definiendo un tipo abstracto de dato (Ver "Unidad 2 - Tipo de Dato Abstracto"). Es decir encontrar una abstracción que agrupe a los parámetros que se quieren alterar. Así, por ejemplo si tenemos una función que debe leer por parámetro los datos de un producto como código, descripción, precio, en lugar de tener variables sueltas y tener que pasar sus direcciones para ser alterada, tendríamos una única variable de tipo Producto que agruparía estas propiedades. Y estas propiedades podrán ser modificadas por la función.

//TODO apuntar a ejemplo

Función con más de un valor de retorno

A veces estamos tentados a implementar una función que retorne más de un elemento. En general, la forma de implementar esto dependerá del lenguaje. Ya veremos en la "Unidad 2 - Tipo de Dato Abstracto", que existe una forma más elegante de resolver esto (donde elegante se entiende por encontrar una abstracción que haga más claro el código). Pero también uno podría pensar en resolver esto mediante punteros, como un caso particular del ejemplo anterior, donde en lugar de que la función me "devuelva" las cosas, yo al invocarla le paso punteros, y ella modifica sus contenidos.

El uso de punteros, de nuevo, es algo particular de C. Aunque el concepto de que la función altere el estado del programa es algo general, y hasta transversal al paradigma, llamado efecto de lado (o efecto colateral).

Para más información acerca de efecto de lado y transparencia referencial pueden ver acá.

Punteros vs Vectores (Arrays)

Cuando veamos un poco más de memoria dinámica en C, veremos que los vectores y punteros tiene un tratamiento similar a nivel de lenguaje.

Ver acá