Structs: Introduction and Custom Behaviour
A Struct is a simple way of creating a container than holds a specific set of data, without having to create an explicit class for it.
Creating them is done in the same way as creating a new Class object; by calling the '.new' method on the Class. In the case of Struct, this would be Struct.new().
There are two ways that we can create a Struct object. For the purposes of this post, we will be using a Struct called Point, which will hold an x and y value which refers to a pixel location on the screen.
#Way #1:
Struct.new("Point", :x, :y)
#Way #2:
Point = Struct.new(:x, :y)
Now, Structs are a little different to normal classes in that they don't return instances of the Struct class. Instead, they return a new Class object whose name is based on the name given. In Way #1, the identifier (the string) that was given as the first argument is used as the Class name. In Way #2, it is the assigned to the variable on the left hand side of the '='. Do note that using variables to store the Class object will result in that object being bound by scope.
It is important to note that using Way #1 requires that the identifier is a Constant (it must begin with a capital letter). Struct.new("point", :attr) will not compile. This is because the identifier is added as a constant value in the Struct class itself. For this reason, any Constant name given must also be unique, which will severely limit the choice of names should you use a lot of Structs this way.
The symbols (and they must be symbols) that are provided as arguments to Struct are the names of the attributes that we want our data structure to contain. They are created as accessors, and thus can be both written to and read from.
With our Struct Class objects defined, we can now make instances of these Classes.
Depending on which way we have defined them, we will have to change how we are to create these instances:
#Way 1:
p1 = Struct::Point.new(0, 0) #p1 is a Point in the top left corner at x = 0, y = 0
#Way 2:
p2 = Point.new(50, 50) #p2 is a Point in at x = 50, y = 50)
The only difference between these two methods is that in Way #1 we have to go through the Struct Class to get to our defined Point Class object.
In the two examples above, we have created our two Points in two different locations by providing two arguments. If you recall, our first defined attribute was :x and the second was :y. When we create our objects in the above manner, the attributes are assigned the values in the same order that they were defined.
Note: For the remainder of the post we will assume that we have used Way #2.
Now, when we create an instace of our Struct Class object, Point, we don't have to assign to all of the attributes we defined at once. All attributes are optional, and our passed in arguments will be assigned from left to right, as is standard in Ruby when passing in arguments.
p1 = Point.new(5)
puts p1 # #<struct Point :x = 5, :y = nil>
p2 = Point.new(5, 10)
puts p2 # #<struct Point :x = 5, :y = 10>
This does mean that for a Struct object which contains many attributes, we will need to assign all of the preceding attributes or at least provide some argument to them. But because our attributes are created as accessors, we can access individual attributes directly.
p1 = Point.new # an instance where all attributes are nil
p1.x = 10
puts p1 # #<struct Point :x = 10, :y = nil>
p2 = Point.new
p2.y = 15
puts p2 # #<struct Point :x = nil, :y = 15>
What is interesting about Structs, outside of their simple implementation of a data class object, is the methods that are inherent to the Struct class and through that are accessible by our Class objects that we create.
The Struct class provides us with a variety of methods that can be found in both Arrays and Hashes. Namely, the following:
Because of these, we can use Structs in a similar way to Arrays and Hashes.
For example, we can access and assign our attributes like we would an Array or Hash with the []= method:
p1 = Point.new
#accessing :x as an element in an Array
p1[0] = 5 # #<struct Point :x = 5, :y = nil>
#accessing :x as a key in a Hash
p1[:y] = 10 # #<struct Point :x = 5, :y = 10>
What is interesting is that whilst we define our attributes using symbols, we can access that attribute with a string format:
p1["x"] # 5
p1["y"] # 10
We can also write out the members ('keys' in Hashes), or the values that correspoind to those members.
p p1.members # [:x, :y]
p p1.values # [10, 15]
We can also iterate through each pair of members and values in the same way we would with Hashes:
p1.each_pair { |m, v| p [m, v] }
# [:x, 10]
# [:y, 15]
With this additional functionality already built it, Structs make for a good alternative to an explicit class definition when it comes to containing data. They also come with some of the usefulness of a Hash with the addition of being able to hardcode attribute names unlike with Hash objects, which can result in less errors in your program.
But wait, there is still another very interesting aspect to Structs that differs from Hashes, and that is the ability to define additional methods within a particular Struct Class object that we have created.
Let's say that we want a way to determine the distance between two given points. Because we might know that we want this functionality ahead of creating our objects, we may decide to write an expicit class that implements a method for this purpose. But that's all that we want from our class. We could easily have a class definition that looks like this:
class Point
attr_accessor :x, :y
def distance_to(dest_point)
Math.sqrt((self.x - dest_point.x) ** 2 + (self.y - dest_point.y) ** 2)
end
end
This would allow use to take a Point object and find the distance_to another specified Point object. But by defining our own class, we lose all of that free functionality that comes with Struct.
One possibility is to inherit from a Struct Class object in a custom class:
class Point < Struct.new(:x, :y)
def distance_to(dest_point)
Math.sqrt((self.x - dest_point.x) ** 2 + (self.y - dest_point.y) ** 2)
end
end
But doing something like is can be a potential waste of resources that offers very little in terms of advantages.
What we would like is a way to add that one method but still have a Struct without having to resort to the above definition. Luckily, Structs can do that with little problem. Though this is undocumented, Structs are capable of taking a block of code that will be used and added to the Class that is subsequently created.
Point = Struct.new(:x, :y) {
def distance_to(dest_point)
Math.sqrt((self.x - dest_point.x) ** 2 + (self.y - dest_point.y) ** 2)
end
}
You'll note that I use curly braces for my blocks, but you can also use do...end which for some is more preferable. There is actually some story behind whether to use { } or do...end, and that is something I will touch on in a later post.
Back on track, we can now call the method distance_to from a Point instance:
p1 = Point.new(0, 0)
p2 = Point.new(10, 0)
p p1.distance_to(p2) # 10.0 (which is what we would expect)
And because this is still based on a Struct object, we can continue to use methods like #members and #each_pair without having to implement our own versions in a custom defined class.