Tutorial: Criar algoritmos usando correspondência de padrões

A correspondência de padrões permite adicionar funcionalidade para tipos definidos em outras bibliotecas sem modificar esses tipos. Outro uso dos padrões é criar a funcionalidade de que seu aplicativo precisa, mas que não é um recurso fundamental do tipo que está sendo estendido.

Neste tutorial, você aprenderá como:

  • Reconhecer situações em que a correspondência de padrões deverá ser usada.
  • Usar expressões de correspondência de padrões para implementar o comportamento com base em tipos e valores de propriedade.
  • Combinar a correspondência de padrões com outras técnicas para criar algoritmos completos.

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".

Cenários para a correspondência de padrões

O desenvolvimento moderno geralmente inclui a integração de dados de várias fontes e a apresentação de informações e insights de dados em um único aplicativo coeso. Você e sua equipe podem não controlar os tipos que representam os dados de entrada.

A abordagem de design clássica orientada a objetos exige a criação de tipos em seu aplicativo que representam cada tipo de dados dessas várias fontes de dados. Em seguida, seu aplicativo funciona com esses novos tipos, constrói hierarquias de herança, cria métodos virtuais e implementa abstrações. Essas técnicas funcionam e, às vezes, são as melhores ferramentas. Outras vezes, é possível escrever menos código. Em alguns casos, a separação de dados das operações que os usam pode facilitar a leitura do código.

Neste tutorial, você criará e explorará um aplicativo que recebe dados de várias fontes externas para um único cenário. Você vê como a correspondência de padrões fornece uma maneira eficiente de consumir e processar esses dados de maneiras que não faziam parte do sistema original.

Considere uma grande área metropolitana que usa pedágios e preços de horário de pico para gerenciar o tráfego. Você escreve um aplicativo que calcula o pedágio de um veículo com base em seu tipo. Posteriormente, as melhorias vão incorporar preços com base no número de ocupantes do veículo. Outros aprimoramentos vão adicionar o preço com base na hora e no dia da semana.

A partir dessa breve descrição, você pode esboçar rapidamente uma hierarquia de objetos para modelar esse sistema. No entanto, seus dados são provenientes de várias fontes, como outros sistemas de gerenciamento de registro de veículos. Esses sistemas fornecem classes diferentes para modelar esses dados e esses sistemas não compartilham um único modelo de objeto. Neste tutorial, você usará essas classes simplificadas para modelar os dados do veículo desses sistemas externos, conforme mostrado no código a seguir:

namespace ConsumerVehicleRegistration
{
    public class Car
    {
        public int Passengers { get; set; }
    }
}

namespace CommercialRegistration
{
    public class DeliveryTruck
    {
        public int GrossWeightClass { get; set; }
    }
}

namespace LiveryRegistration
{
    public class Taxi
    {
        public int Fares { get; set; }
    }

    public class Bus
    {
        public int Capacity { get; set; }
        public int Riders { get; set; }
    }
}

Faça o download do código inicial no repositório dotnet/samples do GitHub. Você pode ver que as classes de veículos são de sistemas diferentes e estão em namespaces diferentes. Você não pode usar uma classe base comum diferente de System.Object.

Modelos de correspondência de padrões

O cenário usado neste tutorial destaca os tipos de problemas que a correspondência de padrões resolve bem:

  • Os objetos com os quais você precisa trabalhar não estão em uma hierarquia de objetos que corresponde aos seus objetivos. Você pode estar trabalhando com classes que fazem parte de sistemas não relacionados.
  • A funcionalidade que você está adicionando não faz parte da abstração central dessas classes. A tarifa paga por um veículo muda de acordo com diferentes tipos de veículos, mas o pedágio não é uma função principal do veículo.

Quando a forma dos dados e as operações nos dados não são descritas em conjunto, o recurso de correspondência padrões no C# facilita o trabalho.

Implementar os cálculos básicos de pedágio

O cálculo mais básico do pedágio dependerá apenas do tipo do veículo:

  • Um Car é R$2,00.
  • Um Taxi é R$ 3,50.
  • Um Bus custa R$5,00.
  • DeliveryTruck custa US$ 10,00.

Crie uma nova classe TollCalculator e implemente a correspondência de padrões no tipo de veículo para obter a quantidade do pedágio. O código a seguir mostra a implementação inicial do TollCalculator.

using System;
using CommercialRegistration;
using ConsumerVehicleRegistration;
using LiveryRegistration;

namespace Calculators;

public class TollCalculator
{
    public decimal CalculateToll(object vehicle) =>
        vehicle switch
    {
        Car c           => 2.00m,
        Taxi t          => 3.50m,
        Bus b           => 5.00m,
        DeliveryTruck t => 10.00m,
        { }             => throw new ArgumentException(message: "Not a known vehicle type", paramName: nameof(vehicle)),
        null            => throw new ArgumentNullException(nameof(vehicle))
    };
}

O código anterior usa uma expressão switch (não é o mesmo que uma instrução switch) que testa o padrão de declaração. A expressão switch inicia-se com a variável vehicle no código anterior, seguida pela palavra-chave switch. Em seguida, estão os braços do switch dentro de chaves. A expressão switch faz outros refinamentos na sintaxe que circunda a instrução switch. A palavra-chave case é omitida, e o resultado de cada braço é uma expressão. Os dois últimos braços apresentam um novo recurso de linguagem. O caso { } corresponde a qualquer objeto não nulo que não correspondia a um braço anterior. Este braço captura qualquer tipo incorreto passado para esse método. O caso { } deve seguir os casos para cada tipo de veículo. Se a ordem for revertida, o caso { } terá precedência. Finalmente, o nullpadrão constante detecta quando null é passado para esse método. O padrão null pode ser o último, porque os outros padrões correspondem apenas a um objeto não nulo do tipo correto.

Você pode testar esse código usando o seguinte código em Program.cs:

using System;
using CommercialRegistration;
using ConsumerVehicleRegistration;
using LiveryRegistration;

using toll_calculator;

var tollCalc = new TollCalculator();

var car = new Car();
var taxi = new Taxi();
var bus = new Bus();
var truck = new DeliveryTruck();

Console.WriteLine($"The toll for a car is {tollCalc.CalculateToll(car)}");
Console.WriteLine($"The toll for a taxi is {tollCalc.CalculateToll(taxi)}");
Console.WriteLine($"The toll for a bus is {tollCalc.CalculateToll(bus)}");
Console.WriteLine($"The toll for a truck is {tollCalc.CalculateToll(truck)}");

try
{
    tollCalc.CalculateToll("this will fail");
}
catch (ArgumentException e)
{
    Console.WriteLine("Caught an argument exception when using the wrong type");
}
try
{
    tollCalc.CalculateToll(null!);
}
catch (ArgumentNullException e)
{
    Console.WriteLine("Caught an argument exception when using null");
}

Esse código está incluído no projeto inicial, mas está comentado. Remova os comentários e você pode testar o que escreveu.

Você está começando a ver como os padrões podem ajudar a criar algoritmos em que o código e os dados estão separados. A expressão switch testa o tipo e produz valores diferentes com base nos resultados. Mas isso é somente o começo.

Adicionar preços de acordo com a ocupação

A autoridade de pedágio deseja incentivar o tráfego de veículos na sua capacidade máxima. Eles decidem cobrar mais quando os veículos têm menos passageiros, e incentivam veículos completos oferecendo preços mais baixos:

  • Os carros e táxis com nenhum passageiro pagam uma taxa adicional de R$ 0,50.
  • Os carros e táxis com dois passageiros obtêm um desconto de R$ 0,50.
  • Os carros e táxis com três ou mais passageiros obtêm um desconto de R$ 1,00.
  • Os ônibus com menos de 50% da capacidade completa pagam uma taxa adicional de R$ 2,00.
  • Os ônibus com 90% da capacidade de passageiros completa, ganham um desconto de R$ 1,00.

Você pode implementar essas regras usando um padrão de propriedade na mesma expressão switch. Um padrão de propriedade compara um valor de propriedade com um valor constante. O padrão de propriedade examina as propriedades do objeto assim que o tipo é determinado. Car {Passengers: 0} é, em si, um padrão recursivo: o padrão de propriedade externo em Car contém um padrão interno de constante que testa o valor de Passengers. O único caso de um Car se expande para quatro casos diferentes:

vehicle switch
{
    Car {Passengers: 0} => 2.00m + 0.50m,
    Car {Passengers: 1} => 2.0m,
    Car {Passengers: 2} => 2.0m - 0.50m,
    Car                 => 2.00m - 1.0m,

    // ...
};

Os três primeiros casos testam o tipo como um Car, em seguida, verificam o valor da propriedade Passengers. Se ambas corresponderem, a expressão é avaliada e retornada.

Você também pode expandir os casos de táxis de maneira semelhante:

vehicle switch
{
    // ...

    Taxi {Fares: 0}  => 3.50m + 1.00m,
    Taxi {Fares: 1}  => 3.50m,
    Taxi {Fares: 2}  => 3.50m - 0.50m,
    Taxi             => 3.50m - 1.00m,

    // ...
};

Em seguida, implemente as regras de ocupação expandindo os casos para os ônibus, conforme mostrado no exemplo a seguir:

vehicle switch
{
    // ...

    Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
    Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
    Bus => 5.00m,

    // ...
};

A autoridade de pedágio não está preocupada com o número de passageiros nos caminhões de carga. Em vez disso, ela ajusta a quantidade de pedágios com base na classe de peso dos caminhões da seguinte maneira:

  • Os caminhões mais de 5000 quilos pagam uma taxa adicional de R$ 5,00.
  • Os caminhões leves abaixo de 3.000 lb recebem um desconto de US$ 2,00.

Você pode implementar essa regra com o seguinte código:

vehicle switch
{
    // ...

    DeliveryTruck t when (t.GrossWeightClass > 5000) => 10.00m + 5.00m,
    DeliveryTruck t when (t.GrossWeightClass < 3000) => 10.00m - 2.00m,
    DeliveryTruck => 10.00m,
};

O código anterior mostra a cláusula when de um braço de switch. Use a cláusula when para testar condições diferentes de igualdade em uma propriedade. Ao concluir, você tem um método que se parece muito com o seguinte código:

vehicle switch
{
    Car {Passengers: 0}        => 2.00m + 0.50m,
    Car {Passengers: 1}        => 2.0m,
    Car {Passengers: 2}        => 2.0m - 0.50m,
    Car                        => 2.00m - 1.0m,

    Taxi {Fares: 0}  => 3.50m + 1.00m,
    Taxi {Fares: 1}  => 3.50m,
    Taxi {Fares: 2}  => 3.50m - 0.50m,
    Taxi             => 3.50m - 1.00m,

    Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
    Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
    Bus => 5.00m,

    DeliveryTruck t when (t.GrossWeightClass > 5000) => 10.00m + 5.00m,
    DeliveryTruck t when (t.GrossWeightClass < 3000) => 10.00m - 2.00m,
    DeliveryTruck => 10.00m,

    { }     => throw new ArgumentException(message: "Not a known vehicle type", paramName: nameof(vehicle)),
    null    => throw new ArgumentNullException(nameof(vehicle))
};

Como observado anteriormente, esses braços do switch são padrões recursivos, aninhando um padrão constante dentro de um padrão de propriedade.

É possível fazer esse código menos repetitivo, usando switches aninhados. O Car e Taxi têm quatro braços diferentes nos exemplos anteriores. Em ambos os casos, você pode criar um padrão de declaração que serve como base para um padrão constante. Essa técnica é mostrada no código a seguir:

public decimal CalculateToll(object vehicle) =>
    vehicle switch
    {
        Car c => c.Passengers switch
        {
            0 => 2.00m + 0.5m,
            1 => 2.0m,
            2 => 2.0m - 0.5m,
            _ => 2.00m - 1.0m
        },

        Taxi t => t.Fares switch
        {
            0 => 3.50m + 1.00m,
            1 => 3.50m,
            2 => 3.50m - 0.50m,
            _ => 3.50m - 1.00m
        },

        Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
        Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
        Bus b => 5.00m,

        DeliveryTruck t when (t.GrossWeightClass > 5000) => 10.00m + 5.00m,
        DeliveryTruck t when (t.GrossWeightClass < 3000) => 10.00m - 2.00m,
        DeliveryTruck t => 10.00m,

        { }  => throw new ArgumentException(message: "Not a known vehicle type", paramName: nameof(vehicle)),
        null => throw new ArgumentNullException(nameof(vehicle))
    };

No exemplo anterior, aninhar uma expressão de comutador dentro de outro braço de comutador significa que você não repete os braços Taxi e Car que contêm braços filho que testam o valor da propriedade. Essa técnica não é usada para os braços Bus e DeliveryTruck porque esses braços testam intervalos para a propriedade, não valores discretos.

Adicionar precificação de pico

Para a funcionalidade final, a autoridade de pedágio deseja adicionar tarifação de pico com variação por horário. Durante os horários de pico da manhã e do final da tarde, os pedágios serão dobrados. Essa regra afetará apenas o tráfego em uma direção: entrada para a cidade, no período da manhã, e de saída da cidade, no período da tarde. Em outros períodos durante o dia útil, os pedágios aumentam 50%. Nos períodos da noite e madrugada e de manhã cedo, as tarifas são 25% mais baratas. Durante o fim de semana, a taxa é normal, independentemente da hora. Você pode usar uma série de if statements e else statements para expressar essa regra usando o seguinte código:

public decimal PeakTimePremiumIfElse(DateTime timeOfToll, bool inbound)
{
    if ((timeOfToll.DayOfWeek == DayOfWeek.Saturday) ||
        (timeOfToll.DayOfWeek == DayOfWeek.Sunday))
    {
        return 1.0m;
    }
    else
    {
        int hour = timeOfToll.Hour;
        if (hour < 6)
        {
            return 0.75m;
        }
        else if (hour < 10)
        {
            if (inbound)
            {
                return 2.0m;
            }
            else
            {
                return 1.0m;
            }
        }
        else if (hour < 16)
        {
            return 1.5m;
        }
        else if (hour < 20)
        {
            if (inbound)
            {
                return 1.0m;
            }
            else
            {
                return 2.0m;
            }
        }
        else // Overnight
        {
            return 0.75m;
        }
    }
}

O código anterior funciona corretamente, mas não é legível. Você precisa percorrer todos os casos de entrada e as instruções aninhadas if para raciocinar sobre o código. Em vez disso, você usará a correspondência de padrões para esse recurso, mas poderá integrá-lo a outras técnicas. Você pode criar uma única expressão de correspondência de padrão que seja responsável por todas as combinações de direção, dia da semana e hora. O resultado seria uma expressão complicada. Seria difícil de ler e entender. O que dificulta garantir a exatidão. Em vez disso, combine esses métodos para criar uma tupla de valores que descreve de forma concisa todos os estados. Em seguida, use a correspondência de padrões para calcular um multiplicador para o pedágio. A tupla contém três condições distintas:

  • O dia é um dia da semana ou do fim de semana.
  • A faixa de tempo durante a qual o pedágio é coletado.
  • O sentido é para a cidade ou para fora da cidade.

A tabela a seguir mostra as combinações de valores de entrada e multiplicador de preços para os horários de pico:

Dia Time Direction Premium
Dia de Semana horário de pico da manhã de entrada x 2,00
Dia de Semana horário de pico da manhã Saída x 1,00
Dia de Semana período diurno de entrada x 1,50
Dia de Semana período diurno de saída x 1,50
Dia de Semana horário de pico do fim da tarde de entrada x 1,00
Dia de Semana horário de pico do fim da tarde De saída x 2,00
Dia de Semana durante a noite de entrada x 0,75
Dia de Semana durante a noite saída x 0,75
Fim de Semana horário de pico da manhã de entrada x 1,00
Fim de Semana horário de pico da manhã De saída x 1,00
Fim de Semana período diurno de entrada x 1,00
Fim de Semana período diurno saída x 1,00
Fim de Semana horário de pico do fim da tarde de entrada x 1,00
Fim de Semana horário de pico do fim da tarde De saída x 1,00
Fim de Semana durante a noite de entrada x 1,00
Fim de Semana durante a noite De saída x 1,00

Há 16 combinações diferentes das três variáveis. Combinando algumas das condições, você simplifica a expressão final do comutador.

O sistema que coleta os pedágios usa uma estrutura DateTime para a hora em que o pedágio foi cobrado. Construa métodos de membro que criam as variáveis da tabela anterior. A seguinte função usa como correspondência de padrões a expressão switch para expressar se um DateTime representa um fim de semana ou um dia útil:

private static bool IsWeekDay(DateTime timeOfToll) =>
    timeOfToll.DayOfWeek switch
    {
        DayOfWeek.Monday    => true,
        DayOfWeek.Tuesday   => true,
        DayOfWeek.Wednesday => true,
        DayOfWeek.Thursday  => true,
        DayOfWeek.Friday    => true,
        DayOfWeek.Saturday  => false,
        DayOfWeek.Sunday    => false
    };

Esse método está correto, mas é redundante. Para simplificar, faça conforme mostrado no código a seguir:

private static bool IsWeekDay(DateTime timeOfToll) =>
    timeOfToll.DayOfWeek switch
    {
        DayOfWeek.Saturday => false,
        DayOfWeek.Sunday => false,
        _ => true
    };

Depois, adicione uma função semelhante para categorizar o tempo nos blocos:

private enum TimeBand
{
    MorningRush,
    Daytime,
    EveningRush,
    Overnight
}

private static TimeBand GetTimeBand(DateTime timeOfToll) =>
    timeOfToll.Hour switch
    {
        < 6 or > 19 => TimeBand.Overnight,
        < 10 => TimeBand.MorningRush,
        < 16 => TimeBand.Daytime,
        _ => TimeBand.EveningRush,
    };

Adicione um enum privado para converter cada intervalo de tempo em um valor discreto. Em seguida, o GetTimeBand método usa padrões relacionais e padrões lógicos. Um padrão relacional permite testar um valor numérico usando<, >, <= ou >=. Padrões lógicos combinam outros padrões: um or padrão testa se uma expressão corresponde a um ou mais padrões, um and padrão testa se uma expressão corresponde a dois padrões distintos e um not padrão testa se uma expressão não corresponde a um padrão.

Depois de criar esses métodos, você pode usar outra switch expressão com um padrão de tupla — um padrão posicional que corresponde a cada elemento de uma tupla — para calcular o preço premium. Você pode construir uma expressão switch com todos os 16 braços:

public decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
    {
        (true, TimeBand.MorningRush, true) => 2.00m,
        (true, TimeBand.MorningRush, false) => 1.00m,
        (true, TimeBand.Daytime, true) => 1.50m,
        (true, TimeBand.Daytime, false) => 1.50m,
        (true, TimeBand.EveningRush, true) => 1.00m,
        (true, TimeBand.EveningRush, false) => 2.00m,
        (true, TimeBand.Overnight, true) => 0.75m,
        (true, TimeBand.Overnight, false) => 0.75m,
        (false, TimeBand.MorningRush, true) => 1.00m,
        (false, TimeBand.MorningRush, false) => 1.00m,
        (false, TimeBand.Daytime, true) => 1.00m,
        (false, TimeBand.Daytime, false) => 1.00m,
        (false, TimeBand.EveningRush, true) => 1.00m,
        (false, TimeBand.EveningRush, false) => 1.00m,
        (false, TimeBand.Overnight, true) => 1.00m,
        (false, TimeBand.Overnight, false) => 1.00m,
    };

O código anterior funciona, mas pode ser simplificado. Todas as oito configurações para o fim de semana têm o mesmo pedágio. É possível substituir todas as oito pela seguinte linha:

(false, _, _) => 1.0m,

Tanto o tráfego de entrada quanto o de saída têm o mesmo multiplicador durante o dia e a noite, nos dias úteis. Você pode substituir esses quatro braços do comutador pelas duas linhas a seguir:

(true, TimeBand.Overnight, _) => 0.75m,
(true, TimeBand.Daytime, _)   => 1.5m,

O código deverá ser semelhante ao seguinte após essas duas alterações:

public decimal PeakTimePremium(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
    {
        (true, TimeBand.MorningRush, true)  => 2.00m,
        (true, TimeBand.MorningRush, false) => 1.00m,
        (true, TimeBand.Daytime,     _)     => 1.50m,
        (true, TimeBand.EveningRush, true)  => 1.00m,
        (true, TimeBand.EveningRush, false) => 2.00m,
        (true, TimeBand.Overnight,   _)     => 0.75m,
        (false, _,                   _)     => 1.00m,
    };

Por fim, remova os dois horários de pico que pagam o preço regular. Depois de remover esses braços, substitua o false por um descarte (_) no braço final do comutador. Você recebe o seguinte método finalizado:

public decimal PeakTimePremium(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
    {
        (true, TimeBand.Overnight, _) => 0.75m,
        (true, TimeBand.Daytime, _) => 1.5m,
        (true, TimeBand.MorningRush, true) => 2.0m,
        (true, TimeBand.EveningRush, false) => 2.0m,
        _ => 1.0m,
    };

Este exemplo destaca uma das vantagens da correspondência de padrões: as ramificações do padrão são avaliadas na sequência correta. Se você reorganizar de forma que uma ramificação anterior trate um dos seus casos posteriores, o compilador avisará sobre o código inacessível. Essas regras de linguagem tornam as simplificações anteriores mais fáceis com a certeza de que o código não foi alterado.

A correspondência de padrões torna alguns tipos de código mais legíveis e oferece uma alternativa às técnicas orientadas a objeto quando não é possível adicionar o código às classes. A nuvem está fazendo com que os dados e a funcionalidade existam separadamente. A forma dos dados e as operações nela não são necessariamente descritas juntas. Neste tutorial, você utilizou os dados existentes de maneiras completamente diferentes de sua função original. A correspondência de padrões proporcionou a capacidade de escrever a funcionalidade que substituiu esses tipos, ainda que não tenha sido possível estendê-los.

Próximas Etapas 

Baixe o código concluído no repositório dotnet/samples do GitHub. Explore os padrões por conta própria e adicione essa técnica em suas atividades regulares de codificação. Aprender essas técnicas lhe oferece outra maneira de abordar problemas e criar novas funcionalidades.

Consulte também