Skip to content

Set#dup and Set#clone drop the subclass, its ivars and its singleton #866

Open

Description

@tannevaled

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's class,
    ivars, ivarOrder and structVals — and deliberately not its
    builtin;
  • Object#dup then sends initialize_dup → initialize_copy on 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions