Tutorial: Use a correspondência de padrões para criar algoritmos controlados por tipo e dados

Você pode escrever funcionalidades que se comportam como se tivesse estendido tipos que podem estar em outras bibliotecas. Outro uso para padrões é criar funcionalidades que seu aplicativo requer que não sejam um recurso fundamental do tipo que está sendo estendido.

Neste tutorial, irá aprender a:

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

Pré-requisitos

  • Recomendamos o Visual Studio para Windows ou Mac. Você pode baixar uma versão gratuita da página de downloads do Visual Studio. O Visual Studio inclui o SDK do .NET.
  • Você também pode usar o editor de código do Visual Studio. Você precisará instalar o SDK .NET mais recente separadamente.
  • Se preferir um editor diferente, você precisa instalar o SDK .NET mais recente.

Este tutorial pressupõe que você esteja familiarizado com C# e .NET, incluindo o Visual Studio ou a CLI do .NET.

Cenários para 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 desses dados em um único aplicativo coeso. Você e sua equipe não terão controle ou acesso para todos os tipos que representam os dados recebidos.

O design clássico orientado a objeto exigiria a criação de tipos em seu aplicativo que representam cada tipo de dados dessas várias fontes de dados. Em seguida, seu aplicativo trabalharia com esses novos tipos, criaria hierarquias de herança, criaria métodos virtuais e implementaria abstrações. Essas técnicas funcionam e, às vezes, são as melhores ferramentas. Outras vezes você pode escrever menos código. Você pode escrever um código mais claro usando técnicas que separam os dados das operações que manipulam esses dados.

Neste tutorial, você criará e explorará um aplicativo que usa dados de entrada de várias fontes externas para um único cenário. Você verá 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 está usando pedágios e preços de horário de pico para gerenciar o tráfego. Você escreve um aplicativo que calcula pedágios para um veículo com base em seu tipo. Melhorias posteriores incorporam preços baseados no número de ocupantes do veículo. Outras melhorias adicionam preços com base na hora e no dia da semana.

A partir dessa breve descrição, você pode ter esboçado rapidamente uma hierarquia de objetos para modelar esse sistema. No entanto, os seus dados provêm de várias fontes, como outros sistemas de gestão de registo de veículos. Esses sistemas fornecem classes diferentes para modelar esses dados e você não tem um único modelo de objeto que possa usar. 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; }
    }
}

Você pode baixar o código inicial do repositório dotnet/samples do GitHub. Você pode ver que as classes de veículo são de sistemas diferentes e estão em namespaces diferentes. Nenhuma classe de base comum, além da System.Object que pode ser usada.

Designs de correspondência de padrões

O cenário usado neste tutorial destaca os tipos de problemas que a correspondência de padrões é adequada para resolver:

  • Os objetos com os quais você precisa trabalhar não estão em uma hierarquia de objetos que corresponda às suas metas. 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 principal dessas classes. O pedágio pago por um veículo muda para diferentes tipos de veículos, mas o pedágio não é uma função essencial do veículo.

Quando a forma dos dados e as operações nesses dados não são descritas juntas, os recursos de correspondência de padrões em C# facilitam o trabalho.

Implementar os cálculos básicos das portagens

O cálculo mais básico das portagens baseia-se apenas no tipo de veículo:

  • A Car é $2.00.
  • A Taxi é $3.50.
  • A Bus é $5.00.
  • A DeliveryTruck é $10.00

Crie uma nova TollCalculator classe e implemente a correspondência de padrões no tipo de veículo para obter o valor 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 switch expressão (não o mesmo que uma switch instrução) que testa o padrão de declaração. Uma expressão switch começa com a variável, vehicle no código anterior, seguida pela switch palavra-chave. Em seguida, vem todos os braços de interruptor dentro de chaves encaracoladas. A switch expressão faz outros refinamentos na sintaxe que envolve a switch declaração. A case palavra-chave é omitida e o resultado de cada braço é uma expressão. Os dois últimos braços mostram um novo recurso de linguagem. O { } caso corresponde a qualquer objeto não nulo que não correspondeu a um braço anterior. Este braço captura quaisquer tipos incorretos passados para este método. O { } caso deve seguir os casos para cada tipo de veículo. Se a ordem fosse invertida, o { } processo teria precedência. Finalmente, o nullpadrão constante deteta quando null é passado para este método. O null padrão pode ser ú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 é comentado. Remova os comentários e você pode testar o que escreveu.

Você está começando a ver como os padrões podem ajudá-lo a criar algoritmos onde o código e os dados estão separados. A switch expressão testa o tipo e produz valores diferentes com base nos resultados. Isso é apenas o começo.

Adicionar preços de ocupação

A autoridade responsável pelas portagens quer incentivar os veículos a circular na capacidade máxima. Eles decidiram cobrar mais quando os veículos têm menos passageiros e incentivar veículos completos oferecendo preços mais baixos:

  • Carros e táxis sem passageiros pagam um extra de US $ 0,50.
  • Carros e táxis com dois passageiros recebem um desconto de US $ 0,50.
  • Carros e táxis com três ou mais passageiros recebem um desconto de US $ 1,00.
  • Ônibus que estão menos de 50% cheios pagam um extra de US $ 2,00.
  • Ônibus que estão mais de 90% cheios recebem um desconto de US $ 1,00.

Essas regras podem ser implementadas usando um padrão de propriedade na mesma expressão de opção. Um padrão de propriedade compara um valor de propriedade a um valor constante. O padrão de propriedade examina as propriedades do objeto depois que o tipo foi determinado. O caso único para um Car expande-se 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 Care, em seguida, verificam o Passengers valor da propriedade. Se ambos corresponderem, essa expressão será avaliada e retornada.

Você também expandiria os casos para 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 ônibus, como 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 entrega. Em vez disso, ajustam o valor da portagem com base na classe de peso dos camiões da seguinte forma:

  • Caminhões com mais de 5000 libras são cobrados um extra de US $ 5,00.
  • Caminhões leves com menos de 3000 libras recebem um desconto de US $ 2,00.

Essa regra é implementada 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 when cláusula de um braço de interruptor. Você usa a when cláusula para testar condições diferentes da igualdade em uma propriedade. Quando terminar, você terá um método muito parecido com o código a seguir:

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

Muitos desses braços de interruptor são exemplos de padrões recursivos. Por exemplo, Car { Passengers: 1} mostra um padrão constante dentro de um padrão de propriedade.

Você pode tornar esse código menos repetitivo usando opções aninhadas. O Car e Taxi ambos têm quatro braços diferentes nos exemplos anteriores. Em ambos os casos, você pode criar um padrão de declaração que alimenta um padrão constante. Esta 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, usar uma expressão recursiva significa que você não repete Taxi os Car braços e contendo os braços filho que testam o valor da propriedade. Esta técnica não é usada para os Bus braços e DeliveryTruck porque esses braços estão testando intervalos para a propriedade, não valores discretos.

Adicionar preços de pico

Para o recurso final, a autoridade de pedágio quer adicionar preços de pico sensíveis ao tempo. Durante as horas de ponta da manhã e da noite, as portagens são duplicadas. Essa regra afeta apenas o tráfego em um sentido: entrada para a cidade pela manhã e saída na hora do rush da noite. Nos demais horários da jornada de trabalho, os pedágios aumentam 50%. No final da noite e madrugada, as portagens são reduzidas em 25%. Durante o fim de semana, é a taxa normal, independentemente do horário. Você pode usar uma série de if instruções e else para expressar isso 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ê tem que encadear 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 o integrará com outras técnicas. Você poderia criar uma única expressão de correspondência de padrão que explicaria 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 difícil de entender. Isso torna difícil garantir a correção. Em vez disso, combine esses métodos para construir uma tupla de valores que descreva de forma concisa todos esses 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 discretas:

  • O dia é um dia de semana ou um fim de semana.
  • A faixa de tempo em que o pedágio é cobrado.
  • A direção é para dentro ou para fora da cidade

A tabela a seguir mostra as combinações de valores de entrada e o multiplicador de preços de pico:

Dia Hora Direção Premium
Weekday Corrida matinal entrada x 2,00
Weekday Corrida matinal saída x 1,00
Weekday diurno entrada x 1,50
Weekday diurno saída x 1,50
Weekday Corrida noturna entrada x 1,00
Weekday Corrida noturna saída x 2,00
Weekday durante a noite entrada x 0,75
Weekday durante a noite saída x 0,75
Fim de semana Corrida matinal entrada x 1,00
Fim de semana Corrida matinal saída x 1,00
Fim de semana diurno entrada x 1,00
Fim de semana diurno saída x 1,00
Fim de semana Corrida noturna entrada x 1,00
Fim de semana Corrida noturna saída x 1,00
Fim de semana durante a noite entrada x 1,00
Fim de semana durante a noite saída x 1,00

Existem 16 combinações diferentes das três variáveis. Ao combinar algumas das condições, você simplificará a expressão final do switch.

O sistema que cobra as portagens utiliza uma DateTime estrutura para o momento em que a portagem foi cobrada. Crie métodos de membro que criam as variáveis da tabela anterior. A função a seguir usa uma expressão de opção de correspondência de padrão para expressar se um DateTime representa um fim de semana ou um dia da semana:

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 é correto, mas é repetitivo. Você pode simplificá-lo, conforme mostrado no código a seguir:

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

Em seguida, 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,
    };

Você adiciona um privado enum para converter cada intervalo de tempo em um valor discreto. Em seguida, o GetTimeBand método usa padrões relacionais e padrões conjuntivosor. Um padrão relacional permite testar um valor numérico usando <, >, <=, ou >=. O or padrão testa se uma expressão corresponde a um ou mais padrões. Você também pode usar um and padrão para garantir que uma expressão corresponda a dois padrões distintos e um not padrão para testar 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 o padrão de tupla para calcular o prêmio de preço. Você pode construir uma switch expressão 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 acima funciona, mas pode ser simplificado. Todas as oito combinações para o fim de semana têm o mesmo pedágio. Você pode substituir todos os oito pela seguinte linha:

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

Tanto o tráfego de entrada como o de saída têm o mesmo multiplicador durante o dia da semana e durante a noite. Esses quatro braços de comutação podem ser substituídos pelas duas linhas seguintes:

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

O código deve se parecer com o código a seguir 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,
    };

Finalmente, você pode remover os dois horários de ponta que pagam o preço normal. Depois de remover esses braços, você pode substituir o false por um descarte (_) no braço do interruptor final. Você terá o seguinte método concluído:

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 de padrão são avaliadas em ordem. Se você reorganizá-los para que uma ramificação anterior lide com um de seus casos posteriores, o compilador avisa sobre o código inacessível. Essas regras de linguagem tornaram mais fácil fazer as simplificações anteriores com a confiança 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 objetos quando você não pode adicionar código às suas classes. A nuvem está a fazer com que os dados e a funcionalidade vivam separados. A forma dos dados e as operações neles não são necessariamente descritas em conjunto. Neste tutorial, você consumiu dados existentes de maneiras totalmente diferentes de sua função original. A correspondência de padrões lhe deu a capacidade de escrever funcionalidades que substituíam esses tipos, mesmo que você não pudesse estendê-los.

Próximos passos

Você pode baixar o código concluído do repositório dotnet/samples do GitHub. Explore padrões por conta própria e adicione essa técnica às suas atividades regulares de codificação. Aprender estas técnicas dá-lhe outra forma de abordar problemas e criar novas funcionalidades.

Consulte também