611 palavras

Data: o Struct imutável do Ruby

, em Ruby

O Ruby 3.2 saiu no Natal de 2022 com uma classe nova: Data. Ela serve para criar objetos de valor, aqueles que existem só para carregar dados: um ponto, um valor em dinheiro, o resultado de uma consulta. O que a diferencia é que eles não mudam depois de criados.

Point = Data.define(:x, :y)

Point.new(1, 2)
# => #<data Point x=1, y=2>

Point.new(x: 1, y: 2)
# => #<data Point x=1, y=2>

Ela aceita argumentos posicionais ou por nome.

Por que não um Struct?

O Struct faz algo parecido há anos, mas é mais permissivo do que deveria ser para um objeto de valor:

PointStruct = Struct.new(:x, :y)

PointStruct.new(1)
# => #<struct PointStruct x=1, y=nil>

Point.new(x: 1)
# => ArgumentError: missing keyword: :y

O Struct aceita um campo faltando e preenche com nil. O Data exige todos. Ele também não tem setters e não é um Enumerable: não tem each, nem to_a, nem acesso por índice. Sobra só o que um objeto de valor precisa ter.

point = Point.new(1, 2)

point.x = 10
# => NoMethodError: undefined method 'x=' for an instance of Point

point.frozen?
# => true

Igualdade por valor

Dois Data com os mesmos valores são iguais, o que faz deles boas chaves de Hash:

Point.new(1, 2) == Point.new(1, 2)
# => true

visited = { Point.new(1, 2) => "casa" }
visited[Point.new(1, 2)]
# => "casa"

“Alterando” com with

Para mudar um campo, você cria uma cópia. O with faz isso e deixa o original intacto:

moved = point.with(x: 10)
# => #<data Point x=10, y=2>

point
# => #<data Point x=1, y=2>

Comportamento e validação

O define aceita um bloco, onde entram métodos e um initialize próprio. Ele recebe sempre argumentos por nome, mesmo quando quem cria o objeto passa posicionais, e é o lugar para valores padrão e validação:

Money = Data.define(:amount, :currency) do
  def initialize(amount:, currency: "BRL")
    raise ArgumentError, "amount must be >= 0" if amount.negative?
    super
  end

  def +(other)
    raise ArgumentError, "currency mismatch" unless currency == other.currency
    with(amount: amount + other.amount)
  end

  def to_s = format("%s %.2f", currency, amount)
end

(Money.new(10) + Money.new(5.5)).to_s
# => "BRL 15.50"

Money.new(-1)
# => ArgumentError: amount must be >= 0

O + devolve um Money novo, construído com with. Nenhum dos dois valores somados muda.

Um cuidado se você ainda usa o Ruby 3.2: nessa versão, o with não passa pelo initialize, então Money.new(10).with(amount: -5) cria um valor negativo sem reclamar. Do 3.3 em diante, a validação roda também no with.

A imutabilidade é rasa

O Data congela o próprio objeto, não o que está dentro dele:

Order = Data.define(:id, :items)

order = Order.new(1, ["café"])
order.items << "pão"

order
# => #<data Order id=1, items=["café", "pão"]>

Se um campo guarda um Array ou um Hash, congele no initialize:

Order = Data.define(:id, :items) do
  def initialize(id:, items:) = super(id:, items: items.dup.freeze)
end

Order.new(1, ["café"]).items << "pão"
# => FrozenError: can't modify frozen Array: ["café"]

Combina com Pattern Matching

O Data já sabe se desmontar para o case/in, por nome ou pela classe:

def describe(point)
  case point
  in { x: 0, y: 0 } then "origem"
  in { x: 0 } then "no eixo y"
  in Point(x:, y:) if x == y then "na diagonal"
  else "em algum lugar"
  end
end

describe(Point.new(3, 3))
# => "na diagonal"

Escrevi mais sobre Pattern Matching em checagem de tipos com Pattern Matching.

Quando eu uso

Sempre que um objeto só carrega valores e não tem identidade própria: coordenadas, dinheiro, intervalos de datas, o retorno de um serviço. Se o objeto precisa mudar ao longo do tempo ou ser salvo no banco, ele não é um Data.

Referências