FatalBulletHit
FatalBulletHit

Reputation: 842

Unexpected Behaviour with Where-Object

I just came across an unexpected behaviour of Where-Object which I couldn't find any explanation for:

$foo = $null | Where-Object {$false}
$foo -eq $null
> True


($null, 1 | Measure-Object).Count
> 1

($foo, 1 | Measure-Object).Count
> 1


($null, $null, 1 | Measure-Object).Count
> 1

($foo, $foo, 1 | Measure-Object).Count
> 0

If the condition of Where-Object is false, $foo should be $null (which appears to be correct).

However, piping $foo at least twice before any value into the pipeline seems to break it.

What is causing this?


Other inconsistencies:

($foo, $null, 1 | Measure-Object).Count
> 1

($foo, $null, $foo, 1 | Measure-Object).Count
> 0

($null, $foo, $null, 1 | Measure-Object).Count
> 1

($foo, 1, $foo, $foo | Measure-Object).Count
> 1

($null, $foo, $null, $foo, 1 | Measure-Object).Count
> 0

Upvotes: 4

Views: 907

Answers (2)

mklement0
mklement0

Reputation: 437197

tl;dr:

  • Not all apparent $null values are the same, as Jeroen Mostert's comments indicate: PowerShell has two types of null that situationally behave differently - see the next section.

  • Additionally, you're seeing perhaps surprising Measure-Object behavior and a pipeline bug - see the bottom section.

    • It's best to eliminate Measure-Object from your test commands and simply invoke .Count directly on your arrays; e.g. (the simplest way to create the type of null as in your question is: $foo = & {}):

      • ($foo, $null, 1).Count yields 3
      • ($null, $foo, $null, $foo, 1).Count yields 5
    • As you can see, both types of null (discussed below) properly become elements of an array.


There are two distinct kinds of null values in PowerShell:

  • There's bona fide scalar null (corresponding to null in C#, for instance).

    • This null is contained in the automatic $null variable.
    • .NET methods may return it. (While PowerShell code may output it too, doing so is best avoided).
  • There's also the enumerable "collection null" (also called "AutomationNull", based on its class name), which is technically the System.Management.Automation.Internal.AutomationNull.Value singleton, which is itself a [psobject] instance.

    • This value is technically output by the pipeline when PowerShell commands (both binary cmdlets and PowerShell scripts/functions) produce no output.
    • The simplest way to get this value is with & {} , i.e. by executing an empty script block; of course, you can also use [System.Management.Automation.Internal.AutomationNull]::Value explicitly).

Unfortunately, the collection null value is nontrivial to distinguish from the scalar null, as of PowerShell 7.2:

  • GitHub issue #13465 proposes allowing detection of collection null via $var -is [AutomationNull] in a future PowerShell version.

  • For now, there are several workarounds for testing whether a given value $var contains collection null; perhaps the simplest (but non-obvious) is:

    • $null -eq $var -and $var -is [psobject] is $true only if $var contains the collection null value, because only collection null is technically an object.

Behavioral differences:

  • In expression contexts and in parameter binding, there is no difference in that collection null is implicitly converted to $null.

    • Note that this means that you cannot pass collection null as an argument - see the discussion in GitHub issue #9150.

    • The exception in the context of expressions is the LHS of operators that support collections as their LHS: they treat collection null as an empty collection and therefore evaluate to an empty array (@()) rather than $null:

      • E.g., $var -replace 'foo' | ForEach-Object { 'hi' } prints 'hi' only if $var is scalar $null, not with with collection null, because the -replace operation then outputs an empty array, which sends nothing through the pipeline.
      • See GitHub issue #3866.
  • In the pipeline:

    • Scalar $null is sent through the pipeline - it behaves like a single object: $null | ForEach-Object { '$_ is $null? ' + ($null -eq $_) } prints '$_ is $null? True';

    • Collection null is not sent through the pipeline - it behaves like a collection without elements; that is, just like @() | ForEach-Object { 'hi' } (sending an empty array), & {} | ForEach-Object { 'hi' } sends nothing through the pipeline, because there is nothing to enumerate, and therefore never outputs 'hi'.

    • Curiously, by contrast, in a foreach loop statement (as opposed to the ForEach-Object cmdlet) scalar $null too is not enumerated and the loop body is never entered in the following (ditto for collection null):
      foreach ($i in $null) { 'hi' }


Measure-Object and pipeline problems:

  • Measure-Object generally ignores $null values, presumably by design.

    • This is discussed in GitHub issue #10905, which proposes introducing an -IncludeNull switch to support considering $null values on an opt-in basis. (The default behavior will not change so as not to break backward compatibility.)
  • However, you've discovered an outright bug in PowerShell's pipeline with respect to multi-object input involving collection nulls (as of PowerShell 7.1.2) , which Measure-Object only surfaces, as you've noted yourself:

    • On encountering a second collection null in multi-object input, sending objects through the pipeline unexpectedly stops:

      • E.g., (1, (& {}), 2, (& {}), 3, 4, 5 | Measure-Object).Count yields just 2: only 1 and 2 are counted (the collection nulls themselves are not sent through the pipeline), because the second collection null unexpectedly stops enumeration, so that the remaining objects - 3, 4, and 5 - aren't even sent to Measure-Object.
    • See GitHub issue #14920.

Upvotes: 6

FatalBulletHit
FatalBulletHit

Reputation: 842

To add to mklement0's very detailed and much appreciated answer, I want to share the workaround I used:

$numbers = 3, 42, 7, 69, 13
$no1 = $numbers | Where-Object {$_ -eq 1}
$no2 = $numbers | Where-Object {$_ -eq 2}
$no3 = $numbers | Where-Object {$_ -eq 3}

Instead of piping the variables directly to ForEach-Object, which produces no output ... :

$no1, $no2, $no3 | ForEach-Object {$_}
> 

... pipe the variable names to ForEach-Object and make use of Get-Variable to get the desired result:

'no1', 'no2', 'no3' | ForEach-Object {(Get-Variable $_).Value}
> 3

Upvotes: 0

Related Questions