Compartir a través de


Usa la coincidencia de patrones para estructurar el comportamiento de tu clase y mejorar el código

Las características de coincidencia de patrones de C# proporcionan sintaxis para expresar los algoritmos. Puede usar estas técnicas para implementar el comportamiento en las clases. Puede combinar el diseño de clases orientadas a objetos con una implementación orientada a datos para proporcionar código conciso al modelar objetos reales.

En este tutorial, aprenderá a:

  • Exprese las clases orientadas a objetos mediante patrones de datos.
  • Implemente esos patrones mediante las características de coincidencia de patrones de C#.
  • Aproveche los diagnósticos del compilador para validar la implementación.

Prerrequisitos

Debe configurar la máquina para ejecutar .NET. Descargue Visual Studio 2022 o el SDK de .NET.

Crear una simulación de un bloqueo de canal

En este tutorial, compilará una clase de C# que simula una esclusa de canal. Brevemente, un bloqueo de canal es un dispositivo que eleva y baja barcos a medida que viajan entre dos tramos de agua a diferentes niveles. Un bloqueo tiene dos puertas y algún mecanismo para cambiar el nivel de agua.

En su funcionamiento normal, un barco entra en una de las puertas mientras el nivel de agua de la cerradura coincide con el nivel de agua en el lado que entra el barco. Una vez en el bloqueo, el nivel de agua se cambia para que coincida con el nivel de agua donde el barco sale del bloqueo. Una vez que el nivel de agua coincide con ese lado, se abre la puerta del lado de salida. Las medidas de seguridad aseguran que un operador no pueda crear una situación peligrosa en el canal. El nivel de agua solo se puede cambiar cuando ambas puertas están cerradas. Como máximo, se puede abrir una puerta. Para abrir una compuerta, el nivel de agua en la esclusa debe coincidir con el nivel de agua afuera de la compuerta que se está abriendo.

Puede crear una clase de C# para modelar este comportamiento. Una clase CanalLock admitiría comandos para abrir o cerrar cualquiera de las puertas. Tendría otros comandos para elevar o bajar el agua. La clase también debería admitir propiedades para leer el estado actual de ambas compuertas y el nivel del agua. Los métodos implementan las medidas de seguridad.

Definición de una clase

Compile una aplicación de consola para probar la clase CanalLock. Cree un nuevo proyecto de consola para .NET 5 mediante Visual Studio o la CLI de .NET. A continuación, agregue una nueva clase y asígnele el nombre CanalLock. A continuación, diseñe la API pública, pero deje los métodos no implementados:

public enum WaterLevel
{
    Low,
    High
}
public class CanalLock
{
    // Query canal lock state:
    public WaterLevel CanalLockWaterLevel { get; private set; } = WaterLevel.Low;
    public bool HighWaterGateOpen { get; private set; } = false;
    public bool LowWaterGateOpen { get; private set; } = false;

    // Change the upper gate.
    public void SetHighGate(bool open)
    {
        throw new NotImplementedException();
    }

    // Change the lower gate.
    public void SetLowGate(bool open)
    {
        throw new NotImplementedException();
    }

    // Change water level.
    public void SetWaterLevel(WaterLevel newLevel)
    {
        throw new NotImplementedException();
    }

    public override string ToString() =>
        $"The lower gate is {(LowWaterGateOpen ? "Open" : "Closed")}. " +
        $"The upper gate is {(HighWaterGateOpen ? "Open" : "Closed")}. " +
        $"The water level is {CanalLockWaterLevel}.";
}

El código anterior inicializa el objeto para que ambas puertas estén cerradas y el nivel de agua sea bajo. A continuación, escriba el código de prueba siguiente en el método Main para guiarle a medida que cree una primera implementación de la clase:

// Create a new canal lock:
var canalGate = new CanalLock();

// State should be doors closed, water level low:
Console.WriteLine(canalGate);

canalGate.SetLowGate(open: true);
Console.WriteLine($"Open the lower gate:  {canalGate}");

Console.WriteLine("Boat enters lock from lower gate");

canalGate.SetLowGate(open: false);
Console.WriteLine($"Close the lower gate:  {canalGate}");

canalGate.SetWaterLevel(WaterLevel.High);
Console.WriteLine($"Raise the water level: {canalGate}");

canalGate.SetHighGate(open: true);
Console.WriteLine($"Open the higher gate:  {canalGate}");

Console.WriteLine("Boat exits lock at upper gate");
Console.WriteLine("Boat enters lock from upper gate");

canalGate.SetHighGate(open: false);
Console.WriteLine($"Close the higher gate: {canalGate}");

canalGate.SetWaterLevel(WaterLevel.Low);
Console.WriteLine($"Lower the water level: {canalGate}");

canalGate.SetLowGate(open: true);
Console.WriteLine($"Open the lower gate:  {canalGate}");

Console.WriteLine("Boat exits lock at upper gate");

canalGate.SetLowGate(open: false);
Console.WriteLine($"Close the lower gate:  {canalGate}");

A continuación, agregue una primera implementación de cada método en la clase CanalLock. El código siguiente implementa los métodos de la clase sin preocuparse por las reglas de seguridad. Agregue pruebas de seguridad más adelante:

// Change the upper gate.
public void SetHighGate(bool open)
{
    HighWaterGateOpen = open;
}

// Change the lower gate.
public void SetLowGate(bool open)
{
    LowWaterGateOpen = open;
}

// Change water level.
public void SetWaterLevel(WaterLevel newLevel)
{
    CanalLockWaterLevel = newLevel;
}

Las pruebas que ha escrito hasta ahora son correctas. Implementaste los conceptos básicos. Ahora, escriba una prueba para la primera condición de error. Al final de las pruebas anteriores, ambas puertas están cerradas y el nivel de agua se establece en bajo. Agregue una prueba para intentar abrir la puerta superior:

Console.WriteLine("=============================================");
Console.WriteLine("     Test invalid commands");
// Open "wrong" gate (2 tests)
try
{
    canalGate = new CanalLock();
    canalGate.SetHighGate(open: true);
}
catch (InvalidOperationException)
{
    Console.WriteLine("Invalid operation: Can't open the high gate. Water is low.");
}
Console.WriteLine($"Try to open upper gate: {canalGate}");

Esta prueba falla porque se abre la puerta. Como primera implementación, podría corregirla con el código siguiente:

// Change the upper gate.
public void SetHighGate(bool open)
{
    if (open && (CanalLockWaterLevel == WaterLevel.High))
        HighWaterGateOpen = true;
    else if (open && (CanalLockWaterLevel == WaterLevel.Low))
        throw new InvalidOperationException("Cannot open high gate when the water is low");
}

Las pruebas se realizan correctamente. Pero, a medida que agrega más pruebas, agrega más cláusulas if y prueba diferentes propiedades. Pronto, estos métodos se complican demasiado a medida que se agregan más condicionales.

Implementación de los comandos con patrones

Una mejor manera es usar patrones para determinar si el objeto está en un estado válido para ejecutar un comando. Puede expresar si se permite un comando como función de tres variables: el estado de la puerta, el nivel del agua y la nueva configuración:

Nueva configuración Estado de la puerta Nivel del agua Resultado
Cerrado Cerrado Alto Cerrado
Cerrado Cerrado Bajo Cerrado
Cerrado Abrir Alto Cerrado
Cerrado Abierto Bajo Cerrado
Abrir Cerrado Alto Abrir
Abrir Cerrado Bajo Cerrado (Error)
Abrir Abrir Alto Abrir
Abierto Abierto Bajo Cerrado (Error)

La cuarta y la última fila de la tabla tienen tachado el texto porque no son válidas. El código que va a agregar ahora debe garantizar que la compuerta superior no se abrirá nunca si el nivel del agua es bajo. Esos estados se pueden codificar como una expresión de conmutador único (recuerde que false indica "Closed"):

HighWaterGateOpen = (open, HighWaterGateOpen, CanalLockWaterLevel) switch
{
    (false, false, WaterLevel.High) => false,
    (false, false, WaterLevel.Low) => false,
    (false, true, WaterLevel.High) => false,
    (false, true, WaterLevel.Low) => false, // should never happen
    (true, false, WaterLevel.High) => true,
    (true, false, WaterLevel.Low) => throw new InvalidOperationException("Cannot open high gate when the water is low"),
    (true, true, WaterLevel.High) => true,
    (true, true, WaterLevel.Low) => false, // should never happen
};

Pruebe esta versión. Las pruebas se realizan correctamente, lo que valida el código. En la tabla completa se muestran las posibles combinaciones de entradas y resultados. Esto significa que usted y otros desarrolladores pueden examinar rápidamente la tabla y ver que ha cubierto todas las entradas posibles. Incluso más fácil, el compilador también puede ayudar. Después de agregar el código anterior, puede ver que el compilador genera una advertencia: CS8524 indica que la expresión switch no cubre todas las entradas posibles. El motivo de esa advertencia es que una de las entradas es un tipo enum. El compilador interpreta "todas las entradas posibles" como todas las entradas del tipo subyacente, normalmente una int. Esta expresión switch solo comprueba los valores declarados en el enum. Para quitar la advertencia, puede agregar un patrón de descarte comodín para el último segmento de la expresión. Esta condición produce una excepción, ya que indica una entrada no válida:

_  => throw new InvalidOperationException("Invalid internal state"),

El segmento modificador anterior debe ir al final de la expresión switch porque coincide con todas las entradas. Experimente poniendo el segmento modificador antes en la expresión. Eso generará un error de compilador CS8510 para un código inalcanzable en un patrón. La estructura natural de las expresiones switch permite al compilador generar errores y advertencias para posibles errores. La "red de seguridad" del compilador facilita la creación de código correcto en menos iteraciones y brinda la libertad de combinar segmentos modificadores con caracteres comodín. El compilador emite errores si la combinación da como resultado brazos inaccesibles que no esperaba y advertencias si quita un brazo necesario.

El primer cambio es combinar todos los brazos donde el comando es cerrar la puerta; eso siempre está permitido. Agregue el código siguiente como primer brazo en la expresión switch:

(false, _, _) => false,

Después de agregar el segmento modificador anterior, recibirá cuatro errores de compilador, uno en cada uno de los segmentos donde el comando es false. Esos brazos ya están cubiertos por el brazo recién agregado. Puede quitar esas cuatro líneas de forma segura. Su intención era que este segmento modificador nuevo reemplazara esas condiciones.

A continuación, puede simplificar los cuatro brazos donde el comando es abrir la puerta. En ambos casos en los que el nivel de agua es alto, se puede abrir la puerta. (En un caso, ya está abierta). Un caso en el que el nivel del agua es bajo genera una excepción y el otro no debería ocurrir. Debería ser seguro generar la misma excepción si el cierre hidráulico ya tiene un estado no válido. Puede realizar las siguientes simplificaciones para esos brazos:

(true, _, WaterLevel.High) => true,
(true, false, WaterLevel.Low) => throw new InvalidOperationException("Cannot open high gate when the water is low"),
_ => throw new InvalidOperationException("Invalid internal state"),

Vuelva a ejecutar las pruebas y las completarán correctamente. Esta es la versión final del método SetHighGate:

// Change the upper gate.
public void SetHighGate(bool open)
{
    HighWaterGateOpen = (open, HighWaterGateOpen, CanalLockWaterLevel) switch
    {
        (false, _,    _)               => false,
        (true, _,     WaterLevel.High) => true,
        (true, false, WaterLevel.Low)  => throw new InvalidOperationException("Cannot open high gate when the water is low"),
        _                              => throw new InvalidOperationException("Invalid internal state"),
    };
}

Implemente patrones usted mismo

Ahora que ha visto la técnica, rellene usted mismo los métodos SetLowGate y SetWaterLevel. Empiece agregando el código siguiente para probar operaciones no válidas en esos métodos:

Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetWaterLevel(WaterLevel.High);
    canalGate.SetLowGate(open: true);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't open the lower gate. Water is high.");
}
Console.WriteLine($"Try to open lower gate: {canalGate}");
// change water level with gate open (2 tests)
Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetLowGate(open: true);
    canalGate.SetWaterLevel(WaterLevel.High);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't raise water when the lower gate is open.");
}
Console.WriteLine($"Try to raise water with lower gate open: {canalGate}");
Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetWaterLevel(WaterLevel.High);
    canalGate.SetHighGate(open: true);
    canalGate.SetWaterLevel(WaterLevel.Low);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't lower water when the high gate is open.");
}
Console.WriteLine($"Try to lower water with high gate open: {canalGate}");

Vuelva a ejecutar la aplicación. Puede ver que se generan errores en las pruebas nuevas y que la esclusa de canal queda en un estado no válido. Intente implementar los métodos restantes usted mismo. El método para establecer la puerta inferior debe ser similar al método para establecer la puerta superior. El método que cambia el nivel de agua tiene comprobaciones diferentes, pero debe seguir una estructura similar. Es posible que le resulte útil usar el mismo proceso para el método que establece el nivel de agua. Comience con las cuatro entradas: el estado de ambas puertas, el estado actual del nivel de agua y el nuevo nivel de agua solicitado. La expresión switch debe comenzar con:

CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
{
    // elided
};

Tendrá que completar un total de 16 segmentos modificadores. A continuación, pruebe y simplifique.

¿Creaste métodos parecidos a este?

// Change the lower gate.
public void SetLowGate(bool open)
{
    LowWaterGateOpen = (open, LowWaterGateOpen, CanalLockWaterLevel) switch
    {
        (false, _, _) => false,
        (true, _, WaterLevel.Low) => true,
        (true, false, WaterLevel.High) => throw new InvalidOperationException("Cannot open low gate when the water is high"),
        _ => throw new InvalidOperationException("Invalid internal state"),
    };
}

// Change water level.
public void SetWaterLevel(WaterLevel newLevel)
{
    CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
    {
        (WaterLevel.Low, WaterLevel.Low, true, false) => WaterLevel.Low,
        (WaterLevel.High, WaterLevel.High, false, true) => WaterLevel.High,
        (WaterLevel.Low, _, false, false) => WaterLevel.Low,
        (WaterLevel.High, _, false, false) => WaterLevel.High,
        (WaterLevel.Low, WaterLevel.High, false, true) => throw new InvalidOperationException("Cannot lower water when the high gate is open"),
        (WaterLevel.High, WaterLevel.Low, true, false) => throw new InvalidOperationException("Cannot raise water when the low gate is open"),
        _ => throw new InvalidOperationException("Invalid internal state"),
    };
}

Las pruebas deben superarse y el bloqueo del canal debe funcionar de forma segura.

Resumen

En este tutorial, ha aprendido a usar la coincidencia de patrones para comprobar el estado interno de un objeto antes de aplicar los cambios a ese estado. Puede comprobar combinaciones de propiedades. Una vez que haya compilado tablas para cualquiera de esas transiciones, pruebe el código y, a continuación, simplifique la legibilidad y el mantenimiento. Estas refactorizaciones iniciales pueden sugerir refactorizaciones adicionales que validen el estado interno o administren otros cambios de API. En este tutorial se combinan clases y objetos con un enfoque más orientado a datos y basado en patrones para implementar esas clases.