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, 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 sua máquina para executar o .NET. Baixe oSDK do .NET ou o Visual Studio 2022.

Construa uma simulação de uma eclusa de canal

Neste tutorial, você cria 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 sai 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 classe CanalLock 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 suportar propriedades para ler o estado atual das comportas e o nível da água. Os seus métodos implementam os procedimentos de segurança.

Definir uma classe

Você cria um aplicativo de console para testar sua classe 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, 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 método Main 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 classe CanalLock. O código a seguir implementa os métodos da classe sem preocupação com as regras de segurança. Você adiciona 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 escreveste até agora funcionam. 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, adiciona mais cláusulas if e testa propriedades diferentes. Logo, 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 em 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 de Água Resultado
Fechado Fechado Alto Fechado
Fechado Fechado Baixo Fechado
Fechado Aberto Alto Fechado
Fechado Abrir Baixa Fechado
Aberto Fechado Alto Aberto
Aberto Fechado Baixo Fechado (Erro)
Aberto Aberto Alto Aberto
Abrir Abrir Baixa Fechado (erro)

A quarta e a última linhas da tabela têm texto riscado porque são inválidas. O código que está a adicionar agora deve assegurar que a comporta alta nunca seja aberta quando a água estiver baixa. Esses estados podem ser codificados como uma única expressão de switch (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. 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 tipo enum. O compilador interpreta "todas as entradas possíveis" como todas as entradas do tipo subjacente, normalmente um int. Esta expressão switch apenas verifica os valores declarados no enum. 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 em sua expressão switch porque corresponde a todas as entradas. Experimente movê-lo para um lugar anterior na ordem. Isso causa um erro de compilador CS8510 para código inalcançável em um padrão. A estrutura natural das expressões de switch permite que o compilador gere erros e avisos para possíveis problemas. 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 emite erros se a sua combinação resultar em ramificações inalcançáveis que não estava à espera, e emite avisos se remover uma ramificação necessária.

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 switch:

(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 está false. Esses braços já estão cobertos pelo braço recém-adicionado. Você pode remover essas quatro linhas com segurança. Pretendeste que este novo braço de comutação substituísse essas funcionalidades.

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 lança uma exceção, e o outro não deveria ocorrer. 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. Aqui está 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"),
    };
}

Implemente padrões você mesmo

Agora que você já viu a técnica, preencha os métodos SetLowGate e SetWaterLevel você mesmo. 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 a trava do canal entrar em 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ê tem 16 braços de interruptor no total para preencher. Depois, teste e simplifique.

Você criou métodos 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 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 seus testes devem passar, 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 para facilitar 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.