Skip to content

@value T | null without a tag when T holds an interface or a class; null tested by descriptor kind - #760

Merged
ASDAlexander77 merged 2 commits into
mainfrom
value-null-niche
Oct 31, 2026
Merged

ASDAlexander77 merged 2 commits into
mainfrom
value-null-niche

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

The last ray tracer cost: Intersection | null. Two commits:

  1. The null test reads the tag's descriptor kind (83659cd, carried over from Ray tracer: parameters captured by copy, direct calls to methods no class overrides #759, which was merged before it was pushed). A tagged union compared with null did a strcmp of its tag with "null". The tag points into its type's descriptor, whose kind (TYPE_KIND_NULL) answers with one load and one compare.
  2. A @value type holding an interface or a class needs no tag for its null (63be294). An interface is {vtable, this} and is null when this is; a class is null when its pointer is. So a @value T with such a field holds its own null. MLIRTypeHelper::getValueNullNiche finds the first such field, looking into nested @value fields too, and isUnionTypeNeedsTag answers no, with base type T.
    • Null converted into the union is T zeroed, and == null reads that pointer. typeof and boxing into any check the same pointer. Undefined is still the empty optional, so null and undefined stay apart.
    • -mm=own: a cast between T and its own T | null is a view. A cast into a tagged union becomes CreateUnionInstanceOp, but this one stays a cast.
    • A @value type with only numeric fields keeps its tag.

Ray tracer

Median of 7 runs, ms; every build prints the same checksum.

Build main null by kind + null in the reference vs Rust idiomatic (55)
gc 79 76 72 1.3x
rc 120 117 111 2.0x
none 79 76 70 1.3x
own 86 83 77 1.4x

Under none the benchmark now runs as fast as the variant that removes the union by hand (69.9 ms).

Testing

  • ctest 6540/6540 (Windows Release).
  • Fuzz seeds 82000-82599 clean.
  • DefaultLib lib.ts compiles under own, rc, gc and none.
  • New: value/value_missing_niche.ts, which is value_missing.ts with an interface and a class in the @value types, runs under gc, rc and none. It covers every spelling and state, typeof, and boxing into any. It doesn't run under own, which rejects that program's records holding a borrowed global; own_raytrace covers own.

Behavior change

Under // @strict-null false, a @value value built with its interface or class field null ({ thing: null, dist: 1 }) now reads as null. This is documented in the spec's "Missing values" section.

Found along the way, already on main

  • rc: function mark(d): Mark | null { return d < 0 ? null : { at: d, owner: new Owner(d) }; } (a @value holding a new class instance, returned from a ?: with null) crashes at run time.
  • (find(-1) ?? find(3)).dist with find(): Hit | null fails to compile: the ?? result type is a union nested in a union.
  • print(find(-1)) of a @value T | null crashes the compiler.

🤖 Generated with Claude Code

ASDAlexander77 and others added 2 commits October 31, 2026 11:02
…n its name is "null"

A tagged union compared with null compared its tag's text with "null" (StringCompareOp,
a strcmp) - every `Intersection | null` result of the ray tracer's Thing.intersect, and
every traceRay and testRay. The tag points into its type's descriptor
(TypeOfOpHelper::typeDescriptorValue), whose kind says null: one load and one compare.
A tag never set reads undefined's descriptor, so it is not null, as before.

Ray tracer, median ms: gc 79 -> 75, none 79 -> 74, rc 120 -> 115, own 86 -> 82. The hot
path has no strcmp left; removing the union altogether (README variant B) now saves
about 6 ms under none. ctest 6534/6534; fuzz seeds 81000-81599 clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`T | null` of a @value type was a tagged union inside an optional, 88 bytes for the
ray tracer's Intersection. An interface is null when the object in it is (`{vtable,
this}`, `this` null), and a class when its pointer is, so a T holding either holds its
own null: MLIRTypeHelper::getValueNullNiche finds the first such field (looking into
@value fields too), and isUnionTypeNeedsTag answers no, base type T.

- null into it is T zeroed (CastLogicHelper); `== null` reads that pointer
  (UnaryBinLogicalOrHelper valueNullCompare); `typeof` and boxing into `any` ask the
  same (TypeOfOpHelper, castToAny). Undefined is still the empty optional, so null and
  undefined stay apart.
- -mm=own: a @value value widened to its own `T | null`, or read back, is a view
  (isView) - the cast stays a cast where a tagged one became CreateUnionInstanceOp.
- A T with only numbers keeps its tag.

Under `// @strict-null false` a T built with that field null now reads as null (spec
"Missing values").

Ray tracer, median ms: gc 76 -> 72, none 76 -> 70, rc 117 -> 111, own 83 -> 77; the
same as the variant with no union at all. Test value/value_missing_niche (gc, rc,
none). ctest 6540/6540; fuzz seeds 82000-82599 clean; DefaultLib compiles under all
four models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit b1b7060 into main Oct 31, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the value-null-niche branch October 31, 2026 11:52
ASDAlexander77 added a commit that referenced this pull request Oct 31, 2026
…n imported .d.ts (#761)

* @value T | null: ?: of a literal holding a new object (rc), ??, and print

Three failures of a @value type that may be missing, all on main before #760:

- rc: `d < 0 ? null : { at: d, owner: new Owner(d) }` returned as `Mark | null`. The
  branch released the new object before the result's retain after the join, which then
  retained freed memory. consumeConditionalResults' producerOf looks through a tuple
  cast to the @value type it is laid out as, so yieldsLiteralOfBranchTemporary sees the
  literal and the branch retains first.
- `h ?? find(3)` was typed `Hit | (Hit | null)` and did not compile: an optional member of
  a union gave its element as one member. processUnionTypeItem now processes the element
  like any other member, so a union in it gives its members.
- print / string conversion: an undefined `T | undefined` printed as null or as a zeroed
  T (the optional went through ValueOrDefault); it is "undefined" now. A null `T | null`
  stored without a tag (#760) printed T's fields; castNullableToString takes a @value
  base too and prints "null".

Test value/value_missing_ops (gc, rc, none; own cannot yet tell which branch of a `?:`
owns a literal it merges). ctest 6546/6546; fuzz seeds 83000-83599 clean; DefaultLib
compiles under own and rc.

Printing a @value value holding an interface still crashes, as printing any interface
does - not changed here.

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

* An interface printed shows its fields, as an object literal does

Printing an interface value, or putting it into a string, crashed at run time,
whatever object was behind it - a class, an object literal, or one a @value value
holds. USER RULING: print its fields. castInterfaceToString:

- its `toString` when the interface has one, as a class's is;
- else its fields, read through the interface, those of the interfaces it extends
  first, in an object literal's format (`{ n: 3, name: 'b' }`, fieldsToString,
  which castTupleToString now shares);
- null as "null";
- a field of an interface type already being printed is `[Object]`: the text is
  made inline, and `interface Chain { next: Chain }` nests without end.

Test tests/00interface_print (every model). ctest 6552/6552; fuzz seeds
84000-84599 clean; DefaultLib compiles under all four models.

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

* A C string result is copied when an imported module loaded its .d.ts first

TSLANG_ISSUES 21. The string a C function returns is copied into a string of the
program's own (#575), but not when the `.d.ts` declaring it was first reached through a
module this file imports: `declaredFunctions` kept the module of that earlier pass, and
isForeignCall wanted the one being generated. The raw `char*` was taken as it was - stored
strerror() results showed the buffer's last text, and under -mm=rc the count was written
into C's memory and the release freed it (native_ts_graphics on Android: "Scudo ERROR:
misaligned pointer when deallocating").

A top-level body-less `declare function` is C whichever module's pass loaded its `.d.ts`:
isForeignCall now asks that, and that this module's function has no body.

Test c-string-import/ (gc, rc, none, own; compiled and JIT), whose `.d.ts` is outside
tests/, where *.d.ts are build output and ignored. ctest 6560/6560; fuzz seeds
85000-85599 clean.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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