Skip to content

Instantly share code, notes, and snippets.

@JEG2
Created June 3, 2013 21:50
Show Gist options
  • Save JEG2/5701747 to your computer and use it in GitHub Desktop.
Save JEG2/5701747 to your computer and use it in GitHub Desktop.
Thinking out loud about the merits and drawbacks of two different usages of Struct…

How Should We Use Struct?

The Choice

It's common in Ruby to see some code setup a Struct like this:

class Specialized < Struct.new(:whatever)
  # ... define custom methods here...
end

Struct supports this kind of modification using a block though, so the above could be written as:

Specialized = Struct.new(:whatever) do
  # ... define custom methods here...
end

Those are the possibilities.

My Opinion

There's no right or wrong way to handle this. However, I do prefer the second form. I'm going to try to make a case for why that is.

I have two main reasons for favoring the second form.

First, I find the second style conceptually easier to clarify. To me, the first form is kind of heavyweight, concept-wise. You need to understand that Struct.new() returns a Class and that the parent class in a class definition can be an arbitrary Ruby expression.

I think it gets even tougher to understand when you eventually run into the problem of combining this style with Ruby's open classes or some autoloading mechanism. When someone comes to me and asks, "Why am I getting a parent class mismatch error?" Explaining that involves more twists. Well, you see, you didn't just customize a Struct. You built a Struct and then defined a subclass of that. Because that Struct is dynamically constructed, you get a different parent class anytime Ruby evaluates that expression, and Ruby doesn't allow the same subclass to reference different parent classes. Yuck. I don't even like describing this complexity and it's sort of reflected in the code by the anonymous Class:

=> [MyStruct,
    #<Class:0x007f8ba7389d18>,
    Struct,
    Enumerable,
    Object,
    PP::ObjectMixin,
    Kernel,
    BasicObject]

In contrast, I feel like describing the second form is about as simple as "… and the constructor can take a block for customizing what is created, say by defining new methods in it."

The second thing I prefer about the block form is a concept I've become aware of thanks to Josh Susser: code malleability. Consider that you begin with a trivial Struct:

Trivial = Struct.new(:whatever)

Now, if I later need to customize it, I can either just tack a block onto the end of the call or begin restructuring the statement. Then what if I decide to back out the customizations? Again, I can restructure or just remove the block.

In other words, the block form flows naturally into and out of the normal Struct usage. It's more malleable. This means refactoring is easier and you are more likely do it when needed.

There's another kind of related point here. Normal Struct usage has a similar form to block Struct usage. This visual similarity gives me the hint, "Oh, this is just a Struct that I am dealing with."

Again, these are just my opinions.

@davisre
Copy link

davisre commented Jun 4, 2013

I think Tom alludes to this, but just to be clear: even if Struct.new didn't take a block, it'd be almost as easy to accomplish the same thing by immediately reopening the class:

Specialized = Struct.new(:whatever)
class Specialized
  # ... define custom methods here...
end

It takes one extra line, but I got used to doing it this way because it felt funny to rely on an undocumented feature of Struct.new. Now in 2.0 it's documented.

@henrik
Copy link

henrik commented Jun 4, 2013

As I mentioned in the Parley discussion, I avoid Struct with either syntax.

It's often used as a shortcut to get an initializer and readers for your own class. But because of how Struct works, the initializer of your class will not require all arguments, and those readers will be public:

Specialized = Struct.new(:whatever) do
  # ... define custom methods here...
end

# Both of these are allowed.
x = Specialized.new
x.whatever

For me, it's a lot more common that I want to require all initializer arguments, and that I don't need all of them to be part of the public interface as readers.

In addition to that – and this is admittedly not a very pragmatic argument – I feel that inheriting from Struct signals that your class is just a glorified data structure, as opposed to a domain representation that encapsulates its data.

We tend to use our own lib instead to get around this.

@henrik
Copy link

henrik commented Jun 4, 2013

I remember reading about the superclass mismatch issue in "Facets of Ruby", but at that point I had been inheriting from Structs for a while in Rails apps and I couldn't recall actually seeing those issues. Could be selective memory, though, or maybe Rails' reloading eventually started compensating for it?

@JEG2
Copy link
Author

JEG2 commented Jun 4, 2013

I think Rails did improve how it handled such cases over time. It may still be possible to trigger it under some cases, but I'm not sure.

@rmg
Copy link

rmg commented Jun 8, 2013

@henrik I tend to use Struct only as an intermediate step while extracting a class, so the signalling that it is just a glorified data structure is quite appropriate. I like the malleability argument for the block form; I think I'll be using that from now on in my refactoring.

@rkh
Copy link

rkh commented Jun 11, 2013

I like structs as a cheap way of creating objects that I might replace with a custom implementation later (ie, use it both without subclassing and without the block). It's better than passing primitives around.

Also, given that you're worried about the superclass mismatch, shouldn't it be Something ||= Struct.new(...) { } to avoid surviving instances to have a different class?

@glv
Copy link

glv commented Jun 11, 2013

To answer @tenderlove's comment in https://twitter.com/tenderlove/status/344584310222880768, it's a shame Struct doesn't have something like this (ugly and quickly thrown together, but you get the idea):

class Struct
  def method_missing(meth, *args)
    if members.include?(meth) && args.empty?
      self[meth]
    elsif meth.to_s =~ /^(\w)=$/ && members.include($1.to_sym) && args.size == 1
      self[$1.to_sym] = args.first
    else
      super
    end
  end
end

@ahoward
Copy link

ahoward commented Jun 11, 2013

@glv

require 'map'

m = Map.struct

m.foo = :bar

p m.foo #=> :bar

https://github.com/ahoward/map/blob/master/lib/map/struct.rb#L12

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment