Repository navigation
Add script block support to containment operators #24748
Description
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
$_/$PSItemrefers 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 }
}
$containsWhile 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 # FalseAddendum
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.
- This amounts to a natural extension of the concept of a delay-bind script block.
Activity
mklement0 commentedon Jan 7, 2025
I like the idea; it reminds me of the following RFC from 2018, which proposed introducing
-foreachand-whereoperators, but was rejected, unfortunately:SeeminglyScience commentedon Jan 8, 2025
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 actualScriptBlockobjects of course but a good bit of them are, and their usage makes sense.surfingoldelephant commentedon Jan 8, 2025
Nice find, thank you. I've updated the issue.
jhoneill commentedon Jan 9, 2025
Why would
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
whereextension on arrays, arraylists etc. From the about_arrays helpIt should be a lot less work to add an option
There is a an example in the help
A Boolean option
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.
surfingoldelephant commentedon Jan 9, 2025
The
ForEach()still has to process every$arrayelement and collect each result upfront, which we want to avoid.Applying this to the original example:
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 aCollection`1instance, irrespective of the result. The new overload would deviate from this.$nullcan 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).mklement0 commentedon Jan 9, 2025
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:
(
Two asides:
That said, especially
.Where()has useful features that its cmdlet counterpart,Where-Objectis missing, which is the subject of:Where-Objectcmdlet and the.Where()array method, introduce -First, -Last, ... switches #13834While not using
(...)in calls such as$array.ForEach{$_ % 2}technically works, it is conceptually problematic:(...)can give rise to the misconception that this is an argument-mode invocation, where whitespace before the{would be permitted (but doesn't work in this case).(...)mandatory.)
I think this makes for a conceptually muddled, counterintuitive solution: the purpose of the
.Where()method, as the name implies, is filtering, not testing.mklement0 commentedon Jan 10, 2025
I think the original proposal - to extend the
-containsand-inoperators 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.[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
-containsand-inoperators and their variants:Each element of the input operand drives any potential type coercions in the - implied - element-individual
-eq/-necomparisons performed against the - by definition scalar - comparison operand; e.g.:$trueis returned, because42 -eq ' 0x2a 'is$true, because it is in effect the same as42 -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.
surfingoldelephant commentedon Jan 12, 2025
I agree. Conceptually, it is indeed straight forward and addresses the main breaking change identified. Thank you. I've updated the issue accordingly.