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.