3

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
FatalBulletHit
  • 762
  • 6
  • 22
  • 4
    `$foo` is not precisely `$null`, which can be verified by checking `$foo.psbase` (a true `$null` gives nothing for `.psbase`). This is the runtime doing something wonky with wrapping objects. – Jeroen Mostert Feb 28 '21 at 12:43
  • 3
    Clearer perhaps is `$foo -is [psobject]`, which is `$True` for `... | Where-Object {$False}` and `$False` for `$null`. It's a `PSObject` that implicitly converts to `$null`, but not with perfect transparency. – Jeroen Mostert Feb 28 '21 at 12:54
  • See also this SO Q&A. https://stackoverflow.com/questions/66396721/in-powershell-why-is-null-lt-0-true-is-that-reliable/66397251#66397251 – postanote Mar 01 '21 at 00:42

2 Answers2

4

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.

mklement0
  • 382,024
  • 64
  • 607
  • 775
0

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
FatalBulletHit
  • 762
  • 6
  • 22
  • 1
    Thanks; a simpler - but also obscure - workaround is: `[array] $no1 + $no2 + $no3 | ForEach-Object {$_}`. By using array concatenation, the collection-null values are effectively eliminated. – mklement0 Mar 01 '21 at 03:35