Skip to content

Instantly share code, notes, and snippets.

@JEG2
Created June 3, 2013 21:50
Show Gist options
  • Star 17 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • 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.

@tomstuart
Copy link

Thanks James, this is really interesting. I agree with all of it!

Like you, I use Struct’s block syntax (or just plain assignment) wherever practical in my day-to-day code. For Understanding Computation I tried it both ways, but ultimately went with class definitions for a few reasons:

  • I wanted the material to be accessible for readers who didn’t already know Ruby, so I was trying to hand-wave as much fiddly detail as possible and avoid explaining difficult things. Admittedly there’s some sleight of hand involved with subclassing the result of Struct.new, but I don’t call it out, so hopefully newbies won’t find it distracting. I pretty much just announce “this is the syntax for declaring a Struct” and leave it at that. (I don’t even mention instance variables anywhere.) I’m taking a gamble here that it’s okay to completely ignore the detail of how class Foo < Struct.new(:bar) works without compromising one’s understanding of the subsequent material.
  • A big reason for choosing Ruby was so that I could gradually explain complex ideas by reopening classes many times to build them up incrementally. Consequently I wanted to avoid showing two different ways of defining methods: one inside a block, and one inside a class. I felt that this would provoke irrelevant (albeit interesting) questions in the mind of a curious reader — I imagined them deriving the existence of module_eval from first principles — so I went with class Foo … end consistently for the sake of reducing distractions.
  • I was conscious that there’s a principle of conservation of pain here: either you use different syntaxes for the initial declaration and later reopening of Struct classes, or you use consistent syntax but then have to deal with the parent class mismatch problem if you ever want to redefine the class with a different set of members. In my case there were only two points in the book (pp. 32 & 36) where I needed to redefine such a class from scratch, so I paid the localised price of magically producing Object.remove_const from nowhere rather than the global price of inconsistent syntax throughout the book. I’m sure there will be readers who bridle at this interruption, and perhaps I should’ve just picked a new class name rather than requiring these gymnastics, but again my gut feeling was that this weirdness was sufficiently orthogonal that the uninitiated reader could safely ignore it without feeling like they’d missed anything.

Essentially, if I’d been writing a book about Ruby, I’d have gone for blocks. In this case I was trying to keep Ruby in the background as far as possible, so I went with classes, with the intention of avoiding the introduction of more ideas than absolutely necessary (at the potential cost of the reader’s actual understanding of Ruby!). That may yet turn out to be the wrong call, and I’d love to hear other people’s opinions.

@lazyatom
Copy link

lazyatom commented Jun 3, 2013

Really interesting stuff. I hadn't considered the superclass mismatch issue -- subtle, but annoying!

However, I would say that for newbies (and that's perhaps anyone who doesn't realise that Ruby is performing internal magic to set the name of a class when you assign an anonymous Class.new instance to a constant), I think the inheritance form is clearer in terms of communicating that you're just defining a thing with certain properties that can be instantiated like any other "normal" class. But that's just my opinion :)

As I said, really interesting stuff!

@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