Repository navigation
A Set subclass reports its own class (closes #903) - #910
Conversation
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>
A wider probe, because
|
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
Closes #903.
⛔ The cause that issue named was not the cause
#903 blamed
Set.newandSet.[]for ignoring their receiver. They did — and fixing that alone would have changed nothing observable:There was no class field, so
classOfcould 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: acls *RClassfield where nil meansSet,classOfreading it, the receiver honoured in bothsmethods, anddupcarrying it. Every existing allocation keeps answeringSetwithout carrying a pointer for it.⛔⛔ MRI's asymmetry comes out for free — which is why it is tested
The subsets
classify/dividebuild 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 alwaysrb_cSet(set.c:946,:1002).The prelude already wrote
self.class.newfor the subsets (prelude.rb:1258,:1276,:1442,:1519) and a bareSet.newfordivide's outer set, and|isn = 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
While every Set reported
Set, returningselfansweredSetwhatever 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 saysSet). That is a regression this change caused, so it is fixed here rather than deferred, with MRI's own form:It also closes an arity gap the one-line version had:
to_set(Sub)raisedArgumentError (given 1, expected 0).Measured
cmd/oracleagainst/opt/homebrew/opt/ruby@4.0/bin/ruby(ruby 4.0.7 … +PRISM): 16 rows,2 engines AGREED, byte for byte; 8 further rows onto_set, stdout identical.⛔ On the first run the two engines printed the same number of bytes (88 vs 88) while differing in content —
SubandSetare both three letters. A comparison by length would have reported parity.⭐ Negative control, and what it says about four rows
Disabling
classOf's read ofclsfails 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") answerSetin 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 madeto_setreturnselffor 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_setwith 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_setdoes not warn in MRI either), with the stdout of all eightto_setrows agreeing.⭐ Since filing it I found the corpus already asserts it —
core/enumerable/to_set_spec.rb, guardedruby_version_is "4.0"..."4.1", currently failing on both engines: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 withmainmerged in — both changes touch this package and this one regeneratesprelude_frozen_gen.go, so "no textual conflict" was not enough.Closes #903
🤖 Generated with Claude Code