Tutorial: Noções básicas de pattern matching

Gorjeta

Novo no desenvolvimento de software? Comece primeiro com os tutoriais de Introdução . Eles introduzem classes, métodos e fluxo de controle.

Experimentou em outro idioma? Este tutorial mostra como os padrões C# podem expressar o comportamento do objeto claramente quando as regras dependem do estado atual de um objeto.

Neste tutorial, você criará um aplicativo de console que modela as regras de uma eclusa.

Uma eclusa eleva ou baixa barcos entre dois trechos de água em alturas diferentes. Tem duas comportas e uma câmara cujo nível de água muda entre uma configuração baixa e uma configuração alta. A eclusa só pode operar com segurança quando o nível da água e a posição do portão permanecerem em combinações válidas.

Neste tutorial, você aprenderá como:

  • Expresse o comportamento do objeto combinando com o estado.
  • Implemente essas regras com correspondência de padrões em C#.
  • Use o diagnóstico do compilador para validar sua implementação.

Prerequisites

Instruções de instalação

No Windows, use este arquivo de configuração WinGet para instalar todos os pré-requisitos. Se você já tiver algo instalado, o WinGet ignorará essa etapa.

  1. Baixe o arquivo e clique duas vezes para executá-lo.
  2. Leia o contrato de licença, digite ye selecione Enter quando solicitado a aceitar.
  3. Se você receber um prompt de UAC (Controle de Conta de Usuário) piscando na barra de tarefas, permita que a instalação continue.

Em outras plataformas, você precisa instalar cada um desses componentes separadamente.

  1. Baixe o instalador recomendado na página de download do .NET SDK e clique duas vezes para executá-lo. A página de download detecta sua plataforma e recomenda o instalador mais recente para sua plataforma.
  2. Baixe o instalador mais recente da home page Visual Studio Code e clique duas vezes para executá-lo. Essa página também detecta sua plataforma e o link deve estar correto para o sistema.
  3. Clique no botão "Instalar" na página de extensão do DevKit em C#. Isso abre o código do Visual Studio e pergunta se você deseja instalar ou habilitar a extensão. Selecione "instalar".

Criar uma simulação de uma eclusa de canal

Uma eclusa levanta e baixa barcos entre vias navegáveis em diferentes níveis. Neste tutorial, a eclusa simulada tem uma comporta inferior, uma comporta superior e água que pode estar baixa ou alta.

Na operação normal, um barco entra quando o nível de água dentro da eclusa corresponde ao nível no lado da entrada. Assim que o barco está dentro, ambos os portões fecham. O nível da água muda para corresponder ao nível da água no lado de saída e, em seguida, a comporta de saída é aberta. Para manter o modelo seguro, o nível da água só pode mudar quando ambos os portões estão fechados, e um portão só pode abrir quando o nível da água está no nível desse lado.

Você pode modelar essas regras com uma CanalLock classe. Ele expõe comandos para abrir ou fechar qualquer um dos dois portões e levantar ou baixar a água. Ele também expõe propriedades que relatam o estado atual do bloqueio.

Definir a classe

Crie um projeto de console e adicione uma classe chamada CanalLock. Comece projetando a API pública e deixando 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 a eclusa com ambos os portões fechados e o nível de água baixo. Em seguida, adicione o seguinte código a Main para orientar sua primeira implementação:

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

Agora, adicione uma primeira implementação que altera cada valor de estado sem impor as regras de segurança:

// 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;
}

Essas primeiras verificações passam. Os mecanismos estão funcionando. Em seguida, adicione um teste para a primeira condição de falha. No final da sequência anterior, ambos os portões estão fechados e o nível da água está baixo. Tente 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 falha porque a comporta superior é aberta quando não deveria. Uma primeira correção pode ter esta aparência:

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

Seus testes passam novamente. Mas, à medida que você adiciona mais condições, acumula mais if instruções. O código fica mais difícil de examinar rapidamente porque cada regra é separada das outras.

Implementar os comandos com padrões

Uma opção mais clara é usar padrões para descrever as combinações válidas diretamente. Na próxima etapa, cada expressão de comutador usa uma tupla como entrada de padrão. O C# avalia essa tupla uma vez, e cada ramo do switch testa o estado atual da comporta, o nível da água e a nova configuração solicitada.

Para o portão superior, você pode resumir essas combinações como esta:

Nova configuração Estado do gate Nível da água Result
Fechado Fechado Alto Fechado
Fechado Fechado Baixo Fechado
Fechado Open Alto Fechado
Closed Aberto Low Closed
Open Fechado Alto Open
Open Fechado Baixo Fechado (erro)
Open Open Alto Open
Aberto Aberto Low Encerrado (erro)

As linhas tachadas representam estados internos inválidos. A expressão switch pode codificar as transições válidas diretamente. false ainda significa que o portão está 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 passam. O compilador também avisa que a expressão de comutador não é exaustiva — não abrange todos os valores possíveis — porque WaterLevel é uma enumeração, e o C# permite que qualquer valor do tipo numérico subjacente da enumeração sofra cast para essa enumeração, mesmo um sem um membro nomeado. Adicione um braço final com o padrão de descarte (_) para lidar com esses estados internos impossíveis:

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

Esse braço deve ser o último porque o padrão de descarte corresponde a cada entrada restante.

Em seguida, você pode simplificar as ramificações anteriores. Fechar a porta é sempre permitido, para que um braço possa substituir os quatro casos fechados separados:

(false, _, _) => false,

Você também pode combinar os casos abertos válidos e preservar o único erro de segurança:

(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 o programa novamente. Os testes ainda passam. Esta é a implementação final 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 as regras restantes

Agora aplique a mesma ideia a SetLowGate e SetWaterLevel. Comece adicionando testes que expõem operações inválidas:

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. Esses testes falham, e a eclusa do canal atinge estados inválidos. Implemente os métodos restantes fazendo correspondência com base no estado completo. SetLowGate é semelhante a SetHighGate. SetWaterLevel usa o nível de água atual mais as duas posições do portão:

CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
{
    // arms go here
};

Você tem 16 combinações a serem consideradas. Comece com a tabela completa, escreva os ramos da instrução switch, execute os testes e simplifique os resultados repetidos.

Você ficou com métodos semelhantes a estes?

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

Seus testes agora devem ser aprovados, e a eclusa do canal deve impor suas regras de segurança.

Resumo

Neste tutorial, você usou a correspondência de padrões para expressar como um objeto pode mudar de um estado válido para outro estado válido. Os padrões mantiveram as transições permitidas juntas para que você pudesse compará-las mais facilmente do que com uma longa série de instruções de ramificação. Essa abordagem funciona bem quando o comportamento depende da forma combinada de vários valores de estado.