Skip to content

Kernel#inspect: honour #instance_variables_to_inspect, the 4.0 hook - #894

Merged
tannevaled merged 3 commits into
mainfrom
inspect-ivars-hook
Nov 5, 2026
Merged

tannevaled merged 3 commits into
mainfrom
inspect-ivars-hook

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Two more of the 27 examples that went red on the move to Ruby 4.0 (#799).
core/kernel/inspect_spec.rb goes 6 → 8, fail=0.

The other two examples in that ruby_version_is "4.0" block already passed,
and that is worth saying plainly: they assert show everything for a nil return
and for an undefined hook, which is what rbgo did anyway. Half the block was
green for a reason that had nothing to do with the feature.

The reference

object.c's rb_obj_inspect at v4.0.9:

VALUE ivars = rb_check_funcall(obj, id_instance_variables_to_inspect, 0, 0);
if (UNDEF_P(ivars) || NIL_P(ivars)) { n = rb_ivar_count(obj); ivars = Qnil; }
else if (RB_TYPE_P(ivars, T_ARRAY)) { n = RARRAY_LEN(ivars); }
else rb_raise(rb_eTypeError, "Expected #instance_variables_to_inspect to "
              "return an Array or nil, but it returned %"PRIsVALUE, ...);

Kernel also gains a private method of that name returning nil. Its name was
already in this repository's kernelPrivateNames, so the rehome pass was ready
for it — nothing had ever defined it, and a listed name that does not exist
reports the same false as one that was never meant to be there.

⛔ Three details that were measured, not inferred

None of these is what reading the C would most naturally suggest:

behaviour why
["@host", "@user"] inspects as #<Object:0x…> elements are compared as Symbols, by identity — n is 2, so the filtering branch runs and a String that spells the name is not the name
%i[@user @host] prints @host=…, @user=… the order is the object's, not the list's: inspect_i walks the object's variables and tests membership
a public hook honoured rb_check_funcall ignores visibility

The dispatch is skipped when it would answer nil

#inspect is on the path of every p and every error message, and consulting
this hook means a method call per inspect that almost never changes anything.
inspectIvarFilter resolves the name and compares the record against Kernel's
default; equal means nobody overrode it, and the send is skipped.

A test takes each way of overriding it — on the class, inherited,
re-overridden below, mixed in from a module — because a fast path that
swallowed one of those would still pass every other row.

Measured

rubydiff against MRI 4.0.7: AGREE on 14 probes covering the return shapes,
the visibilities and the error messages, and on 7 more covering the override
routes.

specdelta, provenanced, over core/kernel, core/module, core/method,
core/basicobject, core/objectspace and language — the directories where
adding a private Kernel method would show up:

351 file(s): control 6048 passing, treatment 6050 passing, +2
1 file(s) moved, 0 regressed

Coverage: inspectIvarFilter and defaultObjectInspect at 100.0%. The last
uncovered branch was the hook not defined at all path, which only a
BasicObject reaches, through a bound Kernel#inspect.

Independent of #892 and #893.

🤖 Generated with Claude Code

tannevaled and others added 3 commits November 4, 2026 22:36
Two more of the 27 examples that went red on the move to Ruby 4.0 (#799).
`core/kernel/inspect_spec.rb` goes 6 → 8, fail=0.

The other two examples in that `ruby_version_is "4.0"` block already passed,
and that is worth saying plainly: they assert "show everything" for a nil
return and for an undefined hook, which is what rbgo did anyway. Two of the
four were green for a reason that had nothing to do with the feature.

## The reference

object.c's rb_obj_inspect at v4.0.9:

    VALUE ivars = rb_check_funcall(obj, id_instance_variables_to_inspect, 0, 0);
    if (UNDEF_P(ivars) || NIL_P(ivars)) { n = rb_ivar_count(obj); ivars = Qnil; }
    else if (RB_TYPE_P(ivars, T_ARRAY)) { n = RARRAY_LEN(ivars); }
    else rb_raise(rb_eTypeError, "Expected #instance_variables_to_inspect to "
                  "return an Array or nil, but it returned %"PRIsVALUE, ...);

and Kernel gains a private method of that name returning nil
(rb_obj_instance_variables_to_inspect). Its name was already in this
repository's kernelPrivateNames, so the rehome pass was ready for it — nothing
had ever defined it, and a listed name that does not exist reports the same
`false` as one that was never meant to be there.

## Three details that were measured, not inferred

Each came back from MRI 4.0.7 on this host and none of them is what reading
the C would most naturally suggest:

* The elements are compared as SYMBOLS, by identity. `["@host", "@user"]`
  matches nothing and inspects as `#<Object:0x…>` — n is 2, so the filtering
  branch runs, and a String that spells the name is not the name.
* The ORDER is the object's, not the list's. `%i[@user @host]` still prints
  `@host=…, @user=…`, because inspect_i walks the object's variables and tests
  each for membership.
* rb_check_funcall ignores visibility, so a hook defined public behaves exactly
  like the private one the documentation shows.

## The dispatch is skipped when it would answer nil

#inspect is on the path of every `p` and every error message, and consulting
this hook means a method call per inspect that almost never changes anything.
inspectIvarFilter resolves the name and compares the record against Kernel's
default; equal means "nobody overrode it" and the send is skipped. A test
takes each way of overriding it — on the class, inherited, re-overridden below,
mixed in from a module — because a fast path that swallowed one of those would
pass every other row here.

## Measured

rubydiff against MRI 4.0.7: AGREE on 14 probes covering the return shapes, the
visibilities and the error messages, and on 7 more covering the override
routes.

specdelta, provenanced, over core/kernel, core/module, core/method,
core/basicobject, core/objectspace and language — the directories where adding
a private Kernel method would show up: 351 files, control 6048, treatment 6050,
+2, 1 file moved, 0 regressed.

Coverage: inspectIvarFilter and defaultObjectInspect at 100.0%. The last
uncovered branch was the "hook not defined at all" path, which only a
BasicObject reaches, through a bound Kernel#inspect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 86d69f1 into main Nov 5, 2026
26 checks passed
@tannevaled
tannevaled deleted the inspect-ivars-hook branch November 5, 2026 15:23
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.

1 participant