Friday, January 25, 2008

Renum goes one-oh with a nice new syntax for an important new feature

Last week while watching Project Runway, I realized there was a cool way to provide a feature Renum was sorely lacking.

The basic enum supported by Renum like the below is nice, but not very interesting.

enum :Status, %w[ NOT_STARTED IN_PROGRESS COMPLETE ]

You get the collection of values and the sorting, which might be worthwhile for you, but there's not much to recommend it over strings or symbols. You could also put behavior in the values like this.

enum :Color, [ :RED, :GREEN, :BLUE ] do
  def abbr
    name[0..0]
  end
end

But with just the name to work with, the behavior couldn't be very interesting unless you resorted to defining singleton methods on the values, which is fine but not pretty. (It sort of defeats the purpose of Renum: to provide a terse and readable API for creating object constants.)

What would really make enum behavior interesting is if you could associate some extra data with each value. But since we want the instances created automagically, we can't just let you provide a custom constructor.

Back in the post where I first tried to create a good API for this, I experimented with a const_missing hack to create the values. Unfortunately I couldn't get const_missing to be called on the enum class that was being created: it would be called on whatever module the code was in the scope of—Object at the top level—which would have meant redefining const_missing on the receiver of the enum method (either the module we were in or the "main" object), which I wasn't willing to do.

What it took me 6 months to realize is that (thanks to the differences between constant lookup and method sending) almost the same syntax I couldn't support with const_missing can be supported with method_missing! Because Renum already has the newly created enumerated value class class_eval the associated block, it would receive the method_missing calls, so I wouldn't have to mess with the methods on any pre-existing object. Method calls would also allow providing additional arguments that could be passed along to the values so that you can define more intersting behavior.

That's probably more detail than anyone wanted. The upshot of all that is that Renum 1.0 allows you to do this:

enum :Size do
  Smaller("Really really tiny", 0.6)
  Medium("Sort of in the middle", 1)
  Larger("Quite big", 1.5)

  attr_reader :description

  def init description, factor
    @description, @factor = description, factor
  end
  
  def adjust original
    original * @factor
  end
end

For more details, see the spec.

7 comments:

jkauzlar said...

I was working on the same problem awhile back. I'll spare you the code, but the declaration syntax looks like this:

class OrderStatus < Enum
set :IGNORE, 0, 'Ignore'
set :COMPLETED, 1, 'Completed'
set :NOT_SHIPPED, 2, 'Not Shipped'
set :PENDING_PAYMENT, 3, 'Pending Payment'
set :DISPUTED, 4, 'Disputed'
set :CANCELLED, 5, 'Cancelled'
set :PROBLEM, 6, 'Unknown Problem'

init
end

[the default arguments are index and to_s value, but you can define your own init parameters].

The only thing lacking was that it doesn't establish a unique class identity for each enum value, as in Java. Does renum solve this problem? (Your syntax is far nicer, btw).

John Hume said...

Renum makes each constant an instance of the enumerated type (OrderStatus in your example). Is that what you mean?

Schulty said...

There is a problem with comparisons of enums.

irb(main):001:0> require 'renum'
=> true
irb(main):002:0> enum :Foo, [:BAR, :BURR, :BEER]
=> [:BAR, :BURR, :BEER]
irb(main):003:0> Foo::BEER> Foo::BURR
=> true
irb(main):004:0> Foo::BEER > Foo::BAR
Segmentation fault

irb(main):001:0> require 'renum'
=> true
irb(main):002:0> enum :Foo, [:BAR, :BURR, :BEER]
=> [:BAR, :BURR, :BEER]
irb(main):004:0> Foo::BEER > Foo::BAR
Segmentation fault

John Hume said...

@schulty: Wow! Thanks for pointing that out. I've just fixed that and released a 1.0.1 version of the gem.

I hope I can figure out what was actually causing it, but for now I just have a fix. (The change was to set the index of each value to an instance variable on initialize rather than calling Array#index(item) to determine it on demand.)

Anonymous said...

Great gem, mate. Thanks for the hard yards.

I'd like to add for any novices (like me) that, depending on your environment, you may need to put the following at the top of any files that require this gem:

require 'rubygems'
require 'renum'

Cheers,

McPop.

Anonymous said...

How can I redefine the to_s method for one of my enums? I'm new to Ruby and would appreciate your help.

John Hume said...

Like any other method you can def to_s inside the block (and from that implementation call super to get the fully qualified name of the constant).

Note however that overriding to_s will break your enumeration vis-a-vis constantize_attribute (which can be used to persist Renum enum values as ActiveRecord attributes).