Why does this filter fail to accept certain $null values from the pipeline?

288 views Asked by At

Context

Consider the following helper function:

Filter If-Null(
   [Parameter(ValueFromPipeline=$true)]$value,
   [Parameter(Position=0)]$default
) {
   Write-Verbose "If ($value) {$value} Else {$default}"
   if ($value) {$value} else {$default}
}

It's basically a null-coalescing operator implemented as a pipeline function. It's supposed to work like so:

PS> $myVar = $null
PS> $myVar | If-Null "myDefault" -Verbose
VERBOSE: If () {} Else {myDefault}
myDefault

However, when I set $myVar to the first element in an empty array...

PS> $myVar = @() | Select-Object -First 1

...which should effectively be the same as $null...

PS> $myVar -eq $null
True
PS> -not $myVar
True

...then the piping does not work anymore:

PS> $myVar | If-Null "myDefault" -Verbose

There is not output at all. Not even the verbose print. Which means If-Null is not even executed.

The Question

So it seems like @() | select -f 1, although being -eq to $null, is a somewhat different $null that somehow breaks piping?

Can anyone explain this behaviour? What am I missing?

Additional Information

PS> (@() | select -f 1).GetType()
You cannot call a method on a null-valued expression.
At line:1 char:1
+ (@() | select -f 1).GetType()
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) [], RuntimeException
    + FullyQualifiedErrorId : InvokeMethodOnNull

PS> (@() | select -f 1) | Get-Member
Get-Member : You must specify an object for the Get-Member cmdlet.
At line:1 char:23
+ (@() | select -f 1) | Get-Member
+                       ~~~~~~~~~~
    + CategoryInfo          : CloseError: (:) [Get-Member], InvalidOperationException
    + FullyQualifiedErrorId : NoObjectInGetMember,Microsoft.PowerShell.Commands.GetMemberCommand
PS> $PSVersionTable

Name                           Value
----                           -----
PSVersion                      5.0.10586.117
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0...}
BuildVersion                   10.0.10586.117
CLRVersion                     4.0.30319.42000
WSManStackVersion              3.0
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1

Solution

Ansgar's explanation is correct (a better explanation can be found in mklement0's answer to the duplicate question). I just wanted to share my solution to the problem.

I fixed If-Null such that it returns the $default even when nothing is processed:

Function If-Null(
    [Parameter(ValueFromPipeline = $true)]$value, 
    [Parameter(Position = 0)]$default
) {
    Process { 
        $processedSomething = $true
        If ($value) { $value } Else { $default } 
    }

    # This makes sure the $default is returned even when the input was an empty array or of
    # type [System.Management.Automation.Internal.AutomationNull]::Value (which prevents
    # execution of the Process block).
    End { If (-not $processedSomething) { $default }}
}

This version does now correctly handle empty pipeline results:

PS> @() | select -f 1 | If-Null myDefault
myDefault
1

There are 1 answers

0
Ansgar Wiechers On BEST ANSWER

Arrays are unrolled by pipelines, so that each array element is passed on separately. If you pass an empty array into a pipeline it essentially is unrolled into nothing, meaning that the downstream cmdlet is never invoked, thus leaving you with an empty variable.

You can observe this behavior by passing $null and @() into a loop that just echoes a string for each input item:

PS C:\> @() | % { 'foo' }    # no output here!
PS C:\> $null | % { 'foo' }  # output: foo
foo

Depending on the context this is different from a variable with the "value" $null. Even though in most cases PowerShell will automatically convert the "empty" variable to a value of $null (as seen in your checks) it doesn't do so when passing the variable into the pipeline. In that case you still don't pass anything into the pipeline, hence your filter is never invoked.