Skip to content

Fix #3824: fold a hoisted argument null-guard at the ILAst level#3852

Merged
siegfriedpammer merged 2 commits into
masterfrom
fix-ctor-initializer-nullguard-ilast
Jul 5, 2026
Merged

Fix #3824: fold a hoisted argument null-guard at the ILAst level#3852
siegfriedpammer merged 2 commits into
masterfrom
fix-ctor-initializer-nullguard-ilast

Conversation

@siegfriedpammer

Copy link
Copy Markdown
Member

Fixes #3824. Supersedes #3829 with an ILAst-level implementation.

When a chained constructor-call argument contains a throwing null-check (e.g. value?.Length ?? throw ...) and the argument is evaluated more than once, the compiler hoists the null-check in front of the chained call. The hoisted if (value == null) throw ...; became the first body statement, so MoveConstructorInitializer could not recognize the chained call and left it as an illegal in-body base..ctor(...) / this..ctor(...) (a parse error).

Instead of re-deriving the pattern on the C# AST, the fix folds the guard back at the ILAst level, where the ?? throw shape already lives: NullCoalescingTransform folds a guard that directly precedes the chained call back into the first use of the parameter as if.notnull(ldloc param, throw). Nothing in a constructor body can legally run before the chained call, so a statement preceding it is necessarily compiler-hoisted; matching is by ILVariable identity, not parameter name. The guard disappears before the AST transforms run, so they need no change.

Value types chain via this = new TSelf(...) (stobj(ldthis, newobj ...)) rather than a base/this..ctor CallInstruction, which ChainedConstructorCallILOffset does not report, so the value-type chain is matched directly -- including the case where this is spilled to a stack slot because the guard sits between its load and the call.

The second commit fixes a related lifting bug this surfaced: when a type's non-chaining constructors disagree on their leading field assignments, the analyzer gave up on the whole type, so the remaining constructors' this()/base() calls were never lifted into initializers. A field-initializer mismatch now only skips the shared-initializer extraction; chain lifting continues.

Known limitation (unchanged from the status quo and shared with #3829): with throw expressions disabled (C# < 7) there is no compilable single-expression form for these constructors.

🤖 Generated with Claude Code

When a chained constructor-call argument contains a throwing null-check
(e.g. `value?.Length ?? throw ...`) and the argument is evaluated more
than once, the compiler hoists the null-check in front of the chained
call. The hoisted `if (value == null) throw ...;` then became the first
body statement, so MoveConstructorInitializer could not recognize the
chained call and left it as an illegal in-body `base..ctor(...)` /
`this..ctor(...)` (a parse error).

Fix it in the ILAst, where the `?? throw` shape already lives, rather
than re-deriving it on the C# AST: NullCoalescingTransform folds a guard
that directly precedes the chained call back into the first use of the
parameter as `if.notnull(ldloc param, throw)`. Nothing in a constructor
body can legally run before the chained call, so a statement preceding it
is necessarily compiler-hoisted; matching is by ILVariable identity, not
parameter name. The guard disappears before the AST transforms run, so
they need no change.

Reference types chain via a base/this..ctor CallInstruction; value types
chain via `this = new TSelf(...)`, i.e. stobj(ldthis, newobj TSelf(...)),
which ChainedConstructorCallILOffset does not report -- so the value-type
chain (including the case where this is spilled to a stack slot because
the guard sits between its load and the call) is matched directly.

Assisted-by: Claude:claude-opus-4-8:Claude Code
A type's leading field assignments are extracted to field declarations
only when every constructor that does not chain with this() agrees on
them. When they disagree, the analyzer gave up on the whole type, so the
remaining constructors' this()/base() calls were never lifted into
initializers -- a struct with two divergent constructors plus a chaining
one rendered the chain as `this = new TSelf(...)` instead of `: this(...)`.

Chain lifting does not depend on the shared-initializer extraction, so a
mismatch now just skips the extraction (the assignments stay in the
bodies) and the transform continues. Primary constructors still bail,
since their parameters drive the initializers that must be extracted.

Assisted-by: Claude:claude-opus-4-8:Claude Code
@siegfriedpammer siegfriedpammer force-pushed the fix-ctor-initializer-nullguard-ilast branch from b6b7e8a to 9a4f501 Compare July 5, 2026 06:48
@siegfriedpammer siegfriedpammer merged commit 112f32f into master Jul 5, 2026
13 checks passed
@siegfriedpammer siegfriedpammer deleted the fix-ctor-initializer-nullguard-ilast branch July 7, 2026 13:50
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.

Chained constructor call emitted as illegal in-body this..ctor(...) when a null-check is hoisted before it

1 participant