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 |
Abrir | Fechadas | Alto | Abrir |
Abrir | Fechadas | Baixo | Fechado (Erro) |
Abrir | Abrir | Alto | Abrir |
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.