From 8a455f44a5663ef66ea327ce5bcd8a66c77ba5d2 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 6 Aug 2026 17:16:34 +0200 Subject: [PATCH] Keep the Deconstruct call explicit when the element type hides it A nested designation rebinds Deconstruct on the element's static type when the output is recompiled, while the explicit call it replaces is bound at the call site. Where a derived element type declares a Deconstruct of the same arity as the called method, and the source deconstructs through a base-typed view, the two bindings differ, so the sugared output calls the wrong method - a divergence the runtime fixture demonstrates on optimized builds, where copy propagation elides the view. Nesting is therefore only applied when the method the call binds to is the one a designation would rebind to; otherwise the call stays explicit, where its receiver cast preserves the binding. Assisted-by: Claude:claude-opus-5:Claude Code --- .../Correctness/DeconstructionTests.cs | 46 +++++++++++++++++++ .../TestCases/Pretty/DeconstructionTests.cs | 42 +++++++++++++++++ .../IL/Transforms/DeconstructionTransform.cs | 35 ++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Correctness/DeconstructionTests.cs b/ICSharpCode.Decompiler.Tests/TestCases/Correctness/DeconstructionTests.cs index dc7cdb091..4411b6b5e 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Correctness/DeconstructionTests.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Correctness/DeconstructionTests.cs @@ -168,6 +168,7 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Correctness NestedDeconstruction_Conversions_AfterAllDeconstructCalls(); NestedDeconstruction_TypedDeclaration_Conversions(new NestedOuter { Value = 5 }); NestedDeconstruction_DiscardWithSideEffectTargets(); + NestedDeconstruction_HiddenDeconstructMethod(default(HidingOuter)); NestedDeconstruction_SystemTupleSource(Tuple.Create(8, new NestedInner { Value = 4 })); NestedDeconstruction_CheckedConversions(new NestedOuter { Value = 9 }); NestedDeconstruction_GenericConstraintSource(new ConstrainedSource { Value = 11 }); @@ -303,6 +304,51 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Correctness (Get(0).IntProperty, (_, Get(1).My)) = new NestedOuter { Value = 31 }; } + public class HidingBase + { + public int Value; + + public void Deconstruct(out string a, out double b) + { + Console.WriteLine("HidingBase.Deconstruct"); + a = "base" + Value; + b = 0.5; + } + } + + public class HidingDerived : HidingBase + { + public new void Deconstruct(out string a, out double b) + { + Console.WriteLine("HidingDerived.Deconstruct"); + a = "derived"; + b = 99.5; + } + } + + public struct HidingOuter + { + public void Deconstruct(out int x, out HidingDerived d) + { + Console.WriteLine("HidingOuter.Deconstruct"); + x = 1; + d = new HidingDerived { Value = 5 }; + } + } + + // The base-typed view forces the call to bind to HidingBase.Deconstruct; a nested + // designation cannot express that, because it rebinds on the element's static type, + // where the hiding method wins. + public void NestedDeconstruction_HiddenDeconstructMethod(HidingOuter o) + { + Console.WriteLine("NestedDeconstruction_HiddenDeconstructMethod:"); + var (_, d) = o; + HidingBase b = d; + var (a, c) = b; + Console.WriteLine(a); + Console.WriteLine(c); + } + public int Side() { Console.WriteLine("Side()"); diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs index e873d6968..eaea65d21 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs @@ -22,6 +22,14 @@ using System.Runtime.InteropServices; namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty { + public class DeconstructionBase + { + } + + public class DeconstructionDerived : DeconstructionBase + { + } + public static class DeconstructionExt { public static void Deconstruct(this KeyValuePair pair, out K key, out V value) @@ -35,6 +43,27 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty item1 = tuple.Item1; item2 = tuple.Item2; } + + public static void Deconstruct(this DeconstructionBase b, out int a, out int c) + { + a = 1; + c = 2; + } + + public static void Deconstruct(this DeconstructionDerived d, out int a, out int c) + { + a = 3; + c = 4; + } + } + + public class DeconstructionOuter + { + public void Deconstruct(out int x, out DeconstructionDerived d) + { + x = 1; + d = new DeconstructionDerived(); + } } internal class DeconstructionTests @@ -529,6 +558,19 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty // The store opcode is sign-agnostic - stind.i4 reports int for a uint target and // stind.i1 reports sbyte for a byte one - so the element type of the target cannot // be taken from it: doing so refuses every one of these deconstructions. + // The IL calls the extension declared on the base type, forced by the cast. Folding + // this into a nested designation would rebind Deconstruct on the element's static + // type, where the extension declared on the derived type wins and returns different + // values, so the call has to stay explicit. + public void Nested_CompetingExtensionDeconstruct(DeconstructionOuter o) + { + o.Deconstruct(out var x, out var d); + ((DeconstructionBase)d).Deconstruct(out int a, out int c); + Console.WriteLine(x); + Console.WriteLine(a); + Console.WriteLine(c); + } + public unsafe void Pointer_NoConversion_Tuple_UInt(uint* p) { int value; diff --git a/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs b/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs index cbd566d86..ac287d673 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs @@ -477,6 +477,14 @@ namespace ICSharpCode.Decompiler.IL.Transforms pos = savedPos; continue; } + if (!BindsOnElementType(nested.Method, result.Type)) + { + // A nested designation rebinds Deconstruct on the element's static type + // when recompiled; if that picks a different method (member hiding), the + // call must stay explicit, where a cast can preserve the binding. + pos = savedPos; + continue; + } pos++; nested.Receiver = receiver; parent.NestedCalls[i] = nested; @@ -488,6 +496,33 @@ namespace ICSharpCode.Decompiler.IL.Transforms { return MatchLdLocOrLdLoca(target, out var v) && v == receiver; } + + static bool BindsOnElementType(IMethod method, IType elementType) + { + int outParamCount = method.Parameters.Count - (method.IsStatic ? 1 : 0); + IType type = elementType; + while (type != null) + { + if (!method.IsStatic && NormalizeTypeVisitor.TypeErasure.EquivalentTypes(type, method.DeclaringType)) + return true; + if (type.GetMethods(m => m.Name == "Deconstruct", GetMemberOptions.IgnoreInheritedMembers) + .Any(m => !m.IsStatic && m.Parameters.Count == outParamCount)) + { + // An instance Deconstruct of the same arity is declared on a type more + // derived than the called method's declaring type: it hides the called + // method (and wins over a called extension method). + return false; + } + type = type.DirectBaseTypes.FirstOrDefault(t => t.Kind == TypeKind.Class)!; + } + // The chain ended without seeing the declaring type, so an instance method's + // binding cannot be verified. An extension method is reached by its receiver + // type, and one declared on a more derived type wins over it; which extensions + // are in scope where the output is compiled is not known here, so the binding + // is only certain when the element type is the receiver type itself. + return method.IsStatic + && NormalizeTypeVisitor.TypeErasure.EquivalentTypes(elementType, method.Parameters[0].Type); + } } struct ConversionInfo