Search code examples
ruby-on-railsrubyopenstructruby-2.3

OpenStruct issue with Ruby 2.3.1


In Ruby 2.1.5 and 2.2.4, creating a new Collector returns the correct result.

require 'ostruct'
module ResourceResponses
  class Collector < OpenStruct
    def initialize
      super
      @table = Hash.new {|h,k| h[k] = Response.new }
    end
  end

  class Response
    attr_reader :publish_formats, :publish_block, :blocks, :block_order
    def initialize
      @publish_formats = []
      @blocks = {}
      @block_order = []
    end
  end  
end

 > Collector.new
 => #<ResourceResponses::Collector>
 Collector.new.responses
 => #<ResourceResponses::Response:0x007fb3f409ae98 @block_order=[], @blocks=  {}, @publish_formats=[]>

When I upgrade to Ruby 2.3.1, it starts returning back nil instead.

> Collector.new
=> #<ResourceResponses::Collector>
> Collector.new.responses
=> nil

I've done a lot of reading around how OpenStruct is now 10x faster in 2.3 but I'm not seeing what change was made that would break the relationship between Collector and Response. Any help is very appreciated. Rails is at version 4.2.7.1.


Solution

  • Let's have a look at the implementation of method_missing in the current implementation:

    def method_missing(mid, *args) # :nodoc:
      len = args.length
      if mname = mid[/.*(?==\z)/m]
        if len != 1
          raise ArgumentError, "wrong number of arguments (#{len} for 1)", caller(1)
        end
        modifiable?[new_ostruct_member!(mname)] = args[0]
      elsif len == 0
        if @table.key?(mid)
          new_ostruct_member!(mid) unless frozen?
          @table[mid]
        end
      else
        err = NoMethodError.new "undefined method `#{mid}' for #{self}", mid, args
        err.set_backtrace caller(1)
        raise err
      end
    end
    

    The interesting part is the block in the middle that runs when the method name didn't end with an = and when there are no addition arguments:

    if @table.key?(mid)
      new_ostruct_member!(mid) unless frozen?
      @table[mid]
    end
    

    As you can see the implementation first checks if the key exists, before actually reading the value.

    This breaks your implementation with the hash that returns a new Response.new when a key/value is not set. Because just calling key? doesn't trigger the setting of the default value:

    hash = Hash.new { |h,k| h[k] = :bar }
    hash.has_key?(:foo)
    #=> false
    hash
    #=> {}
    hash[:foo]
    #=> :bar
    hash
    #=> { :foo => :bar }
    

    Ruby 2.2 didn't have this optimization. It just returned @table[mid] without checking @table.key? first.