In Ruby 3.2, a new class Data
was introduced as a way to define simple immutable value objects. A value object is a type of object that represents a value in a program, such as a point in 2D space or a date. The main advantage of value objects is that they are easy to understand, simple to use, and can improve the readability and maintainability of code. The proposal to add Data
class was accepted by Matz on the Ruby forum here.
How does it work?
Using the newly defined class Data
we can create a simple immutable object. These objects are designed to be small, self-contained, and represent a single concept in your application.
The class definition includes the name of the class, as well as a list of instance variables that the object will contain. Here's an example:
class Point < Data.define(:x, :y)
end
# Can be initialized using Positional or Keyword arguments
point = Point.new(3, 4) OR Point.new(x: 3, y: 4) OR Point[3, 4] OR Point[x: 3, y: 4]
=> #<data Point x=3, y=4>
# But using both Positional and Keyword arguments together will not work
Point.new(2, y: 3)
=> `new`: wrong number of arguments (given 2, expected 0) (ArgumentError)
Once an instance of the object is created, its instance variables cannot be changed. This makes it easy to understand about the object's state and prevents bugs that can arise from unexpected changes to the object.
However, if we need to change any one instance variable by keeping the other variable same we can do that using with
method.
point2 = point.with(x: 10)
=> #<data Point x=10, y=4>
Since Data
objects are immutable, with
method creates a copy of the object to update the arguments.
Note: If the with
method is called with no arguments, the receiver is returned as-is and no new copy is created.
Data.define
also accepts an optional block that can be used to define custom methods for the immutable object. These methods can provide additional functionality to the object without compromising its immutability.
class Point < Data.define(:x, :y)
def distance_from_origin
Math.sqrt(x**2 + y**2)
end
end
point = Point.new(3, 4)
=> #<data Point x=3, y=4>
point.distance_from_origin
=> 5.0
Why not Struct?
While Struct
can also be used to define objects, there are some reasons why Data
might be a better choice in certain situations.
First, Data
provides some safety checks that Struct
does not. For example, Data
prevents an object from being created with a missing argument, while Struct
allows this. This can help to prevent bugs and improve code safety.
Here's an example:
Measure = Struct.new(:amount, :unit)
measure = Measure.new(30) # This works, but will fail with Data
=> #<struct Measure amount=30, unit=nil>
Point.new(x: 3) # Missing argument will raise error for Data
=> `initialize': missing keyword: :y (ArgumentError)
In this example, we've defined a Measure
struct that contains two instance variables: amount
and unit
. However, when we create a new instance of the object, we only provide one argument. This is allowed by Struct
, but it can lead to bugs if the code assumes that all the instance variables are present.
Second, Data
is a more explicit way of defining simple immutable objects. The Data
class makes it clear that the object is intended to be immutable, while with Struct
it's less clear.
Final Thoughts
Data
is a powerful tool for defining simple immutable objects in Ruby 3.2. While it may not be suitable for all situations, its safety, performance, and clarity benefits make it a strong choice in many cases. By understanding how Data
works and its tradeoffs compared to other techniques, such as Struct
, you can make an informed decision about when to use it in your own code.