En este post vamos a ver la diferencia entre tipos por valor y tipos por referencia, además mostraremos como C# trabaja con ambos tipos de datos y las opciones que tenemos.
Índice
1 - Diferencia entre tipos por valor y tipos por referencia
Para determinar cuándo debemos crear o utilizar un struct
o una clase
o incluso un record
debemos saber cuales son sus características, limitaciones y cómo funcionan en términos de memoria.
1.1 - Tipos por valor
Cuando utilizamos tipos por valor en C# nos estamos refiriendo a los structs
.
Pero qué quiere decir tipo por valor?
Quiere decir que cuando utilizamos ese objeto, estamos utilizando el objeto como tal, leyendo y escribiendo en el stack
(veremos lo que es el stack más adelante) y cuando escribimos un objeto en el stack
lo hacemos de forma completa, no un puntero como hacemos en los tipos por referencia.
El caso más común de un struct es cuando representa un único valor, como puede ser un tipo primitivo (double
, int
, decimal
, etc). Además, este debe ser menor de 16kb.
Cuando utilizamos la operación de asignación (=
) a un struct lo que estamos haciendo es una copia del valor en una nueva variable, no asignamos la referencia del mismo. Por lo que, si modificamos uno de los objetos el valor del otro no cambia:
int ejemplo1 = 15; // asignamos un valor inicial
int ejemplo2 = ejemplo1; // asignamos ejempo2 con el valor de ejemplo1
ejemplo2 = 10; // modificamos el valor de ejemplo 2
Console.WriteLine(ejemplo1); // imprime 15;
Console.WriteLine(ejemplo2) // imprime 10;
Esto es importante ya que en los tipos por referencia no funciona de la misma manera.
1.2 - Tipos por referencia
Cuando utilizamos tipos por referencia en C# nos estamos refiriendo a las clases, osea class
y dese C# 9 también a los records
. Y todas las instancias de las clases están ubicadas en el heap
.
Finalmente la variable en sí es un puntero a ese objeto en el heap
, no el objeto como tal.
¿Qué quiere decir que las clases son punteros a memoria?
Imagínate que tenemos una clase llamada vehículo que contiene propiedades como la marca, el modelo y el número de puertas.
Cuando hacemos una asignación como la siguiente:
Vehiculo vehiculo1 = new Vehiculo(“Opel”, “Astra”, 4);
Estamos creando vehículo1
en el heap
y asignando la posición de memoria a su valor;
Por lo que si creamos un vehiculo2
y le asignamos el valor de vehiculo1
lo que estaremos haciendo en verdad es asignar a esa variable el puntero a la misma posición de memoria.
Vehiculo vehiculo1 = new Vehiculo("Opel", "Astra", 4); //Creamos un vehículo
Vehiculo vehiculo2 = vehiculo1; //asignamos el vehiculo2 con el valor de vehiculo1
vehiculo2.Model = "Vectra";
Console.WriteLine(vehiculo1.Model); // imprime vectra;
Console.WriteLine(vehiculo2.Model) // imprime vectra;
Y como vemos en el ejemplo, cuando cambiamos el valor de uno de los objetos, “ambos” se actualizan.
Toda esta acción de utilizar punteros, a diferencia de en otros lenguajes como c, C# lo hace de forma automática por detrás.
El motivo principal por el que tenemos que realizar un montón de comprobaciones en C# para ver si nuestros objetos son null
es por este motivo, porque la variable en realidad es un puntero, y no el valor como tal.
Nota: el puntero añade otros 8 bytes de memoria a lo que pesa el objeto (en un programa de 64 bits) y otros 16 bytes por objeto son añadidos para el uso interno de C# como puede ser el recolector de basura (garbage collector).
Desde C# 9 disponemos de la opción de records
. Los cuales principalmente los utilizaremos para crear tipos por referencia inmutables.
2 - Diferencia entre Heap y Stack
Cuando creamos un objeto en el código, ocupa espacio en la memoria, y para ello tenemos dos opciones, el heap y el stack, aqui vamos a ver en que se diferencian y como trabajan.
2.1 - Qué es el stack?
El stack
es un área de memoria contigua, la cual se asigna de la posición menor de memoria a la posición mayor, en orden. Y cuando queremos liberarla, lo hacemos de la mayor posición a la menor.
Esto quiere decir que para liberar una posición en el medio, debemos liberar todo lo ubicado en una posición mayor.
Para saber si un punto de la memoria está asignado, utilizamos un puntero que apunta a una posición de la memoria, y cuando deasignamos memoria lo que hacemos es mover el puntero una posición para abajo, no limpiamos el espacio de memoria, simplemente la siguiente vez que asignemos un valor sobreescribirá el valor superior.
Como vemos en la imagen al designar el valor lo que hacemos es mover el puntero a la posición con el “valor 1
” pero la posición superior sigue manteniendo el valor que contenía.
Una vez asignamos un nuevo valor, en este caso “valor A
” lo sobreescribimos en la posición superior.
Uno de los grandes beneficios de utilizar asignación con stack es que es muy eficiente y funciona muy bien por ejemplo en funciones locales. Cuando defines una función en tu código todas las variables van al stack y al salir de la función las limpia.
Como nota final, en C# Tenemos un límite de 16 bytes para los structs
(van al stack
) esto es porque al ser pasado por valor tenemos que pasar todo el elemento, y con 16 bytes se puede hacer con un par de instrucciones del procesador, si fuera mayor de 16 perderias las ventajas que trae el stack debido al rendimiento de copiar el elemento en sí.
2.2 - Qué es el heap?
Como hemos visto, el stack
tiene algunas restricciones, por lo que no siempre nos vale, aquí es donde el heap
entra en juego.
Heap
es la memoria que utilizamos para la asignación dinámica de memoria.
Y como su nombre indica, asignamos (y desasignamos) la memoria de forma desorganizada, lo cual puede provocar fragmentación.
A qué me refiero con fragmentación?
Como vemos tenemos varias franjas de memorias asignadas (colores azul, naranja, verde, morado) y varias franjas libres (blanco), si quisiéramos almacenar un objeto que ocupe 3 franjas no podríamos, ya que no tenemos 3 franjas contiguas libres.
Por norma general podemos utilizar el stack para todos aquellos objetos cuyo uso vaya a ir más allá de una función o un proceso.
Finalmente, hemos mencionado que el stack
se limpia solo al salir de la función, en el heap
no, es el desarrollador el que tiene que limpiar el heap. Por suerte C# sabe, gracias a los 16 bytes extra que almacena por objeto, cuando un objeto en memoria ya no se va a utilizar más y pasa el recolector de basura (garbage collector) para limpiar esa memoria que está asignada pero que no se va a volver a utilizar.
3 - Cuándo utilizar struct, class o record
Ahora llega la gran pregunta, con toda esta información, cuando estamos desarrollando código, qué tipo de dato (data type
) debemos utilizar.
3.1 - Cuándo utilizar struct
Podemos crear nuestro tipo como un struct
si cumple las siguientes caracteríticas (todas):
- La instancia es pequeña (16 bytes) y su periodo de vida es comúnmente corto, por ejemplo únicamente lo utilizamos dentro de una función.
- También es común si va a ser parte de otro objeto y nunca un objeto "root" independiente.
- Una de las características principales de los tipos por valor, si va a ser inmutable.
- Finalmente si no va a ser convertido constantemente en un tipo de referencia (boxed).
Si nuestro tipo completa todas estas características, entonces debería ser un struct
.
En caso contrario, deberá ser un tipo por referencia.
3.2 - Cuándo utilizar record
Nuestro tipo va a ser inmutable.
Nota: para mi el ejemplo perfecto de un record es un DTO
de nuestra API. Ya que nunca vamos a querer cambiar el contenido del mismo y a la vez nos ahorra tener que escribir un montón de código.
3.3 - Cuándo utilizar class
Si nuestro objeto no cumple las propiedades anteriores, ni de structs
ni de records
, el tipo que debemos usar es class
.
Hay algunos datos más a tener en cuenta, ya que por ejemplo los records no implementan la interfaz IComparable
o que los structs
no soportan herencia. Pero en el 99% de los casos las pautas mostradas anteriormente.
Conclusión
- En este post hemos visto que son y cuales son las diferencias entre los tipos por valor y los tipos por referencia.
- Hemos visto cuál es la diferencia entre la memoria ubicada en el stack y la memoria ubicada en el heap.
- Finalmente hemos visto cuando utilizar struct, cuando utilizar record y cuando utilizar class en C#.