Skip to content

Set cannot be subclassed: Set.new and Set.[] ignore the receiver, so every subclass instance is a plain Set #903

Closed

Description

@tannevaled

Set cannot be subclassed. Every instance of a subclass is a plain Set.

require "set"
class Sub < Set; end

Sub[1, 2].class          # MRI 4.0.7: Sub    rbgo: Set
Sub.new([1, 2]).class    # MRI 4.0.7: Sub    rbgo: Set

Measured with rubydiff against /opt/homebrew/opt/ruby@4.0/bin/ruby (ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin25]) on main at 6e86fac3.

Cause

Both constructors are smethods on Set that ignore the receiver — the _ object.Value in each signature — and allocate with newSet():

// internal/vm/set.go:144
vm.cSet.smethods["new"] = &Method{name: "new", owner: vm.cSet,
    native: func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value {
        s := newSet()
        vm.send(s, "initialize", args, blk)
        return s
    }}
// internal/vm/set.go:151
vm.cSet.smethods["[]"] = &Method{name: "[]", owner: vm.cSet,
    native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
        s := newSet()
        ...

A subclass inherits those singleton methods, so Sub.new and Sub[…] both reach newSet() and the subclass is lost at allocation. Nothing downstream can recover it.

What it hides

Everything that propagates the receiver's class through Set. The ones measured so far:

Sub[1, 2].divide { |x, y| false }.map { |s| s.class }   # MRI: [Sub, Sub]   rbgo: [Set, Set]
Sub[1, 2].classify { |x| x }.values.map { |s| s.class } # MRI: [Sub, Sub]   rbgo: [Set, Set]
Sub[1, 2].divide { |x| x }.map { |s| s.class }          # MRI: [Sub, Sub]   rbgo: [Set, Set]

MRI propagates it deliberately: set_i_classify passes rb_obj_class(set) to its subset allocation (set.c:890), and set_divide_arity2 calls set_s_alloc(set_class) (set.c:953). Note the asymmetry those two lines encode — the subsets take the receiver's class while the outer Set is always rb_cSet (set.c:946, set.c:1002). A fix must keep that asymmetry; making everything the subclass would be just as wrong.

The prelude already writes self.class.new(...) in the right places, so those sites need no change once allocation honours the receiver.

Found while

Writing #902 (Set#divide). Its test cannot assert the subsets' class until this is fixed, and that is stated in the PR rather than worked around. #902 does not depend on this one.

Scope

This is allocation, not divide: newSet() would need to take the class the method was called on, and *Set would need to carry it. Also worth checking at the same time whether Sub.new's initialize and the other smethods on Set have the same shape.

🤖 Generated with Claude Code

Activity

  1. tannevaled commented on Nov 7, 2026

    @tannevaled
    ContributorAuthor

    ⛔ Correcting the cause in this issue: it is not the two constructors

    This issue says:

    Both constructors are smethods on Set that ignore the receiver — the _ object.Value in each signature — and allocate with newSet()

    That is true and it is not the cause. Fixing those two signatures would change nothing observable, because the class is not lost at allocation — it is unrepresentable:

    // internal/vm/set.go:25
    type Set struct {
    	h         *object.Hash // member -> true, keyed by the member's #hash/#eql?
    	iterating int          // >0 while an #each is in progress (structural-mod guard)
    }

    There is no class field, so classOf cannot answer anything else:

    // internal/vm/object_model.go:1257
    case *Set:
    	return vm.cSet

    A constructor that honoured its receiver would hand the class to a struct with nowhere to put it, and Sub[1,2].class would still be Set.

    What the change actually is

    • add a class to Set and read it in classOf — that is the fix;
    • thread it through the 6 newSet( call sites in internal/vm;
    • the two smethods then stop ignoring their receiver, which is the part this issue already describes.

    What must not be lost

    MRI's asymmetry still holds and is the thing to assert: the subsets take the receiver's class while the outer Set is always rb_cSet (set.c:946, set.c:1002). The prelude already writes self.class.new(...) for the subsets and a bare Set.new(...) for the outer one (#902), so once allocation carries the class this comes out right without any prelude change — which is exactly why it needs a test. Behaviour that is correct by accident of another change is behaviour nobody is holding in place.

    Why this was filed in the wrong place

    I read the two constructors, saw them discard the receiver, and stopped — a true observation that explains the symptom and is not its cause. The question that would have reached it in one step is "where does this value come from?" rather than "which function looks wrong?". Same shape as #907, where a fix in exceptionObject changed nothing because the exceptions in question never passed through it.

    Nothing here is urgent and nothing is blocked; the diagnosis is corrected so the next reader does not start at the constructors and find them innocent.

    🤖 Generated with Claude Code

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