Pattern matching no C# 8.0

A partir do C# 7.0 a linguagem começou a receber funcionalidades que fazem uso de um recurso de programação funcional chamado pattern matching. A maioria desses recursos é derivada do F# e a introdução deles no C# visa facilitar a vida do desenvolvedor e solução de certos tipos de problemas.

São recursos muito poderosos e que simplificam em muito alguns tipos de construções.

Como os recursos de pattern matching do C# 8.0 são extensões daqueles instroduzidos no C# 7.0, vou apresentá-los na mesma ordem para simplificar o entendimento.

Patterns introduzidos no C# 7.0

No C# 7.0 o operador is e a declaração switch foram melhoradas com a adição de alguns novos padrões: const pattern, type pattern e var pattern.

Const pattern

Vejamos a seguinte função, que recebe um objeto o como argumento:

public void Is(object o)
{
    if (o is null) Console.WriteLine("o é null!");
    // ...
}

Antes do C# 7.0, teríamos que escrever essa verificação usando o operador ==, mas agora podemos fazer a mesma validação com o operador is.

Este padrão também vale para inteiros ou outros valores constantes:

if (o is 42) Console.WriteLine("A resposta é 42!");

Type pattern

O type pattern pode ser um pouco mais útil. Com ele podemos extrair uma variável ao verificar o tipo, tanto com o operador is quanto com o switch.

Este padrão sempre tem sucesso quando o tipo aferido é o mesmo da variável.

public void Is(object o)
{
    if (o is Pessoa pessoa)
        Console.WriteLine($"O nome é {pessoa.Nome}");
    // ...
}

Com o tipo identificado, também é possível usá-lo na mesma expressão para filtrar ainda mais o objeto:

if (o is Pessoa p && p.Idade < 18)
    Console.WriteLine($"{p.Nome} é menor de idade.");

Var pattern

O var pattern sempre é avaliado com sucesso, pois toda variável tem um tipo.

Ele é bastante útil para obter uma variável já convertida para o seu tipo verdadeiro.

public void Is(object o)
{
    if (o is var x) {
        var nomeTipo = x?.GetType()?.Name;
        Console.WriteLine($"O tipo de 'x' é {nomeTipo}");
    }
    // ...
}

Então, se passarmos uma variável do tipo Pessoa para a variável x no exemplo acima, a saída será O tipo de 'x' é Pessoa.

Usando a declaração switch com Pattern Matching

Todos os três padrões acima são utilizáveis numa declaração do tipo switch:

switch(o)
{
    case Humano humano: 
        Console.WriteLine($"Nome do humano: {humano.Nome}");
        break;
    case AnimalSelvagem animal:
        Console.WriteLine($"Nome do animal: {animal.Nome}");
        break;
    case int resposta:
        Console.WriteLine($"A resposta é {resposta}");
        break;
    case var x:
        Console.WriteLine($"x não é incógnita! É do tipo {x.GetType().Name}!");
        break;
}

Como o var pattern sempre dá match, pode até ser usado como default.

Recursos introduzidos no C# 8.0

Já no C# 8.0, foi introduzido um pattern matching bem mais poderoso, mais parecido com o encontrado em linguagens como F# e Scala.

Antes de ver um exemplo completo, é interessante fazermos uma rápida revisão de tuplas tal como são usadas a partir do C# 7.0.

Uma tupla pode ser declarada assim:

var tupla = (1, true); // tupla do tipo (int, bool)

E uma tupla pode ser facilmente decomposta desta forma:

(var entrada, var saida = (1, true);
// ou
(int entrada, bool saida = (1, true);

O primeiro valor da tupla é atribuído à primeira variável, e o segundo valor é atribuído à segunda variável.

Outro aspecto importante, é a possibilidade de utilizar o operador de descarte _.

(var a, _) = (x, y);

Neste caso, comunicamos ao compilador que não estamos interessados no segundo valor da tupla, e que ele pode ser descartado.

Full pattern matching!

No C# 8.0, é possível escrever uma declaração switch em que cada caso é uma expressão que será avaliada em tempo de execução.

Vejamos um exemplo para selecionar uma classe CSS com base no valor de uma variável do tipo bool?:

bool? visivel = false;
var classeCss = visivel switch
{
  true => "Visible",
  false => "Hidden",
  null => "Blink"
};

Neste exemplo, ao rodar este código, cada uma das expressões será avaliada, na ordem em que foram declaradas. A primeira que for verdadeira (der match) será executada.

Há dois pontos interessantes para notar: o primeiro é que neste caso a expressão switch possui um retorno que pode ser atribuído a uma variável ou retornado. O segundo ponto é que ela está usando o const pattern que vimos mais acima.

A entrada de uma expressão swtich com pattern matching pode ser uma tupla, e cada caso pode ser decomposto. Este padrão é chamado de tuple pattern. Vamos a um exemplo mais complexo:

int a = A(); // valor entre 1 e 3
int b = B(); // valor entre 1 e 3
bool c = HabilitarAvaliacao();
bool resultado = (a, b, c) switch
{
    (1, 1, false) => Ok(),
    (1, 2, false) => Ok(),
    (1, 3, false) => Ok(),
    (2, 1, false) => Ok(),
    (2, 2, false) => Ok(),
    (2, 3, false) => Ok(),
    (3, 1, false) => Ok(),
    (3, 2, false) => Ok(),
    (3, 3, false) => Ok(),    
    (1, 3, true) => Ok(),
    (2, 3, true) => Ok(),
    (3, 3, true) => Ok(),
    (_, _, _) => Nok()
};

Neste exemplo, estamos tratando várias combinações de variáveis que resultam numa avaliação Ok(), e em outros casos resultam num Nok();

Este código pode ser fácil de ler e entender, mas pode ser simplificado com a introdução de outro recurso, as guardas. Vejamos um exemplo:

var resultado = (1, 2) switch
{
    (var a, var b) when a > 10 => a * b,
    (var a, var b) when b < 1 => a + b,
    (_, _) => return a - b 
};

Neste caso, apesar de termos dois casos aparentemente idênticos (var a, var b), o primeiro só será executado quando a for maior que 10. Caso não for, o segundo poderá ser executado quando b for menor que 1.

Assim, o exemplo anterior poderia ser reescrito, tomando vantagem das guardas:

int a = A(); // valor entre 1 e 3
int b = B(); // valor entre 1 e 3
bool c = HabilitarAvaliacao();
bool resultado = (a, b, c) switch
{
    (_, _, false) => Ok(),
    (_, 3, true) => Ok(),
    (_, _, _) => Nok()
};

Vamos a um exemplo mais prático, em que fazemos um tratamento para checar a saúde de um serviço, e avaliação toma dois argumentos: 1) o sucesso na checagem e 2) o tempo de resposta do serviço:

var healthCheckResult = (sucesso, tempoRespostaMilissegundos) switch
{
    (true, var ms) when ms <= 5000 => HealthCheckResult.Healthy(),
    (true, _) => HealthCheckResult.Degraded(),
    (false, _) => HealthCheckResult.Unhealthy(),
};

No cenário acima, temos um uso interessante da guarda: o serviço só será considerado saudável quando o tempo de resposta for de até 5 segundos.

Esta é uma forma bastante compacta e concisa de representar vários cenários num código expressivo e fácil de ler, e é particularmente útil para reescrever cadeias complexas ou confusas de ifs.

Implementando máquinas de estado

Esta abordagem também é bastante útil para descrever máquinas de estado.

Por exemplo, uma máquina de estado para um portão eletrônico poderia ser algo mais ou menos assim:

var newState = (currentState, action, key.IsValid) switch {
    (GateState.Locked, GateAction.Open, true) => GateState.Opened,
    (GateState.Opened, GateAction.Open, _) => throw new InvalidOperationException("Não se pode abrir um portão aberto"),
    (GateState.Opened, GateAction.Lock, true) => GateState.Locked,
    (GateState.Locked, GateAction.Open, false) => GateState.Locked,
    (GateState.Closed, GateAction.Lock, true) => GateState.Locked,
    (GateState.Closed, GateAction.Close, _) => throw new InvalidOperationException("Não se pode fechar um portão fechado"),
    _ => currentState
};

Desconstrução de tipos

Um último recurso que eu gostaria de mostrar aqui é a desconstrução de tipos, ou seja, é um structure pattern.

Para exemplificar, veja esses dois structs que representam respectivamente pontos em sistemas de coordenadas 2D e 3D:

public struct Point2D
{
    public float X { get; }
    public float Y { get; }

    public Point2D(float x, float y)
    {
        X = x;
        Y = y;
    }
}

public struct Point3D
{
    public float X { get; }
    public float Y { get; }
    public float Z { get; }

    public Point3D(float x, float y, float z)
    {
        X = x;
        Y = y;
        Z = z;
    }
}

Ambos possuem as coordenadas X e Y, mas somente o Point3D possui a coordenada Z. Assim, podemos usar pattern matching para avaliar a estrutura dos objetos e reagir de acordo:

object ponto = ObterPonto(); 
var estaNaOrigem = ponto switch
{
    Point3D {X: 0, Y: 0, Z: 0} => true,
    Point2D {X: 0, Y: 0} => true,
    null => false
};

E se o objeto for de um tipo conhecido, o tipo não precisa nem ser citado:

var ponto = new Point3D(x: 0, y: 2, z: 3); 
var planoAlinhado = ponto switch
{
    {X: 0, Z: 0} => Plano.XZ,
    {X: 0, Y: 0} => Plano.XY,
    {Y: 0, Z: 0} => Plano.YZ,
    _ => Plano.Nenhum
};

Por hoje é isso. Se você conhece outras formas interessantes de aplicar pattern matching em C# ou outra linguagem de programação que você goste, deixe um comentário.

Outras opções para compartilhar:
comments powered by Disqus