En el desarrollo con C#, una de las decisiones más importantes al modelar objetos es elegir entre clases mutables e inmutables. Este artículo explora sus características, ventajas, desafíos y ejemplos prácticos. En este artículo, utilizaremos las nuevas características de .NET 9.

¿Qué son las clases mutables?

Las clases mutables permiten que sus propiedades cambien en cualquier momento después de que el objeto ha sido creado. Este enfoque es directo, pero introduce posibles problemas de mantenimiento en sistemas complejos.

Ejemplo de clase mutable:

public class MutableAccount
{
    public string AccountName { get; set; }
    public decimal Balance { get; set; }
}

var account = new MutableAccount { AccountName = "Savings", Balance = 1000 };
account.Balance += 500; // Cambia el saldo a 1500

Este diseño ha sido ampliamente utilizado durante años debido a su simplicidad. Sin embargo, también es una de las causas más comunes de errores en sistemas financieros y contables. Imagine un entorno multihilo donde dos operaciones intentan modificar el balance al mismo tiempo. Sin una sincronización adecuada, se pueden generar inconsistencias graves, como saldos incorrectos o transacciones perdidas. Este problema, conocido como «race condition» (leer la Nota 2), ha sido un desafío recurrente en el desarrollo de software.

¿Qué son las clases inmutables?

Las clases inmutables, por otro lado, tienen propiedades que no pueden modificarse después de la creación del objeto. Este enfoque promueve un diseño más robusto y predecible.

Ejemplo de clase inmutable:

public class ImmutableAccount
{
    public string AccountName { get; }
    public decimal Balance { get; }

    public ImmutableAccount(string accountName, decimal balance)
    {
        AccountName = accountName ?? throw new ArgumentNullException(nameof(accountName));
        Balance = balance >= 0 ? balance : 
           throw new ArgumentException("Balance must be non-negative");
    }

    public ImmutableAccount Deposit(decimal amount)
    {
        return new ImmutableAccount(AccountName, Balance + amount);
    }
}

var account = new ImmutableAccount("Savings", 1000);
var updatedAccount = account.Deposit(500); // Crea un nuevo objeto con saldo 1500

Al diseñar una cuenta como inmutable, garantizamos que su estado no pueda ser alterado una vez creada. Esto elimina riesgos de inconsistencias y errores provocados por modificaciones inesperadas. Por ejemplo, si un cliente solicita el saldo actual mientras otra operación lo modifica, podríamos devolver información errónea en un diseño mutable. La inmutabilidad asegura que cada instancia refleje un estado consistente y confiable.

Comparación

Característica Mutable Immutable
Cambios de estado Modificable en cualquier momento Fijo tras la creación
Seguridad en hilos Requiere sincronización explícita Intrínsecamente seguro
Facilidad de prueba Más propenso a errores difíciles de rastrear Simplifica la depuración

Estrategias avanzadas con inmutabilidad

La inmutabilidad no implica falta de flexibilidad. Aquí se muestran técnicas avanzadas para manejar escenarios comunes.

Uso de factorias

Las factorias pueden crear instancias controladas para clases inmutables.

Ejemplo:

public class ImmutableAccount
{
    public string AccountName { get; }
    public decimal Balance { get; }

    private ImmutableAccount(string accountName, decimal balance)
    {
        AccountName = accountName ?? throw new ArgumentNullException(nameof(accountName));
        Balance = balance >= 0 ? balance : 
            throw new ArgumentException("Balance must be non-negative");
    }

    // Método de fábrica para crear una nueva cuenta
    public static ImmutableAccount Create(string accountName, decimal initialBalance)
    {
        return new ImmutableAccount(accountName, initialBalance);
    }

    // Método para depositar dinero
    public ImmutableAccount Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Deposit amount must be greater than zero");

        // Crear y devolver una nueva instancia con el saldo actualizado
        return new ImmutableAccount(AccountName, Balance + amount);
    }

    // Método para renombrar la cuenta
    public ImmutableAccount Rename(string newAccountName)
    {
        if (string.IsNullOrWhiteSpace(newAccountName))
            throw new ArgumentException("Account name cannot be empty");

        // Crear y devolver una nueva instancia con el nombre actualizado
        return new ImmutableAccount(newAccountName, Balance);
    }
}

var account = ImmutableAccount.Create("Savings", 1000);
var updatedAccount = account.Deposit(500); // Nueva instancia con saldo actualizado
Console.WriteLine($"Cuenta original: {account.Balance}"); // Imprime: 1000
Console.WriteLine($"Cuenta actualizada: {updatedAccount.Balance}"); // Imprime: 1500

En este ejemplo, los métodos de fábrica permiten controlar las instancias iniciales, mientras que los métodos como Deposit aseguran que las actualizaciones se reflejen en nuevas instancias, manteniendo la inmutabilidad.

¿Cómo garantizar la inmutabilidad al agregar dinero a la cuenta¿?

La clave para la inmutabilidad es que cada operación que implique un cambio de estado no altera el objeto original. En su lugar, crea una nueva instancia con el estado actualizado.

Uso de records en C#

Los records en C# permiten implementar inmutabilidad con concisión y soporte para comparación de valores.

Ejemplo:

public record ImmutableAccount(string AccountName, decimal Balance)  
{  
    public ImmutableAccount Deposit(decimal amount)  
    {  
        return this with { Balance = Balance + amount };  
    }  
  
    public ImmutableAccount RenameAccount(string newName)  
    {  
        return this with { AccountName = newName };  
    }  
}  
  
var account = new ImmutableAccount("Savings", 1000);  
var updatedAccount = account.Deposit(500); // Nueva instancia con saldo actualizado  
var renamedAccount = account.RenameAccount("Checking");  
Ventajas de usar records
  1. Concisión: Los records permiten definir tipos inmutables con menos código, gracias al uso de constructores primarios y sintaxis de propiedades posicionales.

  2. Inmutabilidad: Aunque los records pueden ser mutables, están diseñados principalmente para soportar modelos de datos inmutables, facilitando la creación de instancias que no cambian de estado después de su creación.

  3. Igualdad de valores: Los records implementan la igualdad de valores de forma predeterminada, lo que significa que dos instancias de record con los mismos valores de propiedad son consideradas iguales.

Aplicaciones prácticas
  1. Configuraciones inmutables: Los objetos inmutables son ideales para manejar configuraciones que no deben cambiar una vez definidas.
  2. Cálculos matemáticos seguros: Las clases y records inmutables simplifican cálculos complejos, manteniendo consistencia y evitando efectos secundarios inesperados.
public record ImmutableAccount(string AccountName, decimal Balance)  
{  
    public ImmutableAccount ApplyInterest(decimal rate)  
    {  
        return this with { Balance = Balance * (1 + rate) };  
    }  
}  
  
var account = new ImmutableAccount("Savings", 1000);  
var interestApplied = account.ApplyInterest(0.05m); // Nueva instancia con interés aplicado 

Cálculos matemáticos seguros: Las clases inmutables simplifican cálculos complejos, manteniendo consistencia.

public class ImmutableAccount  
{  
    public string AccountName { get; }  
    public decimal Balance { get; }  
  
    public ImmutableAccount(string accountName, decimal balance)  
    {  
        AccountName = accountName ?? throw new ArgumentNullException(nameof(accountName));  
        Balance = balance >= 0 ? balance : throw new ArgumentException("Balance must be non-negative");  
    }  
  
    public ImmutableAccount ApplyInterest(decimal rate)  
    {  
        return new ImmutableAccount(AccountName, Balance * (1 + rate));  
    }  
}  
  
// Uso de la clase ImmutableAccount  
var account = new ImmutableAccount("Savings", 1000);  
// Nueva instancia con interés aplicado  
var interestApplied = account.ApplyInterest(0.05m);  

¿Cuándo usar o evitar la inmutabilidad?

Usar en:

    • Sistemas multihilo o concurrentes: Donde la consistencia de datos es crucial.
    • Aplicaciones funcionales: Que se benefician de patrones de programación inmutables.
    • Modelos de dominio con estados predecibles: Que no cambian

Evitar en:

    • Escenarios con mutaciones rápidas y frecuentes: En sistemas donde se requiere modificar datos de manera constante y rápida, la inmutabilidad puede introducir una sobrecarga innecesaria debido a la creación de nuevas instancias.
    • Sistemas embebidos con restricciones de memoria: La creación de múltiples instancias de objetos en sistemas con recursos limitados puede ser problemática.
    • Aplicaciones en tiempo real donde el rendimiento es crítico: La sobrecarga de crear nuevas instancias puede impactar el rendimiento en aplicaciones que requieren procesamiento en tiempo real.

Comparativa de Inmutabilidad entre Records y Record Structs

En términos de inmutabilidad, es importante considerar las diferencias fundamentales entre:

  1. Record (o Record Class):
    • Tipo de referencia: Los records son tipos de referencia, lo que significa que la igualdad de valores entre dos instancias de record se basa en el contenido de sus propiedades, no en si son el mismo objeto en memoria.
    • Inmutabilidad por defecto: Las propiedades posicionales en un record son inmutables gracias al uso de init-only, lo que facilita la implementación de inmutabilidad.
    • Herencia: Los records pueden participar en jerarquías de herencia, lo que permite extender su funcionalidad a través de subclases.
    • Seguridad en hilos: Al ser tipos de referencia inmutables, los records son intrínsecamente seguros para el uso en entornos multihilo.
  2. Record Struct:
    • Tipo de valor: Los record structs son tipos de valor, lo que significa que cada instancia es una copia independiente y la igualdad se basa en los valores almacenados en las propiedades.
    • Inmutabilidad opcional: Aunque pueden ser inmutables cuando se declaran como readonly, los record structs también pueden tener propiedades mutables, lo que les da flexibilidad pero puede complicar la inmutabilidad.
    • No hay herencia de clases: No pueden participar en jerarquías de herencia de clases, lo que los limita en términos de extensibilidad.
    • Eficiencia: Como tipos de valor, pueden ser más eficientes en términos de uso de memoria y rendimiento en ciertas situaciones, especialmente cuando se usan en estructuras de datos.
¿Qué es mejor para la inmutabilidad?
  • Record (Record Class) es el más adecuado para escenarios donde la inmutabilidad y la seguridad en hilos son cruciales, debido a sus características de tipo de referencia y su soporte para la herencia.
  • Record Struct podría ser útil en situaciones donde necesitas un tipo de valor eficiente que beneficie de algunas características de los records, como la sintaxis concisa o la igualdad de valores, pero debes tener cuidado de asegurar la inmutabilidad manualmente si es necesario.

Yo usaría esta regla: si la inmutabilidad y la herencia son mis principales preocupaciones, un record es probablemente la mejor opción. Sin embargo, si estoy trabajando con estructuras de datos intensivas en rendimiento y necesito un tipo de valor, podría considerar un record struct, asegurándote de manejar adecuadamente la inmutabilidad.

Inmutabilidad en Sistemas Basados en Eventos y Uso de Records

En un sistema basado en Arquitectura Orientada a Eventos (EDA) y bases de datos de escritura/lectura, la inmutabilidad juega un papel crucial para garantizar que los eventos no se sobrescriban ni modifiquen, lo que ayuda a mantener la coherencia del sistema. Si incorporamos los record de C# en este contexto, podemos mejorar la explicación sobre cómo los eventos son tratados de manera inmutable y cómo se gestionan en memoria y en la base de datos.

Uso de Record en C# en un Sistema EDA:

En C#, los record son tipos de datos inmutables diseñados para representar objetos cuyo estado no cambia una vez que han sido creados. Son ideales en escenarios como el que describes, donde es crucial que los eventos (como transacciones) no se modifiquen después de ser creados.

Ejemplo con Record en C#:

Imaginemos que estamos implementando un sistema de transacciones bancarias en un contexto EDA utilizando C#. Los eventos de transacción, como un depósito o retiro, se representarían como record en C#, de manera que su estado no se pueda modificar después de ser creado.

1. Definición de los Eventos como Record:

Primero, definimos los eventos de la transacción como record en C#:

public record Deposito(decimal Monto, string CuentaDestino);
public record Retiro(decimal Monto, string CuentaDestino);

Cada uno de estos record representa un evento inmutable, donde el Monto y la CuentaDestino no pueden ser cambiados una vez que el evento ha sido creado.

2. Procesamiento de Eventos:

Ahora, cuando un cliente realiza un depósito o un retiro, estos eventos se registran en la base de datos de escritura como record inmutables. Este enfoque asegura que no se puedan modificar los eventos una vez registrados.

Por ejemplo, si el saldo inicial de la cuenta es 200€, y el cliente A realiza un depósito de 100€ y el cliente B realiza un retiro de 50€, ambos eventos se registran de manera inmutable:

Deposito deposito = new Deposito(100m, "CuentaA");
Retiro retiro = new Retiro(50m, "CuentaA");

Ambos eventos se almacenan en una base de datos de escritura (por ejemplo, en un sistema de registros de eventos como Kafka o un almacenamiento persistente).

3. Proyección de los Eventos a la Base de Datos de Lectura:

Cuando estos eventos son proyectados en la base de datos de lectura, cada uno se procesa de manera independiente. Los eventos inmutables permiten calcular el saldo de forma consistente sin el riesgo de interferencia entre los eventos.

Por ejemplo, después de procesar los dos eventos:

  • Depósito de 100€: el saldo proyectado en la base de lectura será 300€.
  • Retiro de 50€: el saldo proyectado en la base de lectura será 250€.

Importante: Los eventos no modifican el estado anterior de la cuenta, solo proyectan un estado nuevo basado en el evento.

4. Prevención de Race Conditions:

Ahora, si dos clientes intentan realizar una transacción simultáneamente (por ejemplo, uno realiza un depósito y otro realiza un retiro), la inmutabilidad de los eventos garantizada por los record asegura que no haya interferencia. Cada evento se maneja de forma independiente:

  • Si Cliente A realiza el depósito primero, su evento inmutable se registra, aumentando el saldo de 200€ a 300€.
  • Luego, Cliente B realiza el retiro. Este evento se procesa independientemente, y se proyecta el saldo a 250€.

En un escenario sin inmutabilidad, podría ocurrir que ambos eventos intenten modificar el mismo saldo simultáneamente, lo que generaría un race condition. Sin embargo, con inmutabilidad, los eventos se procesan de manera secuencial y no modifican los estados previos, lo que evita inconsistencias.

Ejemplo de Race Condition Erróneo Sin Inmutabilidad:

Imagina que en un sistema sin inmutabilidad, el saldo de la cuenta es 200€, y dos transacciones ocurren al mismo tiempo:

  1. Cliente A realiza un depósito de 100€.
  2. Cliente B realiza un retiro de 50€.

Sin inmutabilidad, el sistema podría procesar ambos eventos simultáneamente, sobrescribiendo el saldo de la cuenta:

  • Primero, el sistema podría procesar el depósito de 100€, y actualizar el saldo a 300€.
  • Luego, al procesar el retiro de 50€, el sistema podría actualizar el saldo a 250€, sin tener en cuenta que el saldo debería haber sido 300€ antes de aplicar el retiro.

Esto da como resultado un saldo erróneo de 250€, cuando debería ser 250€ si los eventos se procesaran de forma correcta (es decir, si los eventos fueran tratados como inmutables y procesados en el orden correcto).

Por tanto, el uso de record en C# ayuda a garantizar la inmutabilidad de los eventos, lo que evita race conditions y asegura la consistencia del sistema. Al mantener los eventos inmutables tanto en memoria como en la base de datos, los sistemas EDA pueden gestionar transacciones concurrentes de manera confiable, asegurando que el estado del sistema sea siempre consistente y preciso, sin riesgo de alteraciones no deseadas.

Uso de ImmutableList en C#

Las colecciones inmutables, como ImmutableList, proporcionan una manera efectiva de manejar listas de elementos que no deben cambiar después de su creación. Al igual que las clases y records inmutables, ImmutableList ofrece beneficios significativos en términos de seguridad en hilos y consistencia de datos, especialmente en aplicaciones multihilo o concurrentes.

Ventajas de ImmutableList:

  • Seguridad en hilos: Al ser inmutable, una ImmutableList garantiza que su contenido no cambiará inesperadamente, eliminando la necesidad de bloqueos o sincronización explícita en entornos concurrentes.
  • Facilidad de uso: Las operaciones en una ImmutableList, como agregar o quitar elementos, devuelven una nueva instancia de la lista con los cambios aplicados, dejando la lista original intacta. Esto es similar a cómo las instancias de objetos inmutables se manejan al modificar su estado.
  • Consistencia de datos: En sistemas donde la consistencia de la información es crítica, como en aplicaciones financieras o contables, el uso de colecciones inmutables asegura que los datos permanezcan coherentes a lo largo de su ciclo de vida.

Ejemplo de uso de ImmutableList:

using System;  
using System.Collections.Immutable;  
  
// Definición del record Account, que es inmutable por defecto  
public record Account(string AccountName, decimal Balance);  
  
class Program  
{  
    static void Main()  
    {  
        // Crear una ImmutableList de cuentas  
        var originalList = ImmutableList.Create(  
            new Account("Savings", 1000),  
            new Account("Checking", 500),  
            new Account("Investment", 2000)  
        );  
  
        // Agregar una nueva cuenta a la lista, creando una nueva instancia de la lista  
        var updatedList = originalList.Add(new Account("Emergency", 300));  
  
        // La lista original permanece igual  
        Console.WriteLine($"Original List Count: {originalList.Count}"); // Imprime: 3  
        // La lista actualizada refleja el cambio  
        Console.WriteLine($"Updated List Count: {updatedList.Count}");   // Imprime: 4  
  
        // Mostrar balances de las cuentas en la lista original  
        Console.WriteLine("Original List Balances:");  
        foreach (var account in originalList)  
        {  
            Console.WriteLine($"{account.AccountName}: {account.Balance}");  
        }  
  
        // Mostrar balances de las cuentas en la lista actualizada  
        Console.WriteLine("Updated List Balances:");  
        foreach (var account in updatedList)  
        {  
            Console.WriteLine($"{account.AccountName}: {account.Balance}");  
        }  
    }  
} 

Este ejemplo ilustra cómo usar ImmutableList en conjunto con objetos inmutables para mantener la consistencia y la seguridad en la manipulación de datos en C#.

Aplicaciones prácticas de ImmutableList:

  • Gestión de configuraciones: Ideal para manejar configuraciones que no deben cambiar una vez definidas.
  • Historial de eventos: Perfecta para mantener un registro inmutable de eventos, útil en sistemas basados en eventos.
  • Procesamiento de datos concurrente: Facilita el manejo seguro de datos en aplicaciones multihilo sin conflictos de estado.

La ImmutableList complementa las estrategias de inmutabilidad discutidas en este artículo, proporcionando un control más preciso sobre el estado y la coherencia de sus colecciones de datos en C#.

Cuidado: el uso de ImmutableList garantiza que la colección en sí no puede ser modificada, es decir, no puedes agregar, eliminar o cambiar el orden de los elementos directamente en esa lista. Sin embargo, esto no implica que los objetos contenidos dentro de la ImmutableList sean inmutables. Si los objetos dentro de la lista son mutables, sus propiedades aún pueden ser modificadas. Para asegurar la inmutabilidad completa de los objetos contenidos, es recomendable usar record, ya que las clases no son inmutables por definición.

Uso de readonly struct en c#

Ofrece varias ventajas cuando necesitamso inmutabilidad, especialmente en contextos donde se prioriza el rendimiento y la eficiencia en memoria.

Algunas de las ventajas:

  1. Eficiencia en Memoria: Los structs son tipos de valor, lo que significa que se almacenan en la pila en lugar del heap, por regla general obtenemos una menor sobrecarga de memoria y un acceso más rápido en comparación con los tipos de referencia (clases).
  2. Inmutabilidad de Propiedades: Al declarar un struct como readonly, estás indicando que todas sus propiedades deben ser de solo lectura, lo que asegura que el estado del struct no pueda ser modificado después de su creación. Esto es útil para garantizar la inmutabilidad y prevenir cambios accidentales.
  3. Optimización del Rendimiento: Los readonly struct pueden ser más eficientes en términos de rendimiento, especialmente cuando se pasan a métodos como parámetros. Esto se debe a que los structs, al ser tipos de valor, se copian por valor. Cuando son readonly, el compilador puede aplicar ciertas optimizaciones, como evitar copias innecesarias cuando se accede a sus miembros.
  4. Seguridad en Hilos: Al ser inmutables, los readonly struct son intrínsecamente seguros para su uso en entornos multihilo, ya que no hay riesgo de que el estado interno del struct sea modificado concurrentemente.
  5. Claridad Semántica: Usar readonly struct comunica claramente la intención de inmutabilidad a otros desarrolladores que lean tu código, mejorando así la mantenibilidad y comprensión del código.
public readonly struct Account  
{  
    public Account(string accountName, decimal balance)  
    {  
        AccountName = accountName ?? throw new ArgumentNullException(nameof(accountName));  
        Balance = balance >= 0 ? balance : throw new ArgumentException("Balance must be non-negative");  
    }  
  
    public string AccountName { get; }  
    public decimal Balance { get; }  
  
    // Método para depositar dinero en la cuenta, creando un nuevo struct con el saldo actualizado  
    public Account Deposit(decimal amount)  
    {  
        if (amount <= 0)  
            throw new ArgumentException("Deposit amount must be greater than zero");  
  
        return new Account(AccountName, Balance + amount);  
    }  
  
    // Método para retirar dinero de la cuenta, creando un nuevo struct con el saldo actualizado  
    public Account Withdraw(decimal amount)  
    {  
        if (amount <= 0)  
            throw new ArgumentException("Withdrawal amount must be greater than zero");  
        if (amount > Balance)  
            throw new InvalidOperationException("Insufficient funds");  
  
        return new Account(AccountName, Balance - amount);  
    }  
}  
  
class Program  
{  
    static void Main()  
    {  
        var account = new Account("Savings", 1000);  
        var depositAccount = account.Deposit(200);  
        var withdrawAccount = depositAccount.Withdraw(150);  
  
        Console.WriteLine($"Original Account Balance: {account.Balance}"); // Imprime: 1000  
        Console.WriteLine($"After Deposit Balance: {depositAccount.Balance}"); // Imprime: 1200  
        Console.WriteLine($"After Withdrawal Balance: {withdrawAccount.Balance}"); // Imprime: 1050  
    }  
} 

Este diseño ejemplo utiliza cuentas bancarias de manera inmutable, asegurando consistencia y seguridad en el manejo de sus estados.

Conclusión

La inmutabilidad es una herramienta que mejora la claridad del código, la seguridad en entornos multihilo y la mantenibilidad del software. A través de este artículo, he intentado comprar entre mutables e inmutable, destacando cómo la inmutabilidad pueden evitar problemas comunes como las race conditions. La introducción de características propias de C#, como los records y las colecciones inmutables, nos permite implementar patrones que se benefician de la immutabildiad con mayor facilidad y eficiencia,  por ejemlo un builder o un factory. Además, los readonly struct ofrecen una opción de inmutabilidad en contextos donde el rendimiento y la eficiencia en memoria son cruciales.

Sin embargo, aunque la inmutabilidad aporta numerosos beneficios, no es la solución adecuada para todos los escenarios. En aplicaciones donde las mutaciones rápidas y frecuentes son necesarias, o en sistemas embebidos con restricciones de memoria, la creación constante de nuevas instancias podría introducir una sobrecarga significativa. Por lo tanto, es esencial que evaluemos cuidadosamente el contexto y los requisitos específicos de nuestro proyecto antes de decidir si la inmutabilidad es el enfoque más adecuado.