Episode #020: Struct

Upgrade to download episode video.

Episode Script

I've used Structs in passing in several episodes already, so I figured it was about time I did a proper introduction to Struct.

Here's a typical Ruby class definition for a Point class. The class has two attributes, x and y, and an initializer with positional arguments for setting the two attributes.

class Point
  attr_accessor 😡
  attr_accessor :y

  def initialize(x=nil,y=nil)
    @x = x
    @y = y
  end
end

While fairly concise by many language' standards, this definition still seems a little redundant. The tokens x and y are each referenced a total of four times. It would be nice if we could eliminate some of the duplication in this very common case.

Ruby has a tool to help, and its name is Struct. Let's create a Struct with x and y coordinates.

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

Just what exactly have we created here? Let's take a closer look.

point = Struct.new(:x, :y)
point # => #<Class:0x00000004da22e0>
point.class # => Class
point.name  # => nil

So we called .new on a class and… we got another class! And this new class has no name.

Typically when we create Structs in Ruby we immediately assign the resulting class to a constant. Let's assign our Struct to a constant called Point.

Point = point
Point # => Point
point # => Point
point.class # => Class
point.name  # => "Point"

Looking at this, you might be suspecting some slight of hand. Before we assigned the Struct to the Point constant, it had no name. But afterward, it has the name “Point” – even when we reference it through the original local variable that we assigned it to.

It turns out that Ruby has a very special rule for when an anonymous class or module is assigned to a constant for the first time. When that happens, Ruby sets the name of the class or module to the name of the constant. This only happens once:

Point = Struct.new(:x, :y)
Point                           # => Point
Location = Point
Location                        # => Point

This is the only time that assigning an object to a variable or constant causes a change to the object itself.

So now that we have a Point class generated by Struct, what can we do with it?

Well, we can instantiate Point objects:

Point = Struct.new(:x, :y)
Point.new                       # => #<struct Point x=nil, y=nil>
Point.new(23)                   # => #<struct Point x=23, y=nil>
Point.new(5,7)                  # => #<struct Point x=5, y=7>

We can also get and set the x and y attribute values.

p = Point.new(4,5)
p.x                             # => 4
p.y                             # => 5
p.x = 7
p.x                             # => 7

So we can see that so far, this one-line Struct is equivalent to the 8-line class we started out with. But Struct doesn't stop there.

We can also get and set values using Hash-like subscript syntax. Symbols and strings can be used interchangeably as keys.

p = Point.new(4,5)
p[:x]                           # => 4
p["y"]                          # => 5
p[:x] = 13                      
p.x                             # => 13

We also get the equality operator for free. Struct defines it so that instances with equal attributes are considered equal.

Point = Struct.new(:x, :y)
Point.new(5,3) == Point.new(5,3) # => true
Point.new(5,3) == Point.new(3,5) # => false

But it doesn't stop there. Unlike attributes defined with attr_accessor, structs can introspect and iterate over their attributes. We can ask a point instance for the names of its attributes with #members, iterate over the values with #each, or iterate over names and values with each_pair.

p = Point.new(3,5)
p.members                       # => [:x, :y]
p.each do |value|
  puts value
end
p.each_pair do |name, value|
  puts "#{name}: #{value}"
end
# >> 3
# >> 5
# >> x: 3
# >> y: 5

To top it off, structs include Enumerable, so we have the full complement of Enumerable methods as well.

p = Point.new(3,5)
p.max                           # => 5
p.reduce(&:+)                   # => 8

But what if we want more than just attribute-related methods? Do we have to revert to a traditional class definition?

No we don't! The Struct constructor can also take a block. Inside the block we can our own methods, just as we would in a class definition. Here's a custom #to_s method for our Point class.

Point = Struct.new(:x, :y) do
  def to_s
    "(#{x}x#{y})"
  end
end
Point.new(3,5).to_s             # => "(3x5)"

In conclusion: Struct is awesome. It's a big timesaver, and a powerful tool for defining rich data structures. If you aren't using it already, I highly recommend taking some time to play around and get familiar with Struct's capabilities.

Happy hacking!