Skip to content

A Set subclass reports its own class (closes #903) - #910

Merged
tannevaled merged 2 commits into
mainfrom
set-subclass-class
Nov 8, 2026
Merged

tannevaled merged 2 commits into
mainfrom
set-subclass-class

Conversation

@tannevaled

@tannevaled tannevaled commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Closes #903.

class Sub < Set; end
Sub[1, 2].class      # MRI: Sub    rbgo: Set

⛔ The cause that issue named was not the cause

#903 blamed Set.new and Set.[] for ignoring their receiver. They did — and fixing that alone would have changed nothing observable:

type Set struct {
	h         *object.Hash
	iterating int
}

case *Set:
	return vm.cSet

There was no class field, so classOf could not answer anything else. The class was unrepresentable, not discarded: a constructor honouring its receiver would have handed a class to a struct with nowhere to put it. The diagnosis is corrected in the issue so the next reader doesn't start at the constructors and find them innocent.

The change

There is exactly one allocation point (set.go:65 — checked, including &Set{ literals), so: a cls *RClass field where nil means Set, classOf reading it, the receiver honoured in both smethods, and dup carrying it. Every existing allocation keeps answering Set without carrying a pointer for it.

⛔⛔ MRI's asymmetry comes out for free — which is why it is tested

The subsets classify/divide build take the receiver's class (rb_obj_class(set), set.c:890; set_s_alloc(set_class), set.c:953) while the outer Set those two return is always rb_cSet (set.c:946, :1002).

The prelude already wrote self.class.new for the subsets (prelude.rb:1258, :1276, :1442, :1519) and a bare Set.new for divide's outer set, and | is n = dup. So one field plus one read produced MRI's entire shape with no prelude change. Behaviour that is correct by accident of another change is behaviour nobody is holding in place, so it is asserted.

⛔ And the change unmasked a divergence of its own making

def to_set   # prelude.rb:1563, before
  self
end

While every Set reported Set, returning self answered Set whatever the receiver was — it matched MRI by accident. Giving a subclass its own class turned that accident into a divergence (Sub[1,2].to_set → Sub; MRI says Set). That is a regression this change caused, so it is fixed here rather than deferred, with MRI's own form:

def to_set(klass = Set, *args, &block)
  return self if instance_of?(Set) && klass == Set && block.nil? && args.empty?
  klass.new(self, *args, &block)
end

It also closes an arity gap the one-line version had: to_set(Sub) raised ArgumentError (given 1, expected 0).

Measured

cmd/oracle against /opt/homebrew/opt/ruby@4.0/bin/ruby (ruby 4.0.7 … +PRISM): 16 rows, 2 engines AGREED, byte for byte; 8 further rows on to_set, stdout identical.

⛔ On the first run the two engines printed the same number of bytes (88 vs 88) while differing in content — Sub and Set are both three letters. A comparison by length would have reported parity.

⭐ Negative control, and what it says about four rows

Disabling classOf's read of cls fails 8 of the 12 test rows. The four that do not are named in the test, with the reason — two are controls, and two ("the outer Set of divide stays a plain Set", "a block makes to_set build a new Set") answer Set in both states.

Those two cannot discriminate against the pre-fix engine. They are not redundant: they guard the opposite direction — a later change that propagated the class to divide's outer Set, or made to_set return self for a block, fails them. Written in the file because a reader repeating the experiment would otherwise read them as dead weight and delete the only guard on the asymmetry.

Not here, filed instead

Refs #909 — Enumerable#to_set with arguments is deprecated in 4.0 and MRI warns on stderr; rbgo is silent. It stays open on purpose: a different method, pre-existing, and unaffected by this change (Set#to_set does not warn in MRI either), with the stdout of all eight to_set rows agreeing.

⭐ Since filing it I found the corpus already asserts it — core/enumerable/to_set_spec.rb, guarded ruby_version_is "4.0"..."4.1", currently failing on both engines:

Enumerable#to_set | instantiates an object of provided as the first argument set class
    expected a warning matching /Enumerable#to_set/, got ""

So #909 is worth +1 on the ratchet rather than being cosmetic. It is still not this PR: the sweep below shows that example unmoved by this change, which is the evidence the two are independent.

go test ./internal/vm/ green on the tree with main merged in — both changes touch this package and this one regenerates prelude_frozen_gen.go, so "no textual conflict" was not enough.

Closes #903

🤖 Generated with Claude Code

tannevaled and others added 2 commits November 8, 2026 10:20
    class Sub < Set; end
    Sub[1, 2].class      # MRI: Sub    rbgo: Set

## ⛔ The cause this issue named was not the cause

#903 blamed Set.new and Set.[] for ignoring their receiver. They did, and fixing
that alone would have changed NOTHING observable:

    type Set struct {
        h         *object.Hash
        iterating int
    }
    case *Set:
        return vm.cSet

There was no class field, so classOf could not answer anything else. The class
was UNREPRESENTABLE, not discarded — a constructor honouring its receiver would
have handed a class to a struct with nowhere to put it. The issue's diagnosis is
corrected in a comment there.

## The change

One allocation point (set.go:65), so: a `cls *RClass` field (nil means Set, so
every existing allocation is untouched), classOf reading it, the receiver
honoured in both smethods, and dup carrying it.

## ⛔⛔ MRI's asymmetry comes out FOR FREE, which is why it is tested

The SUBSETS classify/divide build take the receiver's class (set.c:890, :953)
while the OUTER Set those two return is always rb_cSet (set.c:946, :1002). The
prelude already wrote `self.class.new` for the subsets (prelude.rb:1258, :1276,
:1442, :1519) and a bare `Set.new` for divide's outer set, and `|` is `dup`.

So one field plus one read produced MRI's whole shape with no prelude change.
Behaviour that is correct by accident of another change is behaviour nobody is
holding in place, so it is asserted.

## ⛔ And the change UNMASKED a divergence of its own making

    def to_set   # prelude.rb:1563, before
      self
    end

While every Set reported Set, returning self answered Set whatever the receiver
was — matching MRI BY ACCIDENT. Giving a subclass its own class turned that
accident into a divergence (`Sub[1,2].to_set` -> Sub, MRI says Set). That is a
regression this change caused, so it is fixed here rather than deferred, with
MRI's own form:

    def to_set(klass = Set, *args, &block)
      return self if instance_of?(Set) && klass == Set && block.nil? && args.empty?
      klass.new(self, *args, &block)
    end

It also closes an arity gap the one-line version had: `to_set(Sub)` raised
ArgumentError (given 1, expected 0).

## Measured

cmd/oracle against /opt/homebrew/opt/ruby@4.0/bin/ruby (ruby 4.0.7, +PRISM):
16 rows on subclass propagation, "2 engines AGREED, byte for byte"; 8 rows on
to_set, stdout identical.

⛔ The two engines printed the SAME NUMBER OF BYTES (88 vs 88) on the first run
of the subclass probe while differing in content — Sub and Set are both three
letters. A comparison by length would have called it parity.

## Negative control, and what it says about the rows

Disabling classOf's read of cls fails 8 of the 12 test rows. The 4 that do not
are named in the test with the reason: two are controls, and two ("the outer Set
of divide stays a plain Set", "a block makes to_set build a new Set") answer Set
in BOTH states. Those two cannot discriminate against the pre-fix engine — they
guard the OPPOSITE direction, a later change that over-propagated the class.
Said in the file, because a reader running the same experiment would otherwise
read them as redundant and delete the only guard on the asymmetry.

## Not fixed here, to be filed

`[1, 2].to_set(Sub)` — Enumerable#to_set with arguments — emits
"warning: passing arguments to Enumerable#to_set is deprecated" on MRI and
nothing here. rbgo never emitted it, before or after, so it is pre-existing and
unrelated; a plain `Set[1,2].to_set(Sub)` does not warn.

go test ./... : 14 packages ok, 0 failures.

Closes #903

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled

Copy link
Copy Markdown
Contributor Author

A wider probe, because self.class.new appears in more places than the test names

The test covers classify, divide, dup/clone, |, &, - and to_set. The prelude also writes self.class.new in flatten (prelude.rb:1528), and several other methods return a Set without appearing in either list — so the change's reach is larger than what is asserted. Measured rather than assumed:

2 engines AGREED, byte for byte      (16 rows, ruby 4.0.7 +PRISM)
receiver Sub[1,2,3] MRI and rbgo
flatten, Sub[Set[1],Set[2]].flatten Sub
^, union, intersection, difference, subtract Sub
select, filter, reject, map, sort, partition Array
each_slice(2).to_a.first, to_a Array
group_by Hash

⭐ The second half is the control worth having. It shows the class does not leak into the Enumerable-returning methods: select on a Set subclass still answers an Array, exactly as on a plain Set. A change that threaded a class too eagerly would show up there first, and nothing in the test file would have caught it.

Why these are not added as test rows

They are held in place by the same single mechanism the test already exercises — classOf reading cls. Disabling that line fails 8 of the 12 existing rows, so these would be redundant for regression purposes while costing a full 26-check re-run. The measurement is the useful artefact, so it is recorded here instead. If a reviewer would rather have flatten and select as rows, that is a one-line change and I will add them.

Reach of the change, for the record

One allocation point (set.go:65), one struct field, one classOf read, the receiver honoured in two smethods, and dup. Everything above follows from those — no prelude change beyond to_set, whose accidental correctness this fix broke and had to repair.

🤖 Generated with Claude Code

@tannevaled
tannevaled merged commit f509c3b into main Nov 8, 2026
26 checks passed
@tannevaled
tannevaled deleted the set-subclass-class branch November 8, 2026 10:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant