Este es un concepto clave para programar y al mismo tiempo uno de los que más le cuesta entender a los principiantes, así que vamos a intentar explicarlo lo mejor posible a continuación.
Antes de empezar, necesitamos comprender dos conceptos importantes que paso a resumir de manera sencilla:
- La pila o “stack”: es una zona de memoria reservada para almacenar información de uso inmediato por parte del hilo de ejecución actual del programa. Por ejemplo, cuando se llama a una función se reserva un bloque en la parte superior de esta zona de memoria (de la pila) para almacenar los parámetros y demás variables de ámbito local. Cuando se llama a la siguiente función este espacio se “libera” (en el sentido de que ya no queda reservado) y puede ser utilizado por la nueva función. Es por esto que si hacemos demasiadas llamadas anidadas a funciones (en recursión, por ejemplo) podemos llegar a quedarnos sin espacio en la pila, obteniendo un “stack overflow”.
- El montón o “heap”: es una zona de memoria reservada para poder asignarla de manera dinámica. Al contrario que en la pila no existen “normas” para poder asignar o desasignar información en el montón, pudiendo almacenar y eliminar datos en cualquier momento, lo cual hace más complicada la gestión de la memoria en esta ubicación.
Los tipos de datos llamados “por valor” son tipos sencillos que almacenan un dato concreto y que se almacenan en la pila. Por ejemplo, los tipos primitivos de .NET como int o bool, las estructuras o las enumeraciones. Se almacenan en la pila y se copian por completo cuando se asignan a una función. Por eso cuando se pasa un tipo primitivo a una función, aunque lo cambiemos dentro de ésta, el cambio no se ve reflejado fuera de la misma.
Los tipos “por referencia” son todos los demás, y en concreto todas las clases de objetos en .NET, así como algunos tipos primitivos que no tienen un tamaño determinado (como las cadenas). Estos tipos de datos se alojan siempre en el montón, por lo que la gestión de la memoria que ocupan es más compleja, y el uso de los datos es menos eficiente (y de menor rendimiento) que con los tipos por valor.
Bien. Ahora que ya conocemos de manera superficial estos conceptos, veamos qué pasa con los tipos por valor y por referencia al usarlos en un programa escrito con C#.
Si pasamos un tipo por valor a una función y lo modificamos dentro de ésta, el valor original permanecerá inalterado al terminar la llamada.
Por ejemplo:
public int Suma2(int n) {
n = n+2;
return n;
}
int i = 5;
Console.WriteLine(Suma2(i));
Console.WriteLine(i);
Console.ReadLine();
En este caso definimos una función que simplemente le suma 2 al parámetro que se le pase (de una forma poco eficiente, eso sí) transformando el valor que se le pasa. Se podría pensar que ya que la función cambia el valor que le hemos pasado, cuando mostremos por pantalla posteriormente el valor de i, éste debería ser 7. Sin embargo vemos que, aunque se ha cambiado dentro de la función, sigue siendo 5:
Esto es debido a que los números enteros son tipos por valor y por lo tanto se pasa una copia de los mismos a la pila de la función que se llama, no viéndose afectado el valor original.
Sin embargo, si lo que le pasamos a la función es un objeto (por lo tanto, un tipo por referencia), veremos que sí podemos modificar los datos originales.
Por ejemplo, si ahora definimos este código:
public class Persona
{
public string Nombre;
public string Apellidos;
public int Edad;
}
public static void CambiaNombre(Persona per) {
per.Nombre = per.Nombre + " CAMBIADO";
}
Persona p = new Persona();
p.Nombre = "Antonio";
p.Apellidos = "López";
p.Edad = 27;
CambiaNombre(p);
Console.WriteLine(p.Nombre);
Console.ReadLine();
Lo que hacemos en el fragmento anterior es definir una clase persona muy sencilla y pasársela a una función que modifica su nombre. Cuando ejecutemos el código se verá por pantalla el nombre de la persona cambiado:
Es decir, los cambios son apreciables en el origen, fuera de la propia función.
¿A qué es debido esto?
El motivo es que, como hemos dicho, los tipos por referencia se almacenan siempre en el montón, y lo que se pasa a la función como parámetro no es una copia del dato, como en el caso de los tipos por valor, sino una copia de la referencia al dato.
Esto que suena muy lioso es fácil de entender si lo visualizamos.
Vamos a ver en un gráfico la situación de la pila y el montón en el caso del primer código, donde solo manejábamos un tipo por valor:
Como podemos observar, al definir la variable i ésta se almacena en la pila directamente ya que es un tipo por valor. Al llamar a la función Suma2 pasándole i como argumento, lo que ocurre es que el valor de la variable i se copia a la pila y se asigna al parámetro n de la función (repasa el código anterior). De esta forma tenemos dos copias del dato en la pila, por eso al modificar n no se afecta para nada al valor original de i. En el montón, por cierto, no tenemos nada almacenado.
Consideremos ahora el código del segundo ejemplo y veamos qué se genera en la pila y en el montón en este caso:
Ahora la cosa cambia bastante. Al declarar una variable de tipo Persona se genera una instancia de la misma en el montón, ya que es un tipo por referencia. Al asignar los valores de las diferentes propiedades, esos datos se almacenan en el montón asociadas a la instancia de la clase Persona que acabamos de crear. Lo que realmente almacena la variable p es una referencia a los datos de esa persona que están en el montón.
Es decir, ahora en la pila no se almacenan los datos en sí, sino tan solo un “puntero” a los mismos. Es una forma indirecta de referirse a ellos, no como antes en los tipos por valor que el dato se almacenaba directamente en la pila.
Al llamar a la función CambiaNombre, en la variable local per que constituye el parámetro de la función, lo que se duplica ahora en la pila es la referencia al objeto, no el objeto en sí. Es decir, al hacer la llamada disponemos de dos variables que apuntan al mismo tiempo a los datos almacenados en el montón. Por eso cuando cambiamos una propiedad del objeto, al estar ambas referencias apuntando a los mismos datos, los cambios se ven desde los dos lados.
En realidad el comportamiento de la pila es idéntico en ambos casos, lo que cambia es la información que se almacena, que es el propio dato en el caso de los tipos por valor, y la referencia al dato en el caso de los tipos por referencia.
Este modo de funcionamiento es la diferencia fundamental que existe entre los tipos por valor y los tipos por referencia, y es muy importante tenerla clara pues tiene muchas implicaciones a la hora de escribir código.
¡Espero que te ayude a entenderlo mejor!
Fecha de publicación: