7

An interesting and weird thing I noticed writing PowerShell classes lines:

class A {

    [object] WhereObject(){
        return @(1,2) | Where-Object {$_ -gt 2}
    }

    [object] Where(){
        return @(1,2).Where( {$_ -gt 2})
    }
}

$a = new-object A
$a.WhereObject() # Throw exception Index was out of range. Must be non-negative and less than the size of the collection.

$a.Where() # Works well

It looks like it is by design. Why does it work so?

Workaround

Function which explicitly convert "empty" value to $null:

function Get-NullIfEmpty {
   param(
       [Parameter(ValueFromPipeline=$true)][array] $CollectionOrEmtpy
   )

   begin { $output = $null }

   process
   {
      if($output -eq $null -and $CollectionOrEmtpy -ne $null){
          $output = @()
      }
      foreach ($element in $CollectionOrEmtpy)
      {
          $output += $element
      }
   }

   end { return $output }
}

In this case, the method will look like:

[object] WhereObject() {
   return @(1,2) | Where-Object {$_ -gt 2} | Get-NullIfEmpty
}

I tried to return an empty array from the class method, but it is also tricky because for a regular function an empty array means "nothing" as well. If you have a call chain like method1 -> function -> method2 - method1 throw the same exception. Because the function converts an empty array to nothing.

So converting to $null is optimal in my case :)

Peter Mortensen
  • 30,738
  • 21
  • 105
  • 131
Ilya
  • 436
  • 4
  • 15
  • The only difference that WhereObjectCommand does not write any result to pipeline in case no matches. But .Where extension occasionally return empty array :) – Ilya Jun 20 '18 at 21:07

2 Answers2

9
  • The (PowerShell v4+) .Where() method, which is evaluated in expression mode, always returns an instance of [System.Collections.ObjectModel.Collection[psobject]]:

    • If no input objects match, that instance is simply empty (it has no elements and its .Count property returns 0).
  • By contrast, the Where-Object cmdlet uses pipeline semantics, which implies the following output behavior:

    • If nothing is output (if nothing matches the filter script block), the return value is a "null collection", which is technically the [System.Management.Automation.Internal.AutomationNull]::Value singleton.

    • If a single item matches, that item is output as-is.

    • If multiple items match and they are collected in a variable / evaluated as part of an expression, they are collected in an [object[]] array.


As for the specific symptom - which Bruce Payette's answer has since confirmed to be a bug.

  • Update: The bug is fixed since at least v7; returning "nothing" (AutomationNull) is now coerced to $null; see the original bug report on GitHub.

An internal [List[object]] instance is used to collect the method call's output, executed via an internal pipeline. If that internal pipeline outputs "nothing" - i.e., [System.Management.Automation.Internal.AutomationNull]::Value - no object is added to the list. However, subsequent code assumes that there is at least one object in the list and blindly accesses index 0, causing the error at hand.

A simpler reproduction of the problem:

class A {
  # Try to return [System.Management.Automation.Internal.AutomationNull]::Value
  # (which is what `& {}` produces).
  [object] WhereObject(){ return & {} }
}

$a = new-object A

$a.WhereObject() # Throw exception Index was out of range. Must be non-negative and less than the size of the collection.

As for the desirable behavior:

It seems that the fix will result in $null getting output if the method's code returns the "null collection", using C#'s default-value feature - see this comment.

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

The .Where() operator always returns a Collection<PSObject>. The pipeline case however, returns nothing. This is a problem because the code that invokes the scriptblock expects there to be an object in the result List i.e. result.Count == 1. There are no objects in the pipeline case so you get an index-out-of-range error. So this is a bug. We should still generate an error but it should be "non-void methods must return a value" or some such. BTW - the code in question is here.

Bruce Payette
  • 2,511
  • 10
  • 8
  • As usual, thanks for the peek behind the curtain. However, I don't think that a fix should result in a (different) error still being generated, given that _anything_ can be cast to `[object]` (at least in C#), and that something like `[object] (& {})` works fine (is a no-op). – mklement0 Jun 20 '18 at 22:18
  • Why $null cannot be result of the method which should return object? I would also does not expect exception from this situation... – Ilya Jun 21 '18 at 06:31
  • 1
    No - the exception is a bug but nothing is not null. But maybe autoconverting nothing to null is the right answer here? Thoughts? – Bruce Payette Jun 21 '18 at 15:49
  • Returning $null in this case is what I implemented. – Bruce Payette Jun 22 '18 at 20:34
  • The one thing I don't like every time I should check for $null before iterating. because: $null | Foreach-Object { $_.someprops.prop = "111" } # throws exception; &{} | Foreach-Object { $_.someprops.prop } # no throw because no iterating; – Ilya Jun 26 '18 at 07:33