610 palavrasRead in English

Checagem de Tipos em Tempo de Execução com Pattern Matching no Ruby 3

, em Ruby

O Pattern Matching chegou no Ruby 2.7 para lidar com estruturas de dados de forma mais expressiva. Eu passei a usá-lo também para outra coisa: checar tipos em tempo de execução.

Comentei sobre isso no Mastodon, a galera interagiu bem, e resolvi juntar aqui o que tenho usado.

Patterning Matching is beautiful!

class Person
  def initialize(name:, age:)
    name => ::String
    age  => ::Numeric

    @name = name
    @age  = age
  end
end

@aristotelesbr@ruby.social, 12 de junho de 2023

Um aquecimento

Antes dos tipos, um exemplo que eu gosto de mostrar. O case/in compara um valor com padrões, e o | aceita mais de um padrão no mesmo ramo:

DAYS_OF_WEEK = %w[
  monday tuesday wednesday thursday friday saturday sunday
].freeze

FridayImInLove = ->(day) do
  case day
  in "monday"                then puts "I don't care if Monday's blue"
  in "tuesday" | "wednesday" then puts "Tuesday's grey and Wednesday too"
  in "thursday"              then puts "Thursday, I don't care about you"
  in "friday"                then puts "It's Friday, I'm in love"
  in "saturday"              then puts "Saturday, wait"
  in "sunday"                then puts "And Sunday always comes too late"
  end
end

DAYS_OF_WEEK.each(&FridayImInLove)

Guarde esse |. Ele vai voltar.

O padrão também pode ser uma classe. Uma classe responde ao === dizendo se o valor é uma instância dela, e é isso que o Pattern Matching usa por baixo. Daí sai a checagem de tipos.

Duas formas: => e in

Com =>, o valor precisa casar com o padrão. Se não casar, o Ruby levanta NoMatchingPatternError:

def sum(a, b)
  a => Integer
  b => Integer

  a + b
end

sum(1, 2)
# => 3

sum("1", 2)
# => "1": Integer === "1" does not return true (NoMatchingPatternError)

Com in, a pergunta vira um booleano, sem exceção:

"texto" in String
# => true

42 in String
# => false

Use o => quando um tipo errado for um bug que deve parar o programa, e o in quando você quer decidir o que fazer:

def check_string(value)
  if value in String
    "É uma string válida!"
  else
    "Tipo inválido, esperava uma string."
  end
end

Uma pegadinha: ok = value in String guarda value em ok, não true, porque a atribuição acontece antes do in. Use parênteses: ok = (value in String).

Mais de um tipo

Lembra do |? Com ele o padrão aceita qualquer um dos tipos:

def sum(a, b)
  a => Integer | Float
  b => Integer | Float

  a + b
end

sum(2, 2.5)
# => 4.5

Tipos seus

Qualquer objeto que responda ao === serve de padrão, e uma lambda responde. Então dá para criar as suas próprias regras:

PositiveNumber = ->(n) { n.is_a?(Numeric) && n.positive? }

def sum(a, b)
  a => PositiveNumber
  b => PositiveNumber

  a + b
end

sum(2, 3)
# => 5

sum(-2, 3)
# => -2: #<Proc:0x... (lambda)> === -2 does not return true
#    (NoMatchingPatternError)

Isso vale para o retorno também. Às vezes é ali que mora a garantia que importa:

def sum(a, b)
  a => Integer | Float
  b => Integer | Float

  result = a + b
  result => PositiveNumber

  result
end

sum(-1, 3)
# => 2

sum(-3, 2)
# => -1: #<Proc:0x... (lambda)> === -1 does not return true
#    (NoMatchingPatternError)

Quando não usar

Não é para isso que o Pattern Matching foi criado, e não é de graça. Para uma checagem simples, value.is_a?(String) é mais rápido e todo mundo entende. Para validar dados em aplicações críticas, existem gems feitas para isso.

Onde eu acho que vale: na fronteira do código, onde um tipo errado causaria um erro confuso mais adiante. Uma linha como a => Integer | Float documenta e garante ao mesmo tempo.

Referências