In simple words, Ruby Struct is a built-in class that provides useful functionalities and shortcuts. You can use it for both logic and tests. I will quickly go through its features, compare with other similar stuff, and show some less-known but still useful information about it.
All my notes are based on years of hands-on experience at iRonin.IT - a top software development company, where we provide custom software development and IT staff augmentation services for a wide array of technologies.
Basic usage
Employee = Struct.new(:first_name, :last_name)
employee = Employee.new("John", "Doe")
employee.first_name # => "John"
employee.last_name # => "Doe"
As you can see, it behaves like a simple Ruby class. The above code is equivalent to:
class Employee
attr_reader :first_name, :last_name
def initialize(first_name, last_name)
@first_name = first_name
@last_name = last_name
end
end
employee = Employee.new("John", "Doe")
...
What if we want to define the #full_name
name on our Employee
class? We can do it with Struct
as well:
Employee = Struct.new(:first_name, :last_name) do
def full_name
"#{first_name} #{last_name}"
end
end
employee = Employee.new("John", "Doe")
employee.full_name # => "John Doe"
When to use Struct
Struct
is often used to make code cleaner, format more structured data from a hash, or as a replacement for real-world classes in tests.
Temporary data structure - the most popular example is a geocoding response where you want to form
Address
object with attributes instead of a hash with the geocoded data.Cleaner code
Testing - as long as
Struct
responds to the same methods as the object used in tests, you can replace it if it does make sense. You can consider using it when testing dependency injection.
When not to use Struct
Avoid inheritance from Struct
when you can. I intentionally assigned Struct
to constant in the above example instead of doing this:
class Employee < Struct.new(:first_name, :last_name)
def full_name
"#{first_name} #{last_name}"
end
end
When your class inherits from Struct
you may not realize that:
Arguments are not required - if one of the passed arguments it's an object then calling a method on it will cause an error
Attributes are always public - it is far from perfect encapsulation unless you desire such behavior
Instances are equal if their attributes are equal -
Employee.new
==
Employee.new
Play with it
Access the class attributes the way you want:
person = Struct.new(:first_name).new("John")
person.first_name # => "John"
person[:first_name] # => "John"
person["first_name"] # => "John"
Use the equality operator:
Person = Struct.new(:first_name)
Person.new("John") == Person.new("John") # => true
Iterate over values or pairs:
Person = Struct.new(:first_name, :last_name)
person = Person.new("John", "Doe")
# Values
person.each do |value|
puts value
end
# >> "John"
# >> "Doe"
# Pairs
person.each_pair do |key, value|
puts "#{key}: #{value}"
end
# >> "first_name: John"
# >> "last_name: Doe"
Dig:
Address = Struct.new(:city)
Person = Struct.new(:name, :address)
address = Address.new("New York")
person = Person.new("John Doe", address)
person.dig(:address, :city) # => "New York"
Alternatives
Hash
Hash
is also considered as an alternative to Struct
. It is faster to use but has worse performance than its opponent (I will test it a little bit later in this article).
OpenStruct
OpenStruct
is slower but more flexible alternative. Using it you can assign attribute dynamically and it does not require predefined attributes. All you have to do is to pass a hash with attributes:
require 'ostruct'
employee = OpenStruct.new(first_name: "John", last_name: "Doe")
employee.first_name # => "John"
employee.age = 30
employee.age # => 30
Standard boilerplate
Although it may get annoying when you have to type it multiple times:
class Employee
def initialize(first_name, last_name)
@first_name = first_name
@last_name = last_name
end
def full_name
"#{first_name} #{last_name}"
end
end
Alternatives comparison
Name | Non existing attributes | Dynamically add attribute | Performance (lower is better) |
Struct | raises error | no | 2 |
Open Struct | returns nil | yes | 3 |
Hash | returns nil | yes | 1 |
Benchmarks
I used the following code to measure the performance of the above solutions:
Benchmark.bm 10 do |bench|
bench.report "Hash: " do
10_000_000.times do { name: "John Doe", city: "New York" } end
end
bench.report "Struct: " do
klass = Struct.new(:name, :age)
10_000_000.times do klass.new("John Doe", "New York") end
end
bench.report "Open Struct: " do
10_000_000.times do OpenStruct.new(name: "John Doe", city: "New York") end
end
end
Results:
user | system | total | real | |
Hash | 1.640251 | 0.000000 | 1.640251 | 1.641218 |
Struct | 2.238468 | 0.000000 | 2.238468 | 2.239301 |
Open Struct | 127.253080 | 0.540836 | 127.793916 | 127.904832 |