Skip to content

Data#==/Data#eql? and Struct#eql? return false when instances are extended in initialize #9243

@myronmarston

Description

@myronmarston

Environment

  • JRuby 10.0.2.0 (Ruby 3.4.2 compat)
  • macOS / aarch64

Reproduction

Put this in a script:

# Reproduction script for JRuby Data#== bug when instances use `extend` in `initialize`.
#
# Expected: all assertions pass (as they do on CRuby/MRI).
# Actual on JRuby 10.0.2.0: == and eql? return false for identical Data instances.

module MyModule
  def greet
    "hello"
  end
end

D = Data.define(:a, :b) do
  def initialize(a:, b:)
    extend(MyModule)
    super(a: a, b: b)
  end
end

x = D.new(a: 1, b: 2)
y = D.new(a: 1, b: 2)

puts "RUBY_ENGINE: #{RUBY_ENGINE}"
puts "RUBY_VERSION: #{RUBY_VERSION}"
puts "JRUBY_VERSION: #{defined?(JRUBY_VERSION) ? JRUBY_VERSION : "N/A"}"
puts

# These all correctly return true:
puts "x.a == y.a:                  #{x.a == y.a}"         # true
puts "x.b == y.b:                  #{x.b == y.b}"         # true
puts "x.to_h == y.to_h:            #{x.to_h == y.to_h}"   # true
puts "x.deconstruct == y.deconstruct: #{x.deconstruct == y.deconstruct}" # true
puts "x.hash == y.hash:            #{x.hash == y.hash}"   # true
puts "x.class == y.class:          #{x.class == y.class}" # true
puts

# These return false on JRuby, true on MRI:
puts "x == y:                      #{x == y}"             # BUG: false on JRuby
puts "x.eql?(y):                   #{x.eql?(y)}"          # BUG: false on JRuby
puts

# Root cause: each `extend` creates a unique singleton class, and JRuby's
# Data#== (in RubyData.java) compares getMetaClass() (singleton class) instead
# of getType() (real class). Data#hash correctly uses getType().
puts "x.singleton_class.equal?(y.singleton_class): #{x.singleton_class.equal?(y.singleton_class)}" # false

# Without extend, everything works:
E = Data.define(:a, :b)
x2 = E.new(a: 1, b: 2)
y2 = E.new(a: 1, b: 2)
puts
puts "Without extend: x2 == y2:    #{x2 == y2}" # true

# Struct has the same bug for eql? (but == works):
S = Struct.new(:a, :b) do
  def initialize(a, b)
    extend(MyModule)
    super(a, b)
  end
end

s1 = S.new(1, 2)
s2 = S.new(1, 2)
puts
puts "Struct with extend:"
puts "  s1 == s2:    #{s1 == s2}"    # true (Struct#== not affected)
puts "  s1.eql?(s2): #{s1.eql?(s2)}" # BUG: false on JRuby
puts "  s1.hash == s2.hash: #{s1.hash == s2.hash}" # true

Expected behavior (MRI)

Running the script on Ruby 4.0 produces:

RUBY_ENGINE: ruby
RUBY_VERSION: 4.0.0
JRUBY_VERSION: N/A

x.a == y.a:                  true
x.b == y.b:                  true
x.to_h == y.to_h:            true
x.deconstruct == y.deconstruct: true
x.hash == y.hash:            true
x.class == y.class:          true

x == y:                      true
x.eql?(y):                   true

x.singleton_class.equal?(y.singleton_class): false

Without extend: x2 == y2:    true

Struct with extend:
  s1 == s2:    true
  s1.eql?(s2): true
  s1.hash == s2.hash: true

Actual behavior (JRuby 10.0.2.0)

Running the script on JRuby produces:

RUBY_ENGINE: jruby
RUBY_VERSION: 3.4.2
JRUBY_VERSION: 10.0.2.0

x.a == y.a:                  true
x.b == y.b:                  true
x.to_h == y.to_h:            true
x.deconstruct == y.deconstruct: true
x.hash == y.hash:            true
x.class == y.class:          true

x == y:                      false
x.eql?(y):                   false

x.singleton_class.equal?(y.singleton_class): false

Without extend: x2 == y2:    true

Struct with extend:
  s1 == s2:    true
  s1.eql?(s2): false
  s1.hash == s2.hash: true

Struct has the same bug (partially)

Struct#eql? has the same getMetaClass() issue. Struct#== is unaffected (it apparently uses getType()).

S = Struct.new(:a, :b) do
  def initialize(a, b)
    extend(M)
    super(a, b)
  end
end

s1 = S.new(1, 2)
s2 = S.new(1, 2)

s1 == s2      # => true  (Struct#== not affected)
s1.eql?(s2)   # => false (expected: true)
s1.hash == s2.hash  # => true

The same getMetaClass()getType() fix should be applied to RubyStruct#eql? as well.

Root cause

In RubyData.java, checkDataEquality compares instances using getMetaClass():

RubyClass metaClass = otherObj.getMetaClass();
if (metaClass != selfObj.getMetaClass()) return context.fals;

When extend(M) is called on a Data instance, it creates a unique singleton class. Two instances extended with the same module get different singleton classes, so getMetaClass() returns different objects, causing == to return false.

The fix should use getType() instead of getMetaClass(), which returns the real class ignoring singleton classes. This is already what Data#hash does:

selfObj.getType().hashCode()

This inconsistency (hash uses getType(), equality uses getMetaClass()) also means two "unequal" objects can have the same hash, which violates the contract that a.eql?(b) implies a.hash == b.hash (the converse is allowed but confusing).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions