Fix #3282: leave out trailing optional arguments in indexer accesses - #4043
Fix #3282: leave out trailing optional arguments in indexer accesses#4043siegfriedpammer wants to merge 2 commits into
Conversation
An indexer access is built by HandleAccessorCall, which had no way to express an omitted argument, so CallBuilder asserted that no optional argument had been detected before it got there. That assert fires on any assembly that indexes through an indexer with an optional parameter (#3282), and Release builds silently wrote the defaults back out. The accessor call now goes through the same ArgumentList helpers as an ordinary call, which decide how much of the argument list is written. Two things had to reach them: the assigned value of a setter is the last argument of the call but not an argument of the access - the standard adds it only for the invocation of the accessor - so it neither ends the run of optional arguments nor is written out with them; and the argument names have to stop wherever the argument list does. Whether the shortened access still binds to the same member is left to IsUnambiguousAccess. If it does not, the omitted arguments are written out again before any cast is tried, since restoring them cannot change what the access means. A type declaring both this[int] and this[int, int = 10] therefore keeps both arguments, which the fixture pins. Assisted-by: Claude:claude-opus-5[1m]:Claude Code
C# allows named arguments in an element access, so an indexer access where the compiler reordered the arguments can be recovered as written instead of as a temporary variable assigned before the access. NamedArgumentTransform refused to introduce a named argument for any accessor; CallBuilder writes them out already, now that accessor calls share the argument-list handling of ordinary calls. Only indexers gain this: a property access has no argument list, and an operator cannot take names either. A setter's last argument is the assigned value, which is not part of the element access's argument list and corresponds to the implicit value parameter, so it stays unnamed and is written as the right-hand side. Assisted-by: Claude:claude-opus-5[1m]:Claude Code
51d1808 to
34fc6db
Compare
christophwille
left a comment
There was a problem hiding this comment.
Review summary
The optional-argument part works for the fixtures added, but opening indexer accessors to the named-argument machinery (NamedArgumentTransform + HandleAccessorCall now consuming GetArgumentNames()/GetArgumentExpressions()) re-exposes several assumptions elsewhere that were only safe because accessor calls never carried names. All findings below were reproduced against a build of this branch with small probe assemblies; master decompiles the same probes correctly.
Default settings, plain Roslyn output (crash or uncompilable code):
NamedArgumentTransform: an indexer setter inside aBlockKind.CallInlineAssignblock gets replaced by aCallWithNamedArgsblock ->Block.CheckInvariantassert (Debug) /MatchInlineAssignBlock() returned false(Release).HandleAccessorCall: when every indexer argument is an omitted trailing optional, zero arguments remain and the code falls into the property branches, emittingthis.Item = 5/initializedObject.Item.HandleAccessorCall:CastArgumentspairs argument-order arguments with declaration-ordermethod.Parameters; with reordered named indexer arguments each argument is cast to the wrong parameter's type.HandleAccessorCall:AddNamesToPrimitiveValuesnow applies to every indexer access (dictionary[true]->dictionary[key: true]) and, unlikeGetRequiredTransformationsForCall, the retry loop never tries turning it off before casting ->((Base)d)[flag: true]on overridden indexers. Untested default-output change.
Need AggressiveInlining (or an aggressive context: catch-when, ctor initializer, expression tree):
5. CanExtendNamedArgument still names an indexer setter's value argument; CallWithNamedArgs then reorders it away from the last position, which BuildArgumentList/HandleAccessorCall rely on -> this[value: Get(0), y: Get(1)] = Get(2);.
6. An indexer getter that is the Target of a CompoundAssignmentInstruction can now become a CallWithNamedArgs block -> CompoundAssignmentInstruction.CheckValidTarget assert.
Low severity:
7. IsSetterAccessorWrittenAsAssignment and Build()'s routing condition can disagree after params expansion (contrived, not producible from C#).
Common root cause for 1, 5, 6: accessor calls can now be wrapped in CallWithNamedArgs blocks, but several IL consumers (CallInlineAssign invariant, CompoundAssignmentInstruction.CheckValidTarget, CanExtendNamedArgument) and the position-based "last argument is the value" logic in CallBuilder still assume they cannot. Guarding CanIntroduceNamedArgument against a call.Parent that is a CallInlineAssign block / compound-assignment target, plus identifying the setter value by parameter index rather than position, closes all three.
Minor cleanups (no separate comments): GetArgumentExpressions still does argumentNames.Take(argumentCount) after GetArgumentNames already truncates (dead); lastNameableArgument is an exclusive end (misnamed); the setter predicate is duplicated in three places (Build, IsSetterAccessorWrittenAsAssignment, NamedArgumentTransform).
Details and repro snippets are in the inline comments.
| if (call.Method.IsAccessor) | ||
| { | ||
| // Only an indexer access has an argument list that can carry names. | ||
| if (call.Method.AccessorOwner is not IProperty { IsIndexer: true }) |
There was a problem hiding this comment.
Bug (default settings): indexer setter inside a CallInlineAssign block gets replaced by a CallWithNamedArgs block.
Check(this[y: Get(1), x: Get(2)] = Get(3));
// or
return this[y: Get(1), x: Get(2)] = this[y: Get(3), x: Get(4)];TransformAssignment builds Block CallInlineAssign { call set_Item(this, ldloc t2, ldloc t1, stloc i(Get(3))); final: ldloc i }. Inlining t1 stops at Get(2), CanIntroduceNamedArgument (indexer setter, index 1 != 3, load at index 2) returns NamedArgument, NonAggressiveInlineInto passes (t1 was stored with this on the IL stack), and IntroduceNamedArgument does call.ReplaceWith(namedArgBlock) inside the CallInlineAssign block, breaking MatchInlineAssignBlock. Reproduced: Debug build terminates with Assertion failed (Block.cs:120); Release emits Error: MatchInlineAssignBlock() returned false. Master emits int y = Get(1); int i = (this[Get(2), y] = Get(3));.
Same root cause, needs AggressiveInlining (or any aggressive context like a catch-when filter): this[y: Get(1), x: Get(2)] += 5; -- the indexer getter is the Target of a CompoundAssignmentInstruction, which CheckValidTarget requires to be a Call/CallVirt; it now becomes a CallWithNamedArgs block -> assert at CompoundAssignmentInstruction.cs:97. The non-aggressive default is only saved by the ILStackWasEmpty heuristic.
Suggest returning FindResult.Stop here when call.Parent is a CallInlineAssign block or a CompoundAssignmentInstruction target (i.e. only allow it where IntroduceNamedArgument may legally replace the call by a block).
| if (call.Method.Parameters.Any(p => string.IsNullOrEmpty(p.Name))) | ||
| return FindResult.Stop; // cannot use named arguments | ||
| for (int i = child.ChildIndex; i < call.Arguments.Count; i++) | ||
| int lastNameableArgument = isIndexerSetter ? call.Arguments.Count - 1 : call.Arguments.Count; |
There was a problem hiding this comment.
Bug (AggressiveInlining): CanExtendNamedArgument (unchanged, below) has no equivalent value guard. It still hands out any ldloc v call argument as a named argument, including an indexer setter's value. CallWithNamedArgs then reorders the arguments so the value is no longer last, but BuildArgumentList (isSetter && i + 1 == callArguments.Count) and HandleAccessorCall (Arguments[Length - 1]) identify the value by position.
int s = Get(0);
this[y: Get(1), x: Get(2)] = s;After the index temp is named, inlining s hits FindLoadInNext(Block CallWithNamedArgs) -> CanExtendNamedArgument -> the foreach loop returns NamedArgument for the value. PR build emits this[value: Get(0), y: Get(1)] = Get(2); (wrong and uncompilable). Non-aggressive mode is only saved by NonAggressiveInlineInto's ILStackWasEmpty heuristic.
Fix at the root: identify the setter value by parameter index (argumentToParameterMap[i] == Parameters.Count - 1) in CallBuilder, and/or add the same guard to CanExtendNamedArgument.
| } | ||
| } | ||
|
|
||
| var arguments = argumentList.GetArgumentExpressions().ToList(); |
There was a problem hiding this comment.
Bug (default settings): an all-optional indexer yields zero arguments and falls into the property branches.
public int this[int x = 10] { get; set; }
...
this[10] = 5; this[10] += 5; Console.WriteLine(this[10]); new Probe4 { [10] = 3 };decompiles (PR build, default settings) to this.Item = 5; this.Item += 5; Console.WriteLine(this.Item); new Probe4 { (initializedObject.Item = 3) } -- none of which compiles. When every argument is an omitted trailing optional, GetArgumentExpressions() is empty and the arguments.Count == 0 branches below emit a property access. The all-optional case must keep at least one argument (or FirstOptionalArgumentIndex must never reach 0 for indexers). OptionalArguments.cs only covers this[int x, int y = 10]; please add an all-optional fixture.
| CastArguments(arguments, method.Parameters.ToList()); | ||
| CastArguments( | ||
| new ArraySegment<TranslatedExpression>(argumentList.Arguments, 0, argumentList.GetActualArgumentCount()), | ||
| method.Parameters.ToList()); |
There was a problem hiding this comment.
Bug (default settings): the arguments are in argument order, method.Parameters in declaration order. With reordered named indexer arguments (now possible since CheckNoNamedOrOptionalArguments was dropped for indexers) each argument is cast to the wrong parameter's type.
// this[int i, object o] and this[int i, string o]
var v = this[o: (Get(1) == 1) ? "a" : (object)"b", i: Get(2)];Overload resolution is ambiguous once the (object) cast is dropped, the loop casts, and the PR build emits this[o: (int)((Get(1) == 1) ? "a" : "b"), i: (object)Get(2)] (setter form too) -- uncompilable. Pass argumentList.ExpectedParameters (argument order) instead, as GetRequiredTransformationsForCall does via paramTypesInArgumentOrder (~line 1445).
| IMember? foundMember; | ||
| while (!IsUnambiguousAccess(expectedTargetDetails, targetResolveResult, method, arguments, argumentNames, out foundMember)) | ||
| while (!IsUnambiguousAccess(expectedTargetDetails, targetResolveResult, method, | ||
| argumentList.GetArgumentResolveResults(), argumentList.GetArgumentNames(), out foundMember)) |
There was a problem hiding this comment.
Default-output change, untested: AddNamesToPrimitiveValues now applies to every indexer access, and the retry loop never turns it off. GetRequiredTransformationsForCall tries AddNamesToPrimitiveValues = false before casting arguments/target; this loop does not, so names win over casts.
PR build, default settings: dictionary[true] -> dictionary[key: true], this[true] -> this[flag: true] (master: dictionary[true]). Worse, with class Base { virtual this[bool flag] }, class Derived : Base { override this[bool f] } and d[true] on a Derived d (IL: callvirt Base::get_Item on a Derived receiver), the names ['flag'] fail against the override's parameter name and the loop ends in a target cast: ((Base)d)[flag: true] where master emitted d[true].
Either don't apply primitive-value names to accessor calls, or add the AddNamesToPrimitiveValues = false step first in this loop -- and add a bool-indexer fixture either way (the PR description does not mention this change).
| if (!method.IsAccessor || !method.ReturnType.IsKnownType(KnownTypeCode.Void)) | ||
| return false; | ||
| // Same shapes that Build() hands to HandleAccessorCall. | ||
| return method.AccessorOwner!.SymbolKind == SymbolKind.Indexer || method.Parameters.Count == 1; |
There was a problem hiding this comment.
Low severity / contrived: this predicate (method.Parameters.Count == 1) and Build()'s routing condition (argumentList.ExpectedParameters.Length == allowedParamCount) can disagree after params expansion. An IL-level void accessor set_Foo([ParamArray] int[] value) with ExpandParamsArguments is expanded to N arguments by TransformParamsArgument, Build skips HandleAccessorCall, but IsSetter is already true, so GetArgumentExpressions() drops the last argument in the ordinary invocation path: set_Foo(1) for set_Foo(1, 2). Not producible from C#, but the comment "Same shapes that Build() hands to HandleAccessorCall" is not literally true.
Fixes #3282.
HandleAccessorCallhad no way to express an omitted argument, soCallBuilderasserted that no optional argument had been detected before it got there. Any assembly that indexes through an indexer with an optional parameter hits that assert in a Debug build; Release builds silently wrote the defaults back out.Let an indexer access leave out trailing optional arguments
Accessor calls now go through the same
ArgumentListhelpers as an ordinary call. Two things had to reach them: the assigned value of a setter is the last argument of the call but not an argument of the access - the standard adds it only for the invocation of the accessor (§12.6.2.1) - so it neither ends the run of optional arguments nor is written out with them; and the argument names have to stop wherever the argument list does.Whether the shortened access still binds to the same member is left to
IsUnambiguousAccess. If it does not, the omitted arguments are written out again before any cast is tried, since restoring them cannot change what the access means. A type declaring boththis[int]andthis[int, int = 10]therefore keeps both arguments; the fixture pins that.Write named arguments for indexer accesses
C# allows named arguments in an element access, but
NamedArgumentTransformrefused to introduce one for any accessor, so an access whose arguments the compiler reordered came out as a temporary variable assigned before the access. Only indexers gain this: a property access has no argument list, an operator cannot take names either, and a setter's last argument stays unnamed on the right-hand side.Tests
Indexer cases in
OptionalArguments(get, set, compound assignment, increment, struct receiver, object initializer, and the overload that must keep its argument), inOptionalArgumentsDisabled(with the setting off the arguments stay explicit), and inNamedArguments.Not covered
Still written out explicitly, each for its own reason:
paramsindexers,[Optional]without a constant and[DateTimeConstant]-style defaults (nothing in the signature to compare against),default(T)at a value-type instantiation, and omitting a middle optional argument - the last of which plain calls do not do either (M(0, z: 9)decompiles toM(0, 1, 9)).Interaction with #3972
Checked: all three commits of #3972 cherry-pick onto this branch without conflict, the combined tree builds, and its full decompiler suite is green (3467 tests, 0 failures), including
c[1] += 5on an indexer, which goes through both changes.