Partilhar via


Use a correspondência de padrões para criar seu 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 orientado a objeto com uma implementação orientada a dados para fornecer código conciso enquanto modela objetos do mundo real.

Neste tutorial, irá 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ê precisará configurar sua máquina para executar o .NET. Baixe o Visual Studio 2022 ou o SDK do .NET.

Construa uma simulação de uma eclusa de canal

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

Em seu funcionamento normal, um barco entra em um dos portões enquanto o nível da água na eclusa corresponde ao nível da água no lado em que o barco entra. Uma vez na eclusa, o nível da água é alterado para corresponder ao nível da água onde o barco sairá da eclusa. Uma vez que o nível da água corresponde a esse lado, o portão do lado da saída se abre. 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 estar aberto. Para abrir um portão, o nível de água na eclusa deve corresponder ao nível de água fora do portão que está sendo aberto.

Você pode criar uma classe C# para modelar esse comportamento. Uma CanalLock classe suportaria comandos para abrir ou fechar qualquer um dos portões. Teria outros comandos para elevar ou baixar a água. A classe também deve apoiar propriedades para ler o estado atual de ambos os portões e o nível da água. Os seus métodos implementam as medidas de segurança.

Definir uma classe

Você criará um aplicativo de console para testar sua CanalLock classe. 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, projete 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 de água seja baixo. Em seguida, escreva o seguinte código de teste em seu Main método para guiá-lo 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 CanalLock classe. 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 passam. 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 é definido para 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}");

Este teste falha porque o portão se abre. Como uma primeira implementação, você pode corrigi-lo 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 seus testes são aprovados. Mas, à medida que você adiciona mais testes, você adiciona mais e mais if cláusulas e testa propriedades diferentes. Em breve, esses métodos ficarão muito complicados à medida que você adicionar 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 em função de três variáveis: o estado do portão, o nível da água e a nova configuração:

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

A quarta e a última linhas da tabela têm texto riscado porque são inválidas. O código que você está adicionando agora deve garantir que o portão de água alto nunca seja aberto quando a água estiver baixa. Esses estados podem ser codificados como uma única expressão de switch (lembre-se 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. Seus testes são aprovados, validando o código. A tabela completa mostra as combinações possíveis de entradas e resultados. Isso significa que você e outros desenvolvedores podem olhar rapidamente para 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ê pode ver que o compilador gera um aviso: CS8524 indica que a expressão switch não cobre todas as entradas possíveis. A razão para esse aviso é que uma das entradas é um enum tipo. O compilador interpreta "todas as entradas possíveis" como todas as entradas do tipo subjacente, normalmente um intarquivo . Esta switch expressão apenas verifica os valores declarados enumno . Para remover o aviso, você pode adicionar um padrão de descarte abrangente para o último braço da expressão. Esta condição lança uma exceção, porque indica entrada inválida:

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

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

A primeira mudança é combinar todos os braços onde a ordem é fechar o portão; isso é sempre permitido. Adicione o seguinte código como o primeiro braço na expressão do comutador:

(false, _, _) => false,

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

Em seguida, você pode simplificar os quatro braços onde 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, já está aberto.) Um caso em que o nível da água está baixo abre uma exceção, e o outro não deveria 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 são aprovados. Aqui está a versão final do SetHighGate método:

// 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 padrões você mesmo

Agora que você já viu a técnica, preencha você mesmo os SetLowGate métodos e SetWaterLevel . 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 os novos testes falharem e o bloqueio do canal entrar em um estado inválido. Tente implementar os métodos restantes por conta própria. O método para definir o portão inferior deve ser semelhante ao método 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 de água. Comece com todas as quatro entradas: O estado de ambas as comportas, 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 interruptor no total para preencher. Depois, teste e simplifique.

Você fez métodos algo assim?

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

Seus testes devem ser aprovados e a eclusa 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 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.