Skip to content

Add script block support to containment operators #24748

Open

Description

@surfingoldelephant

Summary of the new feature / enhancement

As a PowerShell user, I often need to check if at least one element of a collection satisfies a predicate. I highly value the ability to do so in a succinct, efficient and PowerShell-idiomatic manner.

I propose extending the existing containment operators (-contains, -in, -notcontains, -notin) to treat a script block operand as a predicate, so that these operators can:

  • Be used to evaluate individual elements of a collection against a script block, in which $_/$PSItem refers to the currently evaluated element.
  • Maintain their existing ability to:
    • Short-circuit, so that unnecessary processing is avoided if the predicate is satisfied.
    • Generate a boolean result.

Example usage:

# With this proposal, the following would be possible:
$array = 1, 2, 3

$array -contains { $_ % 2 -eq 0 } 
# True
# The second element evaluates to True.
# Processing is short-circuited, so the third element is not evaluated.

{ $_ - 5 -gt 0 } -notin $array 
# True
# All elements evaluate to False. 

Currently in PowerShell, the first example is typically approached in one of the following manners:

# Where-Object + Select-Object:
# @(...) is necessary to ensure Count reliably reflects the number of emitted objects.
[bool] @($array | Where-Object { $_ % 2 -eq 0 } | Select-Object -First 1).Count

# Where() method with mode overload:
[bool] $array.Where({ $_ % 2 -eq 0 }, 'First').Count

# foreach loop + break:
$contains = $false
foreach ($item in $array) { 
    if ($item % 2 -eq 0) { $contains = $true; break } 
}
$contains

While these approaches achieve the desired result, they are not particularly convenient due to the explicit need to short-circuit and generate a boolean result. This proposal addresses the inconvenience without deviating from established PS semantics (e.g., script block support was added to -Replace in PS v6).

Backwards compatibility

It would no longer be possible to check for reference equality between a script block and elements of a collection using a containment operator.

$sb = { 'foo' }

$sb, { 'bar' } -contains $sb       # True
{ 'foo' }, { 'bar' } -contains $sb # False

Addendum

Patrick Meinecke (@SeeminglyScience) found a handful of existing projects that use a containment operator to check for script block reference equality.

Michael Klement (@mklement0) notes that a solution exists whereby implementation of this proposal can avoid the breaking change identified above.

  • If the element at hand is a [scriptblock] instance, test for reference equality (which is what already happens currently).
  • Otherwise, invoke the comparison operand's script block to evaluate it against the input element at hand, as proposed in the initial post.

Activity

  1. added
    Issue-Enhancementthe issue is more of a feature request than a bug
    Needs-TriageThe issue is new and needs to be triaged by a work group.
    on Jan 7, 2025
  2. mklement0 commented on Jan 7, 2025

    @mklement0
    Contributor

    I like the idea; it reminds me of the following RFC from 2018, which proposed introducing -foreach and -where operators, but was rejected, unfortunately:

    GitHub
    RFC (Request for Comments) documents for community feedback on design changes and improvements to PowerShell ecosystem - PowerShell/PowerShell-RFC
  3. SeeminglyScience commented on Jan 8, 2025

    @SeeminglyScience
    Contributor

    Backwards compatibility

    It would no longer be possible to check for reference equality between a script block and elements of a collection using a containment operator.

    $sb = { 'foo' }
    
    $sb, { 'bar' } -contains $sb       # True
    { 'foo' }, { 'bar' } -contains $sb # False

    However, I don't see this as being a likely real-world scenario, so believe it amounts to a bucket 3 breaking change.

    I was able to find a few real world examples in existing projects on github with the search path:*.ps1 / -(not)?(contains|in) \$(sb|scriptBlock)/. Not all of the results are actual ScriptBlock objects of course but a good bit of them are, and their usage makes sense.

  4. surfingoldelephant commented on Jan 8, 2025

    @surfingoldelephant
    Author

    I was able to find a few real world examples in existing projects on github with the search path:*.ps1 / -(not)?(contains|in) \$(sb|scriptBlock)/.

    Nice find, thank you. I've updated the issue.

  5. jhoneill commented on Jan 9, 2025

    @jhoneill

    Currently in PowerShell, the first example is typically approached in one of the following manners:

    # Where-Object + Select-Object:
    # @(...) is necessary to ensure Count reliably reflects the number of emitted objects.
    [bool] @($array | Where-Object { $_ % 2 -eq 0 } | Select-Object -First 1).Count
    
    # Where() method with mode overload:
    [bool] $array.Where({ $_ % 2 -eq 0 }, 'First').Count
    
    # foreach loop + break:
    $contains = $false
    foreach ($item in $array) { 
        if ($item % 2 -eq 0) { $contains = $true; break } 
    }
    $contains

    Why would

     $array.ForEach{$_ % 2} -contains 0
    

    Not work ? (side note I was taught to use () with .ForEach and .Where, but the script block can be written without them provided there is no space before the { )

    Of the things the team could find time for (and changing operators is a "core" thing, hard to add in "community" code, like a new command) making this syntax more compact probably won't make the cut.

    What is a lot easier to do is leave the operators alone, and implement a new option for the where extension on arrays, arraylists etc. From the about_arrays help

    There is one definition for the Where method.
        Where(scriptblock expression[, WhereOperatorSelectionMode mode [, int numberToReturn]])
    
    <snip> The acceptable values for mode are:
    -   Default (0) - Return all items
    -   First (1) - Return the first item
    -   Last (2) - Return the last item
    -   SkipUntil (3) - Skip items until condition is true, the return the remaining items
    -   Until (4) - Return all items until condition is true
    -   Split (5) - Return an array of two elements
        -   The first element contains matching items
        -   The second element contains the remaining items
    

    It should be a lot less work to add an option

    - Boolean (6) Returns Boolean true, if first would return anything - even if that 
      item is Boolean false, ; returns boolean false if no items are found. 
      This option ignores any value in the number to return 
    

    There is a an example in the help

        $computers = "Server01", "Server02", "Server03", "localhost", "Server04"
        # Find the first available online server.
        $computers.Where({ Test-Connection $_ }, 'SkipUntil', 1)
    

    A Boolean option

      $ServiceAvailable = $computers.Where({ Test-Connection $_ }, 'Boolean')
    

    Would give true/false instead of a server name, and would avoid all the jumping through hoops when a where returns a single false-equivalent.

  6. surfingoldelephant commented on Jan 9, 2025

    @surfingoldelephant
    Author

    Why would

    $array.ForEach{$_ % 2} -contains 0
    

    Not work ?

    The ForEach() still has to process every $array element and collect each result upfront, which we want to avoid.

    implement a new option for the where extension

    Applying this to the original example:

    $array = 1, 2, 3
    $array.Where({ $_ % 2 -eq 0 }, 'Boolean')
    # True

    It's an interesting idea. Naturally, this wouldn't be as convenient as the containment operators, nor would it have the same level of community benefit/adoption (largely by virtue of the intrinsic methods being less well known).

    With that said, I do still like it. I think it would be a welcome addition if a change to the containment operators isn't viable.

    A few other thoughts:

    • Where()/ForEach() has established output behavior of always returning a Collection`1 instance, irrespective of the result. The new overload would deviate from this.
    • $null can be operated on, but current behavior is to not invoke the script block and just return an empty collection. Ideally, the new overload would always invoke the script block and guarantee a boolean result ($_ would need to map to $null).
      # -contains always returns a bool:
      $null -contains $null # True
      
      # Existing Where() behavior:
      $null.Where{ Write-Host foo; 'bar' } # An empty collection
      
      # Ideally (for the new overload only):
      $null.Where({ Write-Host foo; 'bar' }, 'Boolean')
      # foo
      # True
  7. mklement0 commented on Jan 9, 2025

    @mklement0
    Contributor

    It's an interesting idea. Naturally, this wouldn't be as convenient as the containment operators, nor would it have the same level of community benefit/adoption (largely by virtue of the intrinsic methods being less well known).

    Indeed, it wouldn't be convenient as an operator solution and indeed, these methods are lesser known, presumably in part because they don't fit in well with the rest of PowerShell:

    • method syntax instead of command / operator syntax
    • non-streaming output behavior
    • always-a-collection output

    (
    Two asides:

    )

    $array.Where({ $_ % 2 -eq 0 }, 'Boolean')

    I think this makes for a conceptually muddled, counterintuitive solution: the purpose of the .Where() method, as the name implies, is filtering, not testing.

  8. mklement0 commented on Jan 10, 2025

    @mklement0
    Contributor

    I think the original proposal - to extend the -contains and -in operators and their variants - can be implemented without breaking existing code:

    While the behavior proposed below may be somewhat obscure at first glance, it is conceptually straightforward and fits in with existing behaviors; I suspect most users won't even have to think about the subtlety involved:

    • As for existing code:

      • [scriptblock] instances are compared using reference equality, so that { 'foo' } -contains { 'foo' } returns $false, for instance, because the two instances are distinct objects.
      • In other words: the only meaningful way in which existing code could have utilized these operators with [scriptblock] comparison operands is if the elements of the input operands are [scriptblock]s too, and if the comparison operand is the very same object as one of the elements in the input enumerable.
    • Existing behavior of the -contains and -in operators and their variants:

      • Each element of the input operand drives any potential type coercions in the - implied - element-individual -eq / -ne comparisons performed against the - by definition scalar - comparison operand; e.g.:

        'foo',  42 -contains '  0x2a  ' # -> $true
        
        • That is, $true is returned, because 42 -eq ' 0x2a ' is $true, because it is in effect the same as 42 -eq [int] ' 0x2a '

    Based on the above, the following, non-breaking extension seems feasible:

    • For each element of given input enumerable to be compared against a (by design scalar) [scriptblock] comparison operand:

      • If the element at hand is a [scriptblock] instance, test for reference equality (which is what already happens currently).

      • Otherwise, invoke the comparison operand's script block to evaluate it against the input element at hand, as proposed in the initial post.

    In short:

    • The only thing that will have to change is how a non-script-block input element is compared against a script-block comparison operand.

    • This use case is currently - mostly - pointless (always yields $false, e.g. 42 -contains { 1 }), with one exception, however:

      • Currently, with [string] input elements, specifically, the script-block operand is stringified, so that ' 1 ' -contains { 1 } is $true, i.e. you can meaningfully compare string representations of script blocks against a script block (but note that whitespace is included as-is in the comparison).

      • However, the examples that @SeeminglyScience found seemingly all use [scriptblock] input elements, a use case whose behavior would not change.

      • Therefore, to me the proposal is a bucket 3 change, after all.

        • With the change in place, someone who truly wanted to compare string representations of script blocks could simply do ' 1 ' -contains { 1 }.ToString()

        • Conversely, someone who truly wanted to evaluate [scripblock] input elements via a [scriptblock] comparison operand could wrap the input script blocks in aux. hashtables, e.g.
          @{ sb = { 1 } } -contains { $_.sb.ToString().Trim() -match '1' },
          or iterate via .Where(), e.g.
          [bool] @({ 1 }).Where({ $_.ToString().Trim() -match '1' }, 'First').Count.
          Given that this is an edge case, such workarounds do not strike me as a hardship.

  9. surfingoldelephant commented on Jan 12, 2025

    @surfingoldelephant
    Author

    Based on the above, the following, non-breaking extension seems feasible

    I agree. Conceptually, it is indeed straight forward and addresses the main breaking change identified. Thank you. I've updated the issue accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Issue-Enhancementthe issue is more of a feature request than a bugNeeds-TriageThe issue is new and needs to be triaged by a work group.WG-Languageparser, language semanticsWG-NeedsReviewNeeds a review by the labeled Working Group

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions