Ruby Did You Know That: #5 (NameError::message)

Posted by -

If you've ever pondered the Ruby standard exceptions, you probably realize they can be pretty readily implemented as pure Ruby. While YARV implements the basic exceptions in C, they make sure to use good proper Ruby and Object-Oriented design along the way. Enter NameError::message.

Motivations

When you call a method that doesn't exist, you get either a NameError (if you use the bareword foobar call syntax) or a NoMethodError, which inherits from NameError. However, if you look at the RDoc for NameError.new, you'll see it takes two arguments: a message and the missing name. A NoMethodError also has the arguments to the failed method call to track. A method call, however, has a little extra information: a message, a missing name, the arguments, and the receiver. If we compare a normal NoMethodError.new() message and the message of a NoMethodError created by the interpreter, you'll notice the difference in output:

  NoMethodError.new('some missing error happened', :upcase).message
  #=> "some missing error happened"
  25.upcase rescue $!.message
  #=> "undefined method `upcase' for 25:Fixnum"

Interesting. The one the interpreter created has the receiver, it's class, and the method name in the message. The one we created by hand just has the string message. If you call #inspect on our custom NoMethodError, :upcase still won't be present anywhere. So the interpreter has some formatting requirements when it creates this error.

Now, pretend you're the Ruby interpreter, and someone just tried to call 25.upcase. You've got to raise a NoMethodError, noting that:

  1. The failed call happened because upcase doesn't exist (vs. failed super(), calling a private method, etc)
  2. The "name" missing is upcase
  3. There are no arguments
  4. The receiver is 25.

If you use NoMethodError.new, it seems you'd need to format the receiver into the message yourself, but why is an interpreter defining that logic? It's not just a few lines of code; Ruby has special cases if the receiver is nil, false, and so on. The interpreter itself definitely shouldn't be responsible for all that.

Barring that, you need to find some way to provide the receiver to the exception object. One way you can do that is to provide a message object that is not a String, but is of a class implementing #to_str, the implicit String conversion protocol. That class is NameError::message.

NameError::message

This is where things start to get weird. This formatting logic is stuffed in a class nested in NameError which is assigned to the constant message. Yeah, it starts with a lowercase letter. Before you try, you can't use normal methods like const_get to access a constant starting with a lowercase letter, and the Ruby authors know that: they're trying to hide it from us. Luckily, we can still get it:

  ErrMessage = ObjectSpace.each_object(Class).find do |klass|
    klass.name == 'NameError::message'
  end

Cool, so we've got the class! Let's find out its class hierarchy, and verify that it has a #to_str method:

  ErrMessage.ancestors
  #=> [NameError::message, Data, Object, Kernel, BasicObject]
  ErrMessage.instance_method(:to_str)
  #=> #<UnboundMethod: NameError::message#to_str>

Alright, this seems solid. Data is an uncommon class in Ruby; one Data subclass is StringIO. Now, let's see if we can create one! We know it needs a message of some kind, the receiver, and a name.

  ErrMessage.new  #=> TypeError: allocator undefined for NameError::message
  ErrMessage.singleton_class.instance_methods(false)
  #=> [:!, :_load]

So using ErrMessage.new doesn't work because there's no allocator defined (a result of subclassing Data), but the ! operator is overridden on the class itself. Interesting.

  !ErrMessage
  #=> ArgumentError: wrong number of arguments(0 for 3)
  #=>     from (irb):12:in `!'

.... interesting. So we can't call .new, but there's a custom ! operator... and it takes 3 arguments. If you weren't convinced that this class is being hidden from us, show me how to provide arguments to ! without send. (Edit 5/9/2011 1:45 PM: I forgot ErrMessage.!(args). Whoops. Thanks to schmidt on RubyFlow for the correction!)

End of the Journey

We've found enough to finish this small adventure: if we use send, we can call !, and provide the right arguments:

  msg = ErrMessage.send(:!, 'method: %s receiver: %s', 25, :upcase)
  #=> #<NameError::message:0x000001029c3ff8>
  msg.to_str
  #=> "method: upcase receiver: 25:Fixnum"

Notice that it put the receiver's class into the message, as the interpreter-level error messages do. We can then provide this to a NameError:

  exc = NameError.new(msg, 'upcase')  # msg is from the previous code snippet
  exc.message
  #=> "method: upcase receiver: 25:Fixnum"

Of course, this would have all been much simpler if the Ruby developers had the luxury of breaking the NameError API or of changing the exception class raised; they could just stick a new argument for the receiver into #initialize and put the formatting logic right in NameError. But they didn't want to break all our code, so they had to do something like this.

If you're wondering why this class and method are so hidden, your guess is as good as mine. I'd wager that it's to make it really hard to mess up your interpreter by touching it. If you wonder what I mean, run:

  class << ErrMessage
    undef !
  end
  25.upcase  #=> stack level too deep (SystemStackError)

If NameError::message had been a normally-visible class, and used a normal .new/#initialize combination, it'd be just a little bit easier to end up monkey-patching your way into a bad spot, especially if you're the curious type. My opinion? Not really worth all the trouble, but it made for a fun detour.


Enjoy this article? Then feel free to:


Comments