Repository navigation
Description
Set#dup and Set#clone return a plain Set for a subclass instance,
dropping the class, the instance variables and the singleton. Live on main
(452d895).
Measured against /usr/bin/ruby, same source, four builtin value types:
[["String", String, "hi"], ["Array", Array, [1,2]], ["Hash", Hash, nil], ["Set", Set, [1,2]]].each do |name, base, arg|
sub = Class.new(base)
obj = arg.nil? ? sub.new : sub.new(arg)
obj.instance_variable_set(:@tag, 7)
d = obj.dup
puts "#{name} dup.class==sub:#{d.class == sub} dup keeps @tag:#{d.instance_variable_get(:@tag).inspect}"
end rbgo main MRI
String dup.class==sub:true @tag:7 dup.class==sub:true @tag:7
Array dup.class==sub:true @tag:7 dup.class==sub:true @tag:7
Hash dup.class==sub:true @tag:7 dup.class==sub:true @tag:7
Set dup.class==sub:FALSE @tag:nil dup.class==sub:true @tag:7
clone behaves the same way. Only Set is affected, and the reason it is the
only one is the interesting part.
Why Set and nothing else
internal/vm/set.go:225 overrides the generic path:
dupFn := func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value {
n := newSet()
n.h.ReplaceWith(self(v).h)
return n
}
d("dup", dupFn)
d("clone", dupFn)newSet() is a bare *Set. A subclass instance is an *RObject whose
builtin holds the wrapped set, so returning the bare value discards the
wrapper and everything on it.
String, Array and Hash subclasses survive because they do not override
dup, and the generic path does the right thing in two halves:
dupValue(internal/vm/builtins.go:11218) copies an*RObject'sclass,
ivars,ivarOrderandstructVals— and deliberately not its
builtin;Object#dupthen sendsinitialize_dup→initialize_copyon the copy
(builtins.go:240), and that hook is what fills in the contents.
So the contents arriving through a Ruby-visible hook is the design, not an
accident: it is also what lets a user subclass override initialize_copy and
have dup respect it. Set's override skips both halves.
Suggested shape
Drop the two overrides and give Set an initialize_copy that replaces the
receiver's table from the source — which is what upstream does
(rb_obj_dup plus set_i_initialize_copy, set.c at v4.0.9). The one thing to
work out first is how the fresh *RObject acquires a builtin to replace
into, since dupValue leaves it nil; whatever String's and Hash's
initialize_copy do there is the pattern to follow.
Keep as controls: dup must stay independent of the original (adding to the
copy must not grow the source — true today on both engines) and must keep the
compare_by_identity flag (also true today, and asserted by
core/set/initialize_clone_spec.rb).
How it surfaced
While preparing the 4.0 fix for Set#^ (#799), which upstream implements as
rb_obj_dup(set) followed by toggling — so the natural rbgo translation is
n = dup. That would have fixed the compare_by_identity assertion the 4.0
spec makes and broken the subclass class that today's
self.class.new(other) preserves: one example repaired by breaking its
neighbour. Probing the neighbourhood before writing the fix is what turned this
up, and the ^ change now has to wait on it or route around it.
🤖 Generated with Claude Code
Activity