In Progress
Unit 1, Lesson 1
In Progress

Values

Working with immutable Value Objects is a great way to avoid mutation-related bugs in Ruby code. But it means putting out a little extra effort, since Ruby doesn’t provide tools for immutable Value Objects out of the box. In this episode we’ll meet a handy gem that does one thing well: make constructing Value Object classes trivially easy.

Video transcript & code

If you've been watching this show for any length of time, you know I use Ruby's Struct class a lot. Struct is great because for simple data-holder objects, it gives us a ton of functionality for free.

To use a classic example, here's a Point class with X and Y coordinate attributes.

By using Struct, we get a free constructor.

We get pretty and readable inspect strings.

We get getters and setters., get hash conversions,, and attribute-value-based comparison..

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

p = Point.new(2, 3)
# => #<struct Point x=2, y=3>

p.x                             # => 2
p.y = 5
p.y                             # => 5

p.to_h                          # => {:x=>2, :y=>5}

Point.new(2, 5) == p            # => true

We get all this and a whole lot more with just a one-line definition. If you want to see even more examples of Struct goodness, check out episode #20.

One thing you might have noticed in other episodes is that I often use structs as a convenient way to define Value Object classes.

But Struct isn't entirely ideal for this purpose. By definition, Value Objects ought to be immutable. But as we just saw when we assigned to the y attribute, struct objects are mutable.

Structs also include the Enumerable module, meaning they expose the whole massive Enumerable API. This may be more of an API than we want to commit to supporting for client code. And for a lot of value objects, Enumerable methods might not make a ton of sense.

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

p = Point.new(2, 3)

p.map{|coord| coord * 2}        # => [4, 6]
p.take(1)                       # => [2]

p.reject{|coord| coord.even?}  # => [3]

So, Struct is nice, but imperfect for value objects. Into this gap has stepped Tom Crayford, with his values gem.

Generating a new Value Object class with this gem looks a lot like generating a new Struct class.

Note that the class name is Value, not Values.

As with Struct, we get a free constructor.

As with Struct, we get reader methods.

And hash conversion.

And attribute-value-based comparison.

But what we don't get are writer methods.

require "values"
Point2 = Value.new(:x, :y)

p2 = Point2.new(2, 3)

p2.x                            # => 2
p2.y                            # => 3

p2.to_h                         # => {:x=>2, :y=>3}

Point2.new(2, 3) == p2          # => true

p2.x = 5 # ~> NoMethodError: undefined method `x=' for #<Point2 x=2, y=3>
# =>

# ~> NoMethodError
# ~> undefined method `x=' for #<Point2 x=2, y=3>
# ~>
# ~> xmptmp-in6526qwJ.rb:13:in `<main>'

We also don't get attribute enumeration methods out of the box.

p2.each do |coord| # ~> NoMethodError: undefined method `each' for #<Point2 x=2, y=3>
  puts coord
end

# ~> NoMethodError
# ~> undefined method `each' for #<Point2 x=2, y=3>
# ~>
# ~> xmptmp-in6526egW.rb:6:in `<main>'

We do get some features that structs don't have, though. values provides a keyword-based constructor in addition to the the ordered parameters constructor.

Point2.with(x: 2, y: 3)    # => #<Point2 x=2, y=3>

One common hassle with value objects is that we often want to create new objects based on old ones, but with certain properties changed.

Typically to do this we have to fill in all of the attributes of the new object, even the ones which are unchanged.

p = Point2.new(2, 3)
q = Point2.new(p.x, p.y + 1)
# => #<Point2 x=2, y=4>

But values provides a way to clone off a new copy of an object with only selected attributes changed.

p = Point2.new(2, 3)
q = p.with(y: p.y + 1)
# => #<Point2 x=2, y=4>

It's important to note that values objects are not drop-in replacements for Struct objects. As I already mentioned, values objects don't include the Enumerable interface.

And just as another example, values objects don't have a method for listing attribute names.

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

p = Point.new(2, 3)
# => #<struct Point x=2, y=3>

require "values"
Point2 = Value.new(:x, :y)

p2 = Point2.new(2, 3)

p.members                       # => [:x, :y]
p2.members                      # => NoMethodError: undefined method `members...

But as with Struct objects, it's possible to pass a block to the class generator method in order to customize the produced class.

require "values"
Point2 = Value.new(:x, :y) do
  def inspect
    "(#{x}/#{y})"
  end
end

p2 = Point2.new(2, 3)
p2                    # => (2/3)

The bottom line is that the values gem gives us a quick and easy way to define ultra-minimal Value Object classes. The generated classes have a bare minimum of features necessary for a good Value Object experience. They leave any further bells and whistles to us to add on. And I think that's a good thing. The values gem does one thing and does it well, and as such it's a useful tool to add to our inventory.

Happy hacking!

Responses