E530 Fix double execution of `Target` expressions in null conditionals by SeeminglyScience · Pull Request #24765 · PowerShell/PowerShell · GitHub
[go: up one dir, main page]

Skip to content

Fix double execution of Target expressions in null conditionals#24765

Open
SeeminglyScience wants to merge 4 commits intomasterfrom
SeeminglyScience/issue24716
Open

Fix double execution of Target expressions in null conditionals#24765
SeeminglyScience wants to merge 4 commits intomasterfrom
SeeminglyScience/issue24716

Conversation

@SeeminglyScience
Copy link
Contributor

Fixes #24716

PR Summary

Null conditional expressions (both index and member access) currently evaluate the "target" expression twice. This change saves the "target" result to a temporary variable to compare against.

This change is similar to #12667 which was done to fix null coalesce expressions.

Disassembly

As compiler changes can be challenging to review, I've included some disassembly of compiled scriptblocks before and after the changes. The module uses reflection to get the delegate compiled internally for the end block, and retrieves the associated LINQ expression tree. That expression tree is then interpreted into representative C#

These are manually formatted and with some of the irrelevant parts removed, full disassembly can be viewed here.

Member Expressions

Before Changes (click to expand)

$i = 0
$null = ($i++)?.ToString()
// $i = 0
locals.Item009 = 0;

// $null = ($i++)?.ToString()
LanguagePrimitives.IsNull(
    // This is an Expression.Block, not a delegate.
    {
        object @object = locals.Item009;
        locals.Item009 = Fake.Dynamic<Func<CallSite, object, object>>(PSVariableAssignmentBinder.Get())(
            Fake.Dynamic<Func<CallSite, object, object>>(PSUnaryOperationBinder.Get(ExpressionType.Increment))(
                @object));

        return (@object == null) ? 0 : @object;
    })
    ? null
    : Fake.Dynamic<Func<CallSite, object, object>>(
        PSInvokeMemberBinder.Get(
            "ToString",
            classScope: null,
            new CallInfo(0),
            static: false,
            nonEnumerating: false,
            constraints: null))
        (
            // This is an Expression.Block, not a delegate.
            {
                object @object = locals.Item009;
                locals.Item009 = Fake.Dynamic<Func<CallSite, object, object>>(PSVariableAssignmentBinder.Get())(
                    Fake.Dynamic<Func<CallSite, object, object>>(PSUnaryOperationBinder.Get(ExpressionType.Increment))(
                        @object));

                return (@object == null) ? 0 : @object;
            });

After - target not strongly typed (click to expand)

$i = 0
$null = ($i++)?.ToString()
// $i = 0
locals.Item009 = 0;

// $null = ($i++)?.ToString()
object object1 =
    // This is an Expression.Block, not a delegate.
    {
        object object2 = locals.Item009;
        locals.Item009 = Fake.Dynamic<Func<CallSite, object, object>>(PSVariableAssignmentBinder.Get())(
            Fake.Dynamic<Func<CallSite, object, object>>(PSUnaryOperationBinder.Get(ExpressionType.Increment))(
                object2));

        return (object2 == null) ? 0 : object2;
    };

LanguagePrimitives.IsNull(object1)
    ? null
    : Fake.Dynamic<Func<CallSite, object, object>>(
        PSInvokeMemberBinder.Get(
            "ToString",
            classScope: null,
            new CallInfo(0),
            static: false,
            nonEnumerating: false,
            constraints: null))(
                object1);

After - target strongly typed as valuetype (click to expand)

```powershell [int] $i = 0 $null = ($i++)?.ToString() ```
// [int] $i = 0
locals.Item009 = 0;
// $null = ($i++)?.ToString()
Fake.Dynamic<Func<CallSite, int, object>>(
    PSInvokeMemberBinder.Get(
        "ToString",
        classScope: null,
        new CallInfo(0),
        static: false,
        nonEnumerating: false,
        constraints: null))(
            // This is an Expression.Block, not a delegate.
            {
                int @int = locals.Item009;
                locals.Item009 = Fake.Dynamic<Func<CallSite, object, int>>(
                    PSConvertBinder.Get(typeof(int)))(
                        Fake.Dynamic<Func<CallSite, int, object>>(PSUnaryOperationBinder.Get(ExpressionType.Increment))(
                            @int));

                return @int;
            });

After - target strongly typed as ref type (click to expand)

[string] $i = ''
$null = ($i += 'x')?.Length
// [string] $i = ''
locals.Item009 = "";

// $null = ($i += 'x')?.Length
string @string = locals.Item009 = Fake.Dynamic<Func<CallSite, object, string>>(PSConvertBinder.Get(typeof(string)))(
    Fake.Dynamic<Func<CallSite, string, string, object>>(
        PSBinaryOperationBinder.Get(ExpressionType.Add, ignoreCase: true,scalarCompare: false))(
            locals.Item009,
            "x"));

return LanguagePrimitives.IsNull(@string)
    ? null
    : Fake.Dynamic<Func<CallSite, string, object>>(
        PSGetMemberBinder.Get("Length", type: null, static: false, nonEnumerating: false))(
            @string);

Index Expressions

Before Changes (click to expand)

$i = 0
$null = ($i++)?[0]
// $i = 0
locals.Item009 = 0;

// $null = ($i++)?[0]
LanguagePrimitives.IsNull(
    // This is an Expression.Block, not a delegate.
    {
        object @object = locals.Item009;
        locals.Item009 = Fake.Dynamic<Func<CallSite, object, object>>(PSVariableAssignmentBinder.Get())(
            Fake.Dynamic<Func<CallSite, object, object>>(PSUnaryOperationBinder.Get(ExpressionType.Increment))(
                @object));

        return (@object == null) ? 0 : @object;
    })
    ? null
    : Fake.Dynamic<Func<CallSite, object, int, object>>(
        PSGetIndexBinder.Get(
            argCount: 1,
            constraints: null,
            allowSlicing: true))
        (
            // This is an Expression.Block, not a delegate.
            {
                object @object = locals.Item009;
                locals.Item009 = Fake.Dynamic<Func<CallSite, object, object>>(PSVariableAssignmentBinder.Get())(
                    Fake.Dynamic<Func<CallSite, object, object>>(PSUnaryOperationBinder.Get(ExpressionType.Increment))(
                        @object));

                return (@object == null) ? 0 : @object;
            },
            0);

After - target not strongly typed (click to expand)

$i = 0
$null = ($i++)?[0]
// $i = 0
locals.Item009 = 0;

// $null = ($i++)?[0]
object object1 =
    // This is an Expression.Block, not a delegate.
    {
        object object2 = locals.Item009;
        locals.Item009 = Fake.Dynamic<Func<CallSite, object, object>>(PSVariableAssignmentBinder.Get())(
            Fake.Dynamic<Func<CallSite, object, object>>(
                PSUnaryOperationBinder.Get(ExpressionType.Increment))
            (object2));

        return (object2 == null) ? 0 : object2;
    };

LanguagePrimitives.IsNull(object1)
    ? null
    : Fake.Dynamic<Func<CallSite, object, int, object>>(
        PSGetIndexBinder.Get(
            argCount: 1,
            constraints: null,
            allowSlicing: true))
        (
            object1,
            0);

After - target strongly typed as valuetype (click to expand)

[int] $i = 0
$null = ($i++)?[0]
// [int] $i = 0
locals.Item009 = 0;

// $null = ($i++)?[0]
Fake.Dynamic<Func<CallSite, int, int, object>>(
    PSGetIndexBinder.Get(argCount: 1, constraints: null, allowSlicing: true))
    (
        // This is an Expression.Block, not a delegate.
        {
           
10BC0
 int @int = locals.Item009;
            locals.Item009 = Fake.Dynamic<Func<CallSite, object, int>>(PSConvertBinder.Get(typeof(int)))(Fake.Dynamic<Func<CallSite, int, object>>(PSUnaryOperationBinder.Get(ExpressionType.Increment))(@int));

            return @int;
        },
        0);

After - target strongly typed as ref type (click to expand)

[string] $i = ''
$null = ($i += 'x')?[0]
locals.Item009 = "";
string @string = locals.Item009 = Fake.Dynamic<Func<CallSite, object, string>>(
    PSConvertBinder.Get(typeof(string)))
    (
        Fake.Dynamic<Func<CallSite, string, string, object>>(
            PSBinaryOperationBinder.Get(
                ExpressionType.Add,
                ignoreCase: true,
                scalarCompare: false))
            (
                locals.Item009,
                "x"));

LanguagePrimitives.IsNull(@string)
    ? null
    : Fake.Dynamic<Func<CallSite, string, int, object>>(
        PSGetIndexBinder.Get(
            argCount: 1,
            constraints: null,
            allowSlicing: true))
        (
            @string,
            0);

PR Context

PR Checklist

@SeeminglyScience
Copy link
Contributor Author

/azp run

@azure-pipelines
Copy link
Azure Pipelines successfully started running 5 pipeline(s).

@microsoft-github-policy-service microsoft-github-policy-service bot added the Review - Needed The PR is being reviewed label Jan 23, 2025
Copy link
Member
@daxian-dbw daxian-dbw left a comment

Choose a reason for hiding this comment

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

@SeeminglyScience Thanks for fixing this! Sorry for the delay in review.

: DynamicExpression.Dynamic(PSGetDynamicMemberBinder.Get(_memberFunctionType, memberExpressionAst.Static), typeof(object), target, Compile(memberExpressionAst.Member));

return memberExpressionAst.NullConditional ? GetNullConditionalWrappedExpression(target, memberAccessExpr) : memberAccessExpr;
return MaybeWrapInNullConditionalExpression(
Copy link
Member
@daxian-dbw daxian-dbw Mar 13, 2025

Choose a reason for hiding this comment

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

The way you abstract the code with MaybeWrapInNullConditionalExpression feels complex and not very readable.
Take this particular call site as an example, how about abstract it in the way shown with the code below? With this approach, we only need to make minor changes to the existing code

  • add var targetToUse = section
  • replace target with targetToUse when constructing the member-access expression
  • change the call to GetNullConditionalWrappedExpression slightly
var target = CompileExpressionOperand(memberExpressionAst.Expression);

// **** Use a temp variable to represent the target for null-conditional ****
var targetToUse = memberExpressionAst.NullConditional
    ? Expression.Parameter(target.Type)
    : target;

// If the ?. operator is used for null conditional check, add the null conditional expression.
// **** Generate the member-access expression with `targetToUse` ****
var memberNameAst = memberExpressionAst.Member as StringConstantExpressionAst;
Expression memberAccessExpr = memberNameAst != null
    ? DynamicExpression.Dynamic(PSGetMemberBinder.Get(memberNameAst.Value, _memberFunctionType, memberExpressionAst.Static), typeof(object), targetToUse)
    : DynamicExpression.Dynamic(PSGetDynamicMemberBinder.Get(_memberFunctionType, memberExpressionAst.Static), typeof(object), targetToUse, Compile(memberExpressionAst.Member));

return memberExpressionAst.NullConditional
    ? GetNullConditionalWrappedExpression((ParameterExpression)targetToUse, target, memberAccessExpr)
    : memberAccessExpr;

And change GetNullConditionalWrappedExpression to the following:

private static Expression GetNullConditionalWrappedExpression(ParameterExpression temp, Expression targetExpr, Expression memberAccessExpression)
{
  return Expression.Block(
      typeof(object),
      [temp],
      [
          Expression.Assign(temp, targetExpr),
          Expression.Condition(
              Expression.Call(
                  CachedReflectionInfo.LanguagePrimitives_IsNull,
                  temp.Cast(typeof(object))),
              ExpressionCache.NullConstant,
              memberAccessExpression),
      ]);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Definitely agree on the complexity 💯

That suggestion might work! I'll give it a go when I get a minute. Thanks Dongbo!

@SeeminglyScience
Copy link
Contributor Author

/azp run

@azure-pipelines
Copy link
Azure Pipelines successfully started running 1 pipeline(s).

@SeeminglyScience
Copy link
Contributor Author
SeeminglyScience commented Mar 27, 2025

trying to force tests to rerun had to pull in changes from master

@SeeminglyScience
Copy link
Contributor Author

/azp run

@azure-pipelines
Copy link
Azure Pipelines successfully started running 1 pipeline(s).

@daxian-dbw daxian-dbw moved this from Done to PR In Progress in PowerShell Team Reviews/Investigations Mar 31, 2025
SeeminglyScience and others added 2 commits March 31, 2025 14:54
Now uses a temp variable created in the caller instead of a delegate.

Thanks to @daxian-dbw for the pattern recommendation!
@iSazonov iSazonov added the CL-General Indicates that a PR should be marked as a general cmdlet change in the Change Log label Apr 1, 2025
@daxian-dbw
Copy link
Member

@SeeminglyScience Some tests are failing in CI, can you please take a look?

@microsoft-github-policy-service microsoft-github-policy-service bot removed the Review - Needed The PR is being reviewed label Apr 21, 2025
@microsoft-github-policy-service microsoft-github-policy-service bot added the Review - Needed The PR is being reviewed label Apr 28, 2025
@microsoft-github-policy-service
Copy link
Contributor

This pull request has been automatically marked as Review Needed because it has been there has not been any activity for 7 days.
Maintainer, please provide feedback and/or mark it as Waiting on Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CL-General Indicates that a PR should be marked as a general cmdlet change in the Change Log Review - Needed The PR is being reviewed

Projects

Status: PR In Progress

Development

Successfully merging this pull request may close these issues.

Null Conditional Member Access Invokes Argument Multiple Times

4 participants

0