Why customed "-eq" do twice in Powershell?

42 Views Asked by At

I meet a weird issue when I was doing some OOP programming in PowerShell. To be specific, the code is as below:

class x {
    [int]$v1
    x([int]$a1) {
        $this.v1 = $a1
    }

    [bool] Equals([object]$b) {
        Write-Host "123"
        if ($b -is [int]) {
            return $this.v1 -eq $b
        }
        return $false
    }
}

$xi1 = [x]::new(2)
$bv = $xi1 -eq 3

The code contains a simple custom class x, and implement constructor and an Equals method, which overloads the -eq operator. However, the code will print "123" twice, why does it happen?

In my trials, if I change the last line to "... -eq 2", only one "123" would be printed. It seems if the first comparison be $false, it will try again, and convert the type of "$b" automatically (I find it via printing $b.GetType() as well).

I guess the powershell just do the auto-type-conversion and try again if the first comparison fails. But I cannot find this grammar in the official guide. Also, how to avoid this condition?

2

There are 2 best solutions below

0
Mathias R. Jessen On BEST ANSWER

I guess the [P]ower[S]hell just do the auto-type-conversion and try again if the first comparison fails.

That is exactly right! When $xi1 -eq 3 fails, $xi1 -eq [x]3 is attempted.

You can observe the resulting type conversion operation with Trace-Command:

PS ~> Trace-Command -Expression { $xi1 -eq 3 } -PSHost -Name TypeConversion
123
DEBUG: 2024-03-27 17:44:55.8141 TypeConversion Information: 0 : Constructor result: "x".
123
False

This behavior is encoded in the default equality comparison runtime binder, which can be found in Binders.cc:

var conversion = LanguagePrimitives.FigureConversion(arg.Value, targetType, out debase);
if (conversion.Rank == ConversionRank.Identity || conversion.Rank == ConversionRank.Assignable
    || (conversion.Rank == ConversionRank.NullToRef && targetType != typeof(PSReference)))
{
    // In these cases, no actual conversion is happening, and conversion.Converter will just return
    // the value to be converted. So there is no need to convert the value and compare again.
    return new DynamicMetaObject(toResult(objectEqualsCall).Cast(typeof(object)), target.CombineRestrictions(arg));
}

BindingRestrictions bindingRestrictions = target.CombineRestrictions(arg);
bindingRestrictions = bindingRestrictions.Merge(BinderUtils.GetOptionalVersionAndLanguageCheckForType(this, targetType, _version));

// If there is no conversion, then just rely on 'objectEqualsCall' which most likely will return false. If we attempted the
// conversion, we'd need extra code to catch an exception we know will happen just to return false.
if (conversion.Rank == ConversionRank.None)
{
    return new DynamicMetaObject(toResult(objectEqualsCall).Cast(typeof(object)), bindingRestrictions);
}

// A conversion exists.  Generate:
//    tmp = target.Equals(arg)
//    try {
//        if (!tmp) { tmp = target.Equals(Convert(arg, target.GetType())) }
//    } catch (InvalidCastException) { tmp = false }
//    return (operator is -eq/-ceq/-ieq) ? tmp : !tmp
var resultTmp = Expression.Parameter(typeof(bool));

Expression secondEqualsCall =
    Expression.Call(target.Expression.Cast(typeof(object)),
                    CachedReflectionInfo.Object_Equals,
                    PSConvertBinder.InvokeConverter(conversion, arg.Expression, targetType, debase, ExpressionCache.InvariantCulture).Cast(typeof(object)));

Here we can see that if PowerShell can decide on a meaningful conversion (eg. by casting [x]$b), then it'll try that and do a second comparison attempt against the results of that

1
mclayton On

Here's the source code for the -eq operator:

https://github.com/PowerShell/PowerShell/blob/ff3c8478983bfb566036362f8b629109fef8d180/src/System.Management.Automation/engine/LanguagePrimitives.cs#L629

As @SantiagoSquarzon suggested in comments, the PowerShell codepath is trying two things:

  • Is 3 equal to $xi1?
  • If not, try to convert 3 to an x and compare that

The second bullet is happening here:

https://github.com/PowerShell/PowerShell/blob/ff3c8478983bfb566036362f8b629109fef8d180/src/System.Management.Automation/engine/LanguagePrimitives.cs#L697

    object secondConverted = LanguagePrimitives.ConvertTo(second, firstType, culture);
    return first.Equals(secondConverted);

In other words, something like this:

if( $xi1.Equals(3) )
{
   return $true
}
else
{
   $xi2 = [x] 3;
   return $xi1.Equals($xi2);
}

You can see this if you output add some more logging to your Equals method:

class x {
    [int]$v1
    x([int]$a1) {
        $this.v1 = $a1
    }

    [bool] Equals([object]$b) {
        write-host "`$b.Type = '$($b.GetType().FullName)'"
        write-host "`$b.v1 = '$($b.v1)'"
        if ($b -is [int]) {
            return $this.v1 -eq $b
        }
        return $false
    }
}

$xi1 = [x]::new(2)
$bv = $xi1 -eq 3

You'll see this:

$b.Type = 'System.Int32'
$b.v1 = ''
$b.Type = 'x'
$b.v1 = '3'

and the second pair of lines show $b is an object of type x, but its value is 3 because PowerShell has converted 3 into an x as the second part of its tests in the -eq operator.