Skip to content

Embed URL

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
The craziest ruby bug I've ever seen. I don't understand it. At all.

I've discovered a crazy bug that's really confusing me. I'm curious to hear if anyone can explain it.

Here's some code in foo.rb:

class Superclass
  unless ENV['NORMAL_METHOD_DEF']
    define_method :regex do
      /^(\d)$/
    end
  else
    def regex
      /^(\d)$/
    end
  end
end

class Subclass < Superclass
  unless ENV['FORCE_NIL_BLOCK']
    def self.override(name)
      define_method(name) { super() }
    end
 else
    def self.override(name)
      define_method(name) { super(&nil) }
    end
 end

  unless ENV['DONT_PASS_BLOCK']
    override(:regex) { }
  else
    override(:regex)
  end
end

puts "Subclass.new.regex returns a regular expression object:"
puts Subclass.new.regex.inspect
puts
puts "String#match(regex) returns a MatchData object:"
puts "8".match(/^(\d)$/).inspect
puts
puts "But somehow, when I combine these, I can get nil:"
puts "8".match(Subclass.new.regex).inspect
puts
puts "Unless I add a tap block that does nothing:"
puts "8".match(Subclass.new.regex.tap { |s| }).inspect

And the results of running it:

➜ ruby --version
ruby 1.9.3p327 (2012-11-10 revision 37606) [x86_64-darwin11.4.0]
➜ ruby foo.rb 
Subclass.new.regex returns a regular expression object:
/^(\d)$/

String#match(regex) returns a MatchData object:
#<MatchData "8" 1:"8">

But somehow, when I combine these, I can get nil:
nil

Unless I add a tap block that does nothing:
#<MatchData "8" 1:"8">

For some odd reason, String#match(regex) is returning nil when it should be returning a MatchData object. Adding a no-op tap block fixes this, but I have no idea why.

It seems to be related to the fact that Superclass#regex was defined using define_method rather than def; note what happens when I run it with NORMAL_METHOD_DEF=1:

➜ NORMAL_METHOD_DEF=1 ruby foo.rb 
Subclass.new.regex returns a regular expression object:
/^(\d)$/

String#match(regex) returns a MatchData object:
#<MatchData "8" 1:"8">

But somehow, when I combine these, I can get nil:
#<MatchData "8" 1:"8">

Unless I add a tap block that does nothing:
#<MatchData "8" 1:"8">

Also, the fact that I'm passing a block to override(:regex) seems to matter, too; if I run it with DONT_PASS_BLOCK=1 to avoid that, it fixes the problem again:

➜ DONT_PASS_BLOCK=1 ruby foo.rb
Subclass.new.regex returns a regular expression object:
/^(\d)$/

String#match(regex) returns a MatchData object:
#<MatchData "8" 1:"8">

But somehow, when I combine these, I can get nil:
#<MatchData "8" 1:"8">

Unless I add a tap block that does nothing:
#<MatchData "8" 1:"8">

It appears that I can work around the bug by explicitly passing no block in the call to super using &nil:

➜ FORCE_NIL_BLOCK=1 ruby foo.rb
Subclass.new.regex returns a regular expression object:
/^(\d)$/

String#match(regex) returns a MatchData object:
#<MatchData "8" 1:"8">

But somehow, when I combine these, I can get nil:
#<MatchData "8" 1:"8">

Unless I add a tap block that does nothing:
#<MatchData "8" 1:"8">

I don't understand this. At all. I'm not passing a block to Subclass#regex.

Can anyone explain this?

@peterc

Just to make it more fun, Ruby 2.0.0 does not have the same problem! :) (But Ruby 1.9.3 even at p392 does.)

@peterc

The behavior of define_method changed a bit in Ruby 2.0.0 as it can now accept unbound methods as an argument, so I wonder if some refactoring took place there that resolved a bug (or introduced a new one). I'm having a little dig.

@myronmarston
Owner

The behavior of define_method changed a bit in Ruby 2.0.0 as it can now accept unbound methods as an argument

I don't follow -- can you give an example showing what this means?

BTW, if you want the background on this, it started with this stackoverflow question which we took to this issue discussion.

@peterc

A slightly contrived example - it's sometimes called "method transplanting".

module Foo
  def add
    a + b
  end
end

class Bar < Struct.new(:a, :b)
  define_method :add, Foo.instance_method(:add)
end

b = Bar.new(10, 20)
p b.add  # => 30 in 2.0.0, TypeError in 1.9.3`

It's not really relevant to your problem (at least, I don't think it is) but you did ask ;-)

@myronmarston
Owner

That's cool -- I've tried that before, and was sad that it didn't work.

@peterc

I've tried a few things to pin this down. Firstly, I simplified the example as far as possible to this:

class Superclass
  define_method(:regex) { /^(\d)$/ }
end

class Subclass < Superclass
  def self.override(name)
    define_method(name) { super() }
  end

  override(:regex) { }
end

puts RUBY_VERSION
p a = "8".match(Subclass.new.regex)
p b = "8".match(Subclass.new.regex.tap { |s| })
p a == b

As well as your tap technique, there are two other key ways to make it work. First is:

override(:regex)

.. so WITHOUT the empty block in the call to override.

And also:

define_method(name) { p super() }

So merely adding a "p" into Subclass#override's method definition.

I decided to try using set_trace_func to debug it. Intriguingly, if you enable tracing, the problem goes away!

set_trace_func(lambda { |*a| p a })

So this is certainly an odd one and given it has changed in Ruby 2.0, it's almost certainly a bug IMHO.

@forforf

Another tidbit .. instance_eval causes the problem to go away ... for example:

class Superclass
  define_method(:regex) {  self.instance_eval{  /^(\d)$/  }  } 
end
@myronmarston
Owner

I decided to try using set_trace_func to debug it. Intriguingly, if you enable tracing, the problem goes away!

It's a heisenbug!

One other thing I just discovered; if you change the override(:regex) call to:

override(:regex) { :foo }

Then "8".match(Subclass.new.regex) returns :foo -- which doesn't make any sense to me. I'm thinking it might have to do with the fact that String#match accepts a block that affects the return value:

1.9.3-p327 :005 > "8".match(/(\d)/) { Hash.new }
 => {} 

Weird thing is, I tried changing the "8".match(Subclass.new.regex) call to a custom method that yields (to see if it can trigger the same odd behavior), and I get a LocalJumpError.

@myronmarston
Owner

I decided to file a bug against the ruby bug tracker for this:

http://bugs.ruby-lang.org/issues/8059

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Something went wrong with that request. Please try again.