Structs: what are they and what are they good for?

At todays developer discussion in Boston we discussed Structs, both in C and in Ruby. What are they? How should we use them. My notes follow.

Structs in C:

Structs in C are defined entirely by the memory offset of the fields that they contain. They are immutable and make for great value objects. The Objective-C APIs use them quite a bit (Point, CGRect, Origin, etc). Structs support accessing attributes using dot sytnax (rect.origin.x) but this differs from the dot syntax recently added by Objective-C. On structs you are essentially hopping through memory pointers to access values whereas Objective-C dot syntax is message passing.

** Structs in Ruby:**

Structs in Ruby are nominally supposed to represent a similar concept: a container for a value. Equality == does behave as you would expect. They differ greatly in just about every other area, however. Consider the following:

Person = Struct.new(:user) do
  def greet
    "hello #{user.name}"
  end
end
  • Ruby allows methods to be defined on structs. See the greet method.
  • Ruby allows structs to be initialized with fewer than the number of arguments the struct was declared with. For instance, you could do Person.new and Ruby wouldnā€™t complain until you tried to call greet which would greet you with a sweet NoMethodError.
  • Structs in Ruby are mutable. They have public attr accessors. Person.new(@user).user = @another_user is just fine by Ruby.

** Whatā€™s an OpenStruct? **

An open struct is essentially syntactic sugar on top of a Hash. It gives you dot syntax access to the members of the hash. Consider:

derek = OpenStruct.new(name: 'Derek')
derek.name # => 'Derek'
derek.age # => nil
derek.age = 33
derek.age # => 33

Notice that my initial hash didnā€™t include an ā€˜ageā€™ property, yet my OpenStruct didnā€™t blow up when I tried to access it. Further, I was able to set it just fine and then access it. I can add to an OpenStruct whenever I please. We played with this some in pry, which showed that derek.age is defined by OpenStruct as soon as a setter is called. Essentially, itā€™s handling method_missing for any setter methods and creating them on the fly. This is why OpenStructs (creating and adding methods to) clear Rubyā€™s method cache. Thatā€™s
probably not too important in the world of Rails, but itā€™s something to be aware of.

** What are Ruby structs good for? **

No one really wanted to speak in favor of Structs. Creating a struct is arguably more convenient than creating a class because you get initialize for free. As we discussed earlier, though, that initialize isnā€™t very picky.

Weā€™ve seen Structs for delegators and value object. Delegators may be better served with SimpleDelegator. If youā€™d prefer to be more explicit about your API, consider just writing a class. Structs arenā€™t a great fit for any sort of enforceable value object for the reasons we discussed. Consider something like the values gem for these.

We also commonly see people inherit from Struct.new. That is: class Person < Struct.new(:user). This adds an anonymous class to your inheritence tree with no advantage any of us could see. If youā€™re going to use a Struct, pass
it a block instead.

If youā€™re scripting, Struct and OpenStruct can be very handy. Use them as you see fit there, but in your application code? Just write initialize. That was our basic conclusion.

5 Likes

Iā€™ve also been fiddling around with a library for an immutable version of Struct, with a few API differences.

  • All instances are frozen
  • All arguments are required, rather than defaulting nil
    • Nicer syntax for setting defaults than overriding initialize and calling super:
    • class Point < Structural.new(:x, :y).with_defaults(x: 0, y: 0)
  • No attr_writers
  • Changes happen via a copy method, which takes a hash of arguments to change:
    • Point.new(1, 2).copy(y: 3) # => #<Point x=1, y=3>
  • No square brackets nonsense or other fluff from Struct, just the constructor/getters, copy, hash, and equality methods.

The codeā€™s a bit messy at this point, and itā€™s not quite production ready, but you can check it out here: GitHub - sgrif/structural

@seangriffin, your library is an intriguing idea for folks who want to build lightweight value objects. Hope this gets an eventual official release.

The attr_extas project is worth a thought, too: GitHub - barsoom/attr_extras: Takes some boilerplate out of Ruby with methods like attr_initialize.

Iā€™ve found Structs useful when working with return values from APIs. In two different cases when working with the BalancedPayments API I have had success letting a struct stand in for an object that should have been returned. Hereā€™s one pretty simple example:

class CreditCard < ActiveRecord::Base
  # Only owner is a company for now, but could need users to be owners 
  # like bank accounts in the future if we find a need to charge investors
  # fees via credit card
     
  belongs_to :owner, polymorphic: true

  validates :name, :balanced_uri, presence: true
  validates_uniqueness_of :owner_id, scope: :owner_type

  def get_balanced_card_details
    begin
      Balanced::Card.find(balanced_uri)
    rescue Exception
      Struct.new('Card', :error) do
        def brand; error; end
        def expiration_month; error; end
        def expiration_year; error; end
        def last_four; error; end
      end

      Struct::Card.new('Not Found')
    end
  end
end

I should probably be rescuing a specific exception there. But the nice thing about this is that my view code remains exactly the same regardless of whether or not the API returns a card or has an error. I can always call @credit_card.get_balanced_card_details.brand without having to do an inline rescue or any sort of conditional branching in the view.

If you extracted that struct to a proper class, itā€™d serve the very same purpose and leave your code simpler to read. It could handle the decision to return either the actual balance response or your struct (which sounds like a null object). Then get_balanced_card_details is a one-liner, with the switching logical all contained in a separately testable class.

1 Like

Yea, good point. I think Iā€™ll explore that extraction. Thanks for the input!