Repository navigation
Place ByRef write-backs where VB runs them (return/throw/yield, block headers, field initializers) - #9
Draft
GrahamTheCoder wants to merge 12 commits into
Draft
Place ByRef write-backs where VB runs them (return/throw/yield, block headers, field initializers)#9GrahamTheCoder wants to merge 12 commits into
GrahamTheCoder wants to merge 12 commits into
Conversation
…e#896) VB copies a ByRef argument back to its property straight after the call. The converter emits that write-back after the converted statement, which after return/throw is unreachable, and after yield return only runs when the consumer resumes. When a jump statement has write-backs, evaluate its expression into a local, write back, then jump with the local. Statements without write-backs keep their single-statement form. The local uses var only when the expression's own type is the one the jump needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
A ByRef write-back is emitted after the converted statement. For a call in a block header (Select Case, Case, For, For Each, Using, SyncLock, With, Catch When) that statement is the whole block, so the body saw the old value, and a later Case clause wrote back even when never evaluated. These headers now take the same local-function fallback that If and ElseIf conditions already use, so the write-back happens inside the call site's own evaluation. Headers without write-backs keep their output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Pure refactor, so field initializers can reuse it. No output changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
…#1081) A ByRef argument that needs a temporary (e.g. an omitted Optional ByRef parameter, or a property) hoists a declaration and maybe a write-back. A field initializer has no statement to put them in. The old code moved them into a static method, but read v.Initializer, which is null once an instance initializer has moved to the constructor: NullReferenceException. That path also dropped write-backs, left placeholder names in the constructor and partial-class initial functions, and refused fields with several variables or nested calls. Now each field initializer is converted in its own scope. If anything was hoisted, the initializer becomes a call to a method built with the same statement machinery as method bodies: temporaries, the write-backs before returning, and local functions. The method is static when the initializer stays on the field, and an instance method when it moves to the constructor. Initializers without temporaries are unchanged. WithObjectInitializerCanReadFromPropertiesOfObjectAsync: the object initializer's temporary now gets the same unique name and explicit type as in a method body (`SomeClass @init`), instead of an unchecked `var init`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
When a type has constructors in another part, a non-static field initializer becomes an initialX() function that the other part's constructor calls. It was always emitted static, so any initializer reading an instance member (which is why it isn't on the field) failed to compile with CS0120, including one holding ByRef temporaries. The multi-file characterization initialAnArrayWithNonStaticInitializer... read the instance field anInt from a static method; it now compiles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Coverage showed the unqualified-name branch is never hit: invoked names are qualified (e.g. Me.Foo) before conversion. Falling back to init<Field> would still be a valid name. No output changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
GrahamTheCoder
force-pushed
the
claude/fix-896-1081-byref-writeback-placement
branch
from
November 12, 2026 01:24
a7363ef to
7377689
Compare
A field declaration's hoisted methods were emitted for every variable group of every declarator, so `A As Integer = Inc(C), B As Integer = Inc(C)` or `Arr(Inc(C)), Other As Integer` declared a method twice (CS0111/CS0121). A WithEvents field never emitted the method its constructor assignment calls (CS0103). Take the functions from the field scope as they're emitted, and emit them for WithEvents fields too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Reverse copy-back order of a call's arguments, and ReadOnly auto-properties written back in constructors and initializers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Every caller now references these methods by their final name, so replacing placeholder ids in the constructor initializers never changed anything. Pure refactor, no output changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
A generated method like initW() or Inc_v() clashed with a user member of the same name (CS0111/CS0102/CS0121). Use the unique-name check already used for hoisted locals. Partial-class initialX() functions keep their fixed name, since another part calls them by it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Several declarators in one Dim, lambda parameters in ByRef arguments, auto-property initializers, and partial-class initializer order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes icsharpcode#896
Fixes icsharpcode#1081
Both issues come from the same gap. When a ByRef argument needs a temp (a property, a method result, an omitted
Optional ByRef), VB copies the temp back straight after the call. Roslyn'sLocalRewriter_Call.vblowers the call to a sequence: the call's value, then the copy-backs as side effects. The converter instead emits the write-back after the whole converted statement. For field initializers there is no statement at all.Root causes
PerScopeState.CreateLocalsAsyncadds write-backs (AdditionalAssignment) after the statement. Afterreturnorthrowthey can never run. Afteryield returnthey only run when the consumer resumes.Select Case,Case,Forstart/bound,For Each,Using,SyncLock,With,Catch … When): the "statement" is the whole block, so the body saw the old value. For a laterCaseclause, the write-back ran even when the clause was never evaluated.NextStatementDefinitelyExecutedAftertreated these headers like plain statements. If/ElseIf and loop conditions were already handled.DeclarationNodeVisitor.CreateAdditionalLocalMembersreadv.Initializer, which is null once an instance initializer has moved to the constructor. That threw a NullReferenceException. The same path dropped write-backs, leftph<guid>placeholders in constructors andinitialXfunctions, and refused declarations with several variables or nested calls.initialX()function (used when the constructors are in another part) was alwaysstatic. So any initializer that read an instance member failed with CS0120. The existing multi-file characterizationinitialAnArrayWithNonStaticInitializerReferencingOtherPartis one example.Output by case (progressive fallback; the existing simple output is unchanged)
F(P)as a statement,x = F(P),Dim x = F(P)Return F(New X))Return F(P)int argv = P; long ret = F(ref argv); P = argv; return ret;Throw CreateEx(P)/Yield F(P)throw ex;/yield return ret;Select Case F(P),Case F(P),For i = 0 To F(P),For Each x In F(P),Using d = F(P),SyncLock F(P),With F(P),Catch When F(P)If):int localF() { int argv = P; var ret = F(ref argv); P = argv; return ret; }, thenswitch (localF())etc.Private Second As New Outer(First)with an omittedOptional ByRef(icsharpcode#1081), instance initializerSecond = initSecond();, plus an instance methodOuter initSecond() { string argx = ""; return new Outer(First, x: ref argx); }Private _a As Integer = Inc(P), instance_a = Inc__a();in the constructor;Inc__a()declares, calls, writes back and returnsPrivate Shared _c As Integer = Inc(SP)/ any initializer that stays on the fieldstatic int Inc__c() { … SP = argv; return ret; }and_c = Inc__c();, the existingFoo__p1style, now with the write-backPrivate _d, _e As New Outer(First), orA As Integer = Inc(C), B As Integer = Inc(C)WithEvents W As New Src(P)W = initW();goes in the constructorinitialX()contains the temps, and is now an instance methodThe jump result uses
varonly when the VB expression's own type is the type the jump needs. Otherwise the converted type is written out, e.g.long retfor a widening return.Design
PerScopeState.WithWriteBacksBefore(extracted in a pure refactor commit): if the current scope has write-backs, evaluate into a uniquely named local, write back, then use the local. Otherwise keep the single statement.NextStatementDefinitelyExecutedAfter, so it's a single change. Capturing each header into a variable before the block would be a little prettier, but every visitor would need its own change. ForCaseclauses andCatch Whenit would also be wrong, because they're evaluated conditionally.CommonConversions.SplitVariableDeclarationsAsync).PerScopeState.CreateReturnBlockAsyncbuilds a method body with the same machinery as method bodies: unique names, temps, write-backs before the return, and local functions. It doesn't consume the scope, so each variable sharing an initializer gets its own body.DeclarationNodeVisitortakes the hoisted methods from the field scope as it emits them, so each one is emitted exactly once, for WithEvents fields too.CodeConverter/CSharp/README.mdmaps each case to the VB/C# rules, its code, its sources and the known gaps.Decisions (accepted in review)
ref(out _only works forout), so a temp is the simplest correct form.WithObjectInitializerCanReadFromPropertiesOfObjectAsyncexpectation changed:var init = new SomeClass();becameSomeClass @init = new SomeClass();. This is the same naming a method body uses for the same initializer.PartialClassPart.cs(3 copies):static int[] initialAnArray…()loststatic. It read an instance field, so it didn't compile before.Review round 1
ArgumentConverter, because nested calls write back before their outer call, so a scope-wide reversal would be wrong.ReadOnlyauto-property passed ByRef in a constructor or initializer is written back by VB but not by the converter. A fix needs the "assignable here" rule, and a C# write-back target that's assignable from the initializer method.Reviewer attention
Known gaps, not changed here:
New(object creation) in a block header still write back after the block.For … Stepis re-evaluated on every iteration. This is a separate For-conversion bug, not specific to ByRef.Tests
Tests/TestData/SelfVerifyingTests/VBToCS/ByRefWriteBackPlacementTests.vb: 27 self-verifying tests. They cover:retis taken, property getter, multi-line lambda, throw, yieldInc(Counter) + Counter * 10), several variables in one declarator, several declarators with array bounds, a WithEvents field, a Shared initializer, a partial-class initializerEach fix was seen red first, for the expected reason.
ByRefTests.cs:WritesBackBeforeJumpingIssue896Async,WritesBackBeforeBlockBodyAsync,OmittedOptionalRefInInstanceFieldInitializerIssue1081Async(the exact repro) andFieldInitializersWriteBackAsyncpin the output.Every commit builds.
Suite vs baseline
net10.0): 1456 passed, 5 skipped, 0 failed.Coverage of changed lines
I ran the full suite with
XPlat Code Coveragebefore review round 1. Every changed line is covered except:MethodBodyExecutableStatementVisitor.GetResultTypeSyntax: half the branches oftypeInfo.Type is { } && Equals(...). The null-type branch is a guard, because a ByRef call's result always has a type.CommonConversions.GetInitializerMethodName: its unqualified-name branch was unreachable and has been removed.The round 1 fix has no uncovered paths: both of its branches are exercised by the new tests.
🤖 Generated with Claude Code
https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg