Skip to content

Add support for C# 14 user-defined compound assignment operators - #3972

Open
siegfriedpammer wants to merge 1 commit into
masterfrom
compound-assignment-operators
Open

Add support for C# 14 user-defined compound assignment operators#3972
siegfriedpammer wants to merge 1 commit into
masterfrom
compound-assignment-operators

Conversation

@siegfriedpammer

Copy link
Copy Markdown
Member

Implements decompiler support for C# 14 user-defined compound assignment operators (#829): instance operator declarations decompile to public void operator +=(T rhs) / operator checked += / operator ++(), and call sites fold back to x += y; / x++;.

The implementation was cross-checked against Roslyn 5.9 output first: for every LHS classified as a variable (locals, fields, array elements, ref params, this in structs, constrained generic receivers), the compiler lowers the operation to a single instance call on the lvalue, and property LHS never binds to the instance form at all. Call sites are therefore rewritten at the AST level in ReplaceMethodCallsWithOperators (a separate metadata-name table, deliberately not merged into the one that feeds the static-operator fold in TransformAssignment); no new ILAst instruction is needed. Checked variants reuse the existing AddCheckedBlocks annotation mechanism, and HasCheckedEquivalent's generic op_X -> op_CheckedX mapping covers the new names unchanged.

Recognizing the new op_*Assignment names flips IMethod.IsOperator on for instance methods, so the places that assumed operators are static (CallBuilder, CSharpResolver.GetUserDefinedOperatorCandidates, TransformAssignment) now check IsStatic explicitly. Declarations gate on a new UserDefinedCompoundAssignmentOperators setting (C# 14.0); below C# 14 they fall back to plain op_* methods, following the C# 11 operator checked precedent. The CompilerFeatureRequired("UserDefinedCompoundAssignmentOperators") attribute Roslyn emits on each operator is removed when operator syntax is used, and [IsReadOnly] is now hidden for operators so readonly struct operators print with the modifier only.

Not covered yet: explicit interface implementations (their metadata has no specialname and the existing dotted-name operator detection is static-only, so they decompile as plain methods) and result-used forms like d = (c += 5), which decompile as two equivalent statements.

New UserDefinedCompoundAssignment pretty fixture covering all 19 operators plus the call-site shapes above. Full ICSharpCode.Decompiler.Tests sweep on the rebased branch: 3364 total, 0 failed, 46 skipped.

🤖 Generated with Claude Code

Instance operators (dotnet/csharplang user-defined-compound-assignment)
compile to void-returning specialname instance methods
(op_AdditionAssignment, op_IncrementAssignment, op_Checked*, ...).
Roslyn lowers every variable-classified target to a single instance call
on the lvalue (verified against Roslyn 5.9), so call sites are rewritten
at the AST level in ReplaceMethodCallsWithOperators; no new ILAst
instruction is needed. Classifying the new names as operators flips
IMethod.IsOperator for instance methods, so the static-operator
assumptions in CallBuilder, CSharpResolver and TransformAssignment now
check IsStatic explicitly. Declarations gate on a new C# 14 setting and
fall back to plain op_* methods below C# 14, matching the C# 11
operator-checked precedent.

Not yet handled: explicit interface implementations (their metadata
lacks specialname and the existing dotted-name operator detection is
static-only) and result-used forms, which decompile as separate valid
statements.

Assisted-by: Claude:claude-fable-5:Claude Code

@christophwille christophwille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated high-effort review (multi-agent, each finding independently verified against the PR head). Overall: the PR handles the straightforward Roslyn lvalue cases well, but the new pattern matchers are loose at several boundaries. The most severe defects break recompilation of plain C# 14 code (explicit interface operator implementations, virtual operators called through a derived-typed receiver), and the static-operator compound-assign path can now silently change runtime semantics when a type declares both static and instance operators. Secondary issues are settings-gating gaps (CheckedOperators, UnsignedRightShift) and the signature-blind checked-equivalent probe inherited by the new paths.

10 findings posted as inline comments, ordered by severity there. Summary:

  1. Explicit interface implementations of instance compound operators decompile as plain methods (OperatorDeclaration.cs) - output fails to compile (CS0539).
  2. Derived-typed receivers get a cast that becomes the assignment target (CallBuilder.cs) - emits (C)d += n (CS0131).
  3. Non-lvalue receivers become an invalid assignment LHS (ReplaceMethodCallsWithOperators.cs) - GetC() += n; (CS0131).
  4. Type-parameter cast stripped without checking constraints (ReplaceMethodCallsWithOperators.cs) - non-compiling or wrong-binding output.
  5. x op= y printed for static operator calls even when an instance compound operator exists (TransformAssignment.cs) - recompiled code binds to the instance operator, silently different runtime behavior.
  6. Static / value-returning op_*Assignment (F#, C++/CLI) now rendered as operator declarations (TypeSystemAstBuilder.cs) - invalid C#.
  7. No void-return check on the instance-operator rewrite (ReplaceMethodCallsWithOperators.cs).
  8. Checked compound-assignment names not gated on settings.CheckedOperators (ReplaceMethodCallsWithOperators.cs).
  9. HasCheckedEquivalent is signature-blind (ReplaceMethodCallsWithOperators.cs) - spurious unchecked wrappers.
  10. op_UnsignedRightShiftAssignment not gated on settings.UnsignedRightShift (ReplaceMethodCallsWithOperators.cs).

names[(int)OperatorType.Implicit] = new string[] { "implicit", "op_Implicit" };
names[(int)OperatorType.Explicit] = new string[] { "explicit", "op_Explicit" };
names[(int)OperatorType.CheckedExplicit] = new string[] { "explicit", "op_CheckedExplicit" };
names[(int)OperatorType.AdditionAssignment] = new string[] { "+=", "op_AdditionAssignment" };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] The new operator-name table entries feed MetadataMethod's SpecialName branch, but the non-SpecialName fallback branch for explicit interface implementations (MetadataMethod.cs:99) still requires MethodAttributes.Static, so an explicit interface implementation of an instance compound-assignment operator is classified as a plain method.

Failure scenario: decompiling a C# 14 class containing void ICompound<int>.operator +=(int rhs) (Roslyn emits explicit operator implementations without SpecialName, per the existing comment in MetadataMethod) yields void ICompound<int>.op_AdditionAssignment(int rhs) - an explicit implementation of a nonexistent interface method - so the decompiled output fails to compile (CS0539).

Same root cause also affects the name table at line 124.

}
}
else if (method.IsOperator)
else if (method.IsOperator && method.IsStatic)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] Instance operator calls always fail IsUnambiguousCall (MemberLookup.Lookup's filter at MemberLookup.cs:377 excludes SymbolKind.Operator), so the disambiguation loop casts the receiver to the declaring type whenever the receiver's static type differs from it, and ReplaceMethodCallsWithOperators then uses that cast as the assignment target.

Failure scenario: class C declares public virtual void operator +=(int); class D : C; code D d; d += n; compiles to callvirt C::op_AdditionAssignment on a D-typed receiver. Decompiling: overload resolution always returns AmbiguousMatch, the repair loop rewrites the target to ((C)d).op_AdditionAssignment(n), and the new transform emits (C)d += n - a cast expression as assignment target, so the decompiled output does not compile (CS0131).

{
invocationExpression.AddAnnotation(AddCheckedBlocks.UncheckedAnnotation);
}
var assignment = new AssignmentExpression(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] assignmentTarget is never validated as an assignable expression (variable/field/property/indexer), so non-lvalue receivers are rewritten into an invalid assignment LHS.

Failure scenario: C# 14 source CompoundClass c = GetC(); c += n; where c is single-use: ILInlining inlines the receiver, giving the AST GetC().op_AdditionAssignment(n), which this transform rewrites to GetC() += n; - a CS0131 compile error on recompilation (LHS must be a variable, property or indexer). Likewise new C().op_...(n) becomes new C() += n; and an IL base-call base.op_AdditionAssignment(y) becomes the invalid base += y;.

&& invocationExpression.Target is MemberReferenceExpression targetMemberRef)
{
Expression assignmentTarget = targetMemberRef.Target;
if (assignmentTarget is CastExpression castExpr && castExpr.Expression.GetResolveResult().Type is ITypeParameter)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] The interface cast on a generic receiver is stripped whenever the operand is a type parameter, without verifying the cast type is among the type parameter's constraints (or is the operator's declaring type).

Failure scenario: IL box !!T; castclass ICompound`1<int32>; callvirt op_AdditionAssignment inside void M<T>(T x) where T : IOther decompiles to ((ICompound<int>)x).op_AdditionAssignment(n); the transform strips the cast, emitting x += n; which does not compile because T has no ICompound constraint. If T happens to be constrained to a different interface that also declares operator +=, the recompiled code silently binds to that other operator instead of the one behind the runtime cast - wrong method called at runtime.

targetType, CompoundEvalMode.EvaluatesToNewValue);
}
else if (setterValue is Call operatorCall && operatorCall.Method.IsOperator)
else if (setterValue is Call operatorCall && operatorCall.Method.IsOperator && operatorCall.Method.IsStatic)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] HandleCompoundAssign still prints x op= y for static operator calls without checking whether the target type also declares a C# 14 instance compound-assignment operator, which C# 14 overload resolution prefers for x op= y.

Failure scenario: a type declares both public static C operator +(C c, int n) and public void operator +=(int n). IL for c = c + n (an explicit static-operator call plus store) is decompiled to c += n;. Recompiling that output under C# 14 binds to the instance operator += instead of the static operator-plus-assignment the original assembly executed, so the recompiled program mutates the existing object in place (visible through every alias of c) instead of storing a new instance - silently different runtime behavior with no compile error.

Same root cause also affects the increment/decrement path around line 891.

return ConvertMethod(op);
if (opType == OperatorType.UnsignedRightShift && !SupportUnsignedRightShift)
return ConvertMethod(op);
if (!SupportUserDefinedCompoundAssignmentOperators && OperatorDeclaration.IsCompoundAssignment(opType.Value))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] ConvertOperator gains the compound-assignment gate but no shape check: because the extended name table makes MetadataMethod classify any SpecialName method named op_AdditionAssignment etc. as SymbolKind.Operator, a static or value-returning op_*Assignment method (emitted by F# static member (+=) and C++/CLI operator+=) is now rendered as an operator declaration.

Failure scenario: decompiling an existing F# or C++/CLI assembly that defines a static op_AdditionAssignment produces public static T operator +=(T a, int b) - invalid C# (compound assignment operators must be instance and void-returning), so the output no longer compiles; before this PR the same method decompiled as a normal callable method public static T op_AdditionAssignment(...).

context.EndStep(binaryOperator);
return;
}
if (context.Settings.UserDefinedCompoundAssignmentOperators && method is { IsOperator: true, IsStatic: false }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] The compound-assignment rewrite never checks that the instance operator method returns void, though C# 14 only recognizes void-returning instance operators as compound-assignment operators.

Failure scenario: decompiling a C++/CLI or IL-authored assembly with an instance op_AdditionAssignment(X) that returns a value: X r = a.op_AdditionAssignment(b); is rewritten to X r = a += b; (and even a bare statement call becomes a += b;). On recompilation the non-void instance operator is not an eligible C# 14 compound operator, so the code either fails to compile or binds to a different (static) operator with different behavior.

{
case "op_AdditionAssignment":
return AssignmentOperatorType.Add;
case "op_CheckedAdditionAssignment":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[settings gating] Checked compound-assignment names are not gated on settings.CheckedOperators, unlike the adjacent GetBinaryOperatorTypeFromMetadataName/GetUnaryOperatorTypeFromMetadataName which use when settings.CheckedOperators on every op_Checked* case.

Failure scenario: with CheckedOperators=false and UserDefinedCompoundAssignmentOperators=true (independent toggles in the UI), TypeSystemAstBuilder.ConvertOperator demotes op_CheckedAdditionAssignment to a plain method declaration (the !SupportOperatorChecked && IsChecked branch), but GetCompoundAssignmentOperatorTypeFromMetadataName still rewrites its call sites into checked { x += y; }. The recompiled x += y can only bind to the unchecked operator, so the decompiled program silently calls a different method than the original IL (or fails to compile when the IL declares only the checked variant). The same gap exists in the op_CheckedIncrementAssignment/op_CheckedDecrementAssignment switch at line 227.

{
invocationExpression.AddAnnotation(AddCheckedBlocks.CheckedAnnotation);
}
else if (HasCheckedEquivalent(method))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[correctness] HasCheckedEquivalent is name-only (signature-blind), so an operator overload with no checked counterpart for its own signature still gets an UncheckedAnnotation because a checked variant with a different signature exists on the type.

Failure scenario: for a type where op_CheckedAdditionAssignment(int) exists but only op_AdditionAssignment(long) for long, original source checked { c += 2L; ... } decompiles with an unchecked annotation on c += 2L, so the output pulls the statement into an unchecked {} wrapper / out of the checked block - noisy, misleading decompiled output that misrepresents the original checked context of the statement.

return AssignmentOperatorType.ShiftLeft;
case "op_RightShiftAssignment":
return AssignmentOperatorType.ShiftRight;
case "op_UnsignedRightShiftAssignment":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[settings gating] op_UnsignedRightShiftAssignment is not gated on settings.UnsignedRightShift, unlike op_UnsignedRightShift in GetBinaryOperatorTypeFromMetadataName (when settings.UnsignedRightShift), and TypeSystemAstBuilder.ConvertOperator only gates OperatorType.UnsignedRightShift, not UnsignedRightShiftAssignment.

Failure scenario: a user who disables the C# 11 'unsigned right shift' setting but leaves compound-assignment operators enabled still gets x >>>= y expressions and public void operator >>>= declarations in the output, i.e. syntax the setting explicitly turned off; every other settings-gated operator name in these helpers respects its feature toggle.

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.

2 participants