Compartilhar via


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 seus algoritmos. Você pode usar essas técnicas para implementar o comportamento em suas classes. Você pode combinar o design de classe orientada a objetos com uma implementação orientada a dados para fornecer código conciso durante a modelagem de objetos do mundo real.

Neste tutorial, você aprenderá a:

  • Expresse suas classes orientadas a objetos usando padrões de dados.
  • Implemente esses padrões usando os recursos de correspondência de padrões do C#.
  • Aproveite o diagnóstico do compilador para validar sua implementação.

Pré-requisitos

Você precisa configurar seu 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, um bloqueio de canal é um dispositivo que levanta e baixa barcos enquanto viajam entre dois trechos de água em níveis diferentes. 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 fechadura corresponde ao nível da água no lado em que o barco entra. Uma vez na eclusa, o nível da água é ajustado para corresponder ao nível da água de onde o barco sairá da eclusa. Depois que o nível da água corresponder a esse lado, o portão no lado de saída será aberto. As 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 estão fechados. No máximo, um portão pode ser aberto. Para abrir um portão, o nível da água na fechadura 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 ofereceria suporte a comandos para abrir ou fechar qualquer porta. Teria outros comandos para levantar ou baixar 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 uma classe

Você cria um aplicativo de console para testar sua classe de CanalLock. Crie um novo projeto de console para .NET 5 usando o Visual Studio ou a CLI do .NET. Em seguida, adicione uma nova classe e nomeie-a CanalLock. Em seguida, crie sua 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 em seu método Main para guiá-lo à medida que você cria 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 foram aprovados. Você implementou o básico. Agora, escreva um teste para a primeira condição de falha. No final dos testes anteriores, ambos os portões estão fechados, e o nível da água está definido como baixo. Adicione um teste para tentar abrir a porta 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 falha porque a porta é aberta. Como 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 você adiciona mais testes, adiciona mais cláusulas if e testa propriedades diferentes. Em breve, esses métodos ficam muito complicados à medida que você adiciona mais condicionais.

Implementar os comandos com padrões

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

Nova configuração Estado do portão Nível da Água Resultado
Fechado Fechado Alto Fechado
Fechado Fechado Baixo Fechado
Fechado Abrir Alto Fechado
Fechado Abrir Baixo Fechado
Abrir Fechado Alto Abrir
Abrir Fechado 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 única expressão 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 você cobriu todas as entradas possíveis. Ainda mais fácil, o compilador também pode ajudar. Depois de adicionar o código anterior, você poderá 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 switch:

(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 pelo braço recém-adicionado. Você pode remover com segurança essas quatro linhas. 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 seus testes novamente e eles passam. 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, complete 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 seu 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 ao método para definir a porta 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 switch 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 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"),
    };
}

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 as alterações a esse estado. Você pode verificar combinações de propriedades. Depois de criar tabelas para qualquer uma dessas transições, você testa seu 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.