Usar a correspondência de padrões para criar o comportamento de classe para um código melhor

Os recursos de correspondência de padrões em C# fornecem sintaxe para expressar os algoritmos. Você pode usar essas técnicas para implementar o comportamento nas classes. Você pode combinar o design de classe orientada a objeto com uma implementação orientada a dados para fornecer código conciso durante a modelagem de objetos do mundo real.

Neste tutorial, você aprenderá como:

  • Expressar as classes orientadas a objeto usando padrões de dados.
  • Implementar esses padrões usando os recursos de correspondência de padrões do C#.
  • Aproveitar o diagnóstico do compilador para validar a implementação.

Pré-requisitos

Você precisará configurar o computador para executar o .NET. Baixe o Visual Studio 2022 ou o SDK do .NET.

Criar uma simulação de um bloqueio de canal

Neste tutorial, você criará uma classe C# que simula uma eclusa de canal. Resumidamente, uma eclusa de canal é um dispositivo que levanta e abaixa os barcos à medida que viajam entre dois trechos de água em diferentes níveis. Um bloqueio tem dois portões e algum mecanismo para alterar o nível da água.

Em sua operação normal, um barco entra em um dos portões enquanto o nível da água na eclusa coincide com o nível da água do lado em que o barco entra. Uma vez na eclusa, o nível da água é alterado para corresponder ao nível da água no qual o barco sairá da eclusa. Quando o nível da água corresponder a esse lado, o portão do lado de saída se abre. Medidas de segurança garantem que um operador não possa criar uma situação perigosa no canal. O nível da água só pode ser alterado quando ambos os portões são fechados. No máximo, um portão pode ser aberto. Para abrir um portão, o nível da água na eclusa deve corresponder ao nível da água fora do portão que está sendo aberto.

Você pode criar uma classe C# para modelar esse comportamento. Uma classe CanalLock daria suporte a comandos para abrir ou fechar um dos portões. Ela teria outros comandos para subir ou descer a água. A classe também deve dar suporte a propriedades para ler o estado atual dos portões e do nível da água. Os métodos implementam as medidas de segurança.

Definir um classe

Você criará um aplicativo de console para testar a classe CanalLock. Crie um novo projeto de console para o .NET 5 usando o Visual Studio ou a CLI do .NET. Depois, adicione uma nova classe e nomeie-a CanalLock. Em seguida, crie a API pública, mas deixe os métodos não 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}.";
}

O código anterior inicializa o objeto para que ambos os portões sejam fechados e o nível da água seja baixo. Em seguida, escreva o seguinte código de teste no método Main para orientar você ao criar uma primeira implementação da classe:

// 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}");

Em seguida, adicione uma primeira implementação de cada método na classe CanalLock. O código a seguir implementa os métodos da classe sem preocupação com as regras de segurança. Você adicionará testes de segurança mais tarde:

// 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;
}

Os testes que você escreveu até agora são aprovados. Você implementou as noções básicas. Agora, escreva um teste para a primeira condição de falha. No final dos testes anteriores, ambos os portões são fechados, e o nível da água é definido como baixo. Adicione um teste para tentar abrir o portão 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}");

Esse teste não deu certo porque o portão foi aberto. Como uma primeira implementação, você pode corrigi-la com o seguinte código:

// 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");
}

Os testes são aprovados. Mas, à medida que mais testes são adicionados, você adicionará mais e mais cláusulas if e testará propriedades diferentes. Em breve, esses métodos ficarão muito complicados à medida que você adicionar mais condicionais.

Implementar os comandos com padrões

Uma outra maneira melhor é usar padrões para determinar se o objeto está em um estado válido para executar um comando. Você poderá expressar se um comando for permitido como uma função de três variáveis: o estado do portão, o nível da água e a nova configuração:

Nova configuração Estado do portão Nível da Água Result
Fechado Fechado Alto Fechadas
Fechado Fechado Baixo Fechado
Fechadas Abrir Alto Fechadas
Fechadas Abrir Baixo Fechadas
Abrir Fechadas Alto Abrir
Abrir Fechadas Baixo Fechado (Erro)
Abrir Abrir Alto Abrir
Abrir Abrir Baixo Fechado (Erro)

As quartas e últimas linhas da tabela atingiram o texto porque são inválidas. O código que você está adicionando agora deve garantir que o portão de água alta nunca seja aberto quando a água estiver baixa. Esses estados podem ser codificados como uma expressão única de comutador (lembre-se de que false indica "Fechado"):

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
};

Experimente esta versão. Os testes passam, validando o código. A tabela completa mostra as possíveis combinações de entradas e resultados. Isso significa que você e outros desenvolvedores podem examinar rapidamente a tabela e ver que todas as entradas possíveis foram abrangidas. Ainda mais fácil, o compilador também pode ajudar. Depois de adicionar o código anterior, você pode ver que o compilador gera um aviso: CS8524 indica que a expressão de comutador não abrange todas as entradas possíveis. O motivo desse aviso é que uma das entradas é um tipo enum. O compilador interpreta "todas as entradas possíveis" como todas as entradas do tipo subjacente, normalmente um int. Essa expressão switch verifica apenas os valores declarados no enum. Para remover o aviso, você pode adicionar um padrão de descarte catch-all para o último braço da expressão. Essa condição gera uma exceção, pois indica uma entrada inválida:

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

O braço de comutador anterior deve ser o último na expressão switch porque corresponde a todas as entradas. Experimente movendo-o anteriormente na ordem. Isso causa um erro do compilador CS8510 para código inacessível em um padrão. A estrutura natural de expressões de comutador permite que o compilador gere erros e avisos para possíveis erros. A "rede de segurança" do compilador facilita a criação de código correto em menos iterações e a liberdade de combinar armas com curingas. O compilador emitirá erros se a combinação resultar em braços inacessíveis que você não esperava e avisos se você remover um braço necessário.

A primeira alteração é combinar todos os braços em que o comando deve fechar o portão; isso é sempre permitido. Adicione o seguinte código como o primeiro braço na expressão de comutador:

(false, _, _) => false,

Depois de adicionar o braço de comutador anterior, você receberá quatro erros do compilador, um em cada um dos braços em que o comando é false. Esses braços já estão cobertos por aquele recém-adicionado. Você pode remover essas quatro linhas com segurança. Você pretendia que este novo braço do interruptor substituísse essas condições.

Em seguida, você pode simplificar os quatro braços em que o comando é abrir o portão. Em ambos os casos em que o nível da água é alto, o portão pode ser aberto. (Em um deles, ele já está aberto.) Um caso em que o nível da água é baixo gera uma exceção e o outro não deve acontecer. Deve ser seguro lançar a mesma exceção se o bloqueio de água já estiver em um estado inválido. Você pode fazer as seguintes simplificações para esses braços:

(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"),

Execute os testes novamente e eles serão aprovados. Esta é a versão final do 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"),
    };
}

Implementar padrões por conta própria

Agora que você já viu a técnica, preencha os métodos SetLowGate e SetWaterLevel por conta própria. Comece adicionando o seguinte código para testar operações inválidas nesses 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}");

Execute o aplicativo novamente. Você pode ver que os novos testes falham e o bloqueio do canal entra em um estado inválido. Tente implementar os métodos restantes por conta própria. O método para definir a porta inferior deve ser semelhante àquele para definir o portão superior. O método que altera o nível da água tem verificações diferentes, mas deve seguir uma estrutura semelhante. Você pode achar útil usar o mesmo processo para o método que define o nível da água. Comece com todas as quatro entradas: o estado de ambos os portões, o estado atual do nível da água e o novo nível de água solicitado. A expressão de comutador deve começar com:

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

Você terá 16 braços de comutador totais para preencher. Em seguida, teste e simplifique.

Você fez métodos como 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 high gate when the water is low"),
        _ => 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"),
    };
}

Os testes devem ser aprovados e o bloqueio do canal deve operar com segurança.

Resumo

Neste tutorial, você aprendeu a usar a correspondência de padrões para verificar o estado interno de um objeto antes de aplicar quaisquer alterações a ele. Você pode verificar combinações de propriedades. Depois de criar tabelas para qualquer uma dessas transições, você testa o código e, em seguida, simplifica a legibilidade e a manutenção. Essas refatorações iniciais podem sugerir refatorações adicionais que validam o estado interno ou gerenciam outras alterações de API. Este tutorial combinou classes e objetos com uma abordagem mais orientada a dados e baseada em padrões para implementar essas classes.