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