2

I'm looking to have a function in script where I can use a ScriptBlock passed in as either a predicate or with Where-Object.

I can write

cat .\.gitignore | Where-Object { $_.contains('pp') }

and this works; as does:

$f =  { $_.contains('pp') }; cat .gitignore | Where-Object $f

however trying

$f.Invoke( 'apple' )

results in

MethodInvocationException: Exception calling "Invoke" with "1" argument(s): "You cannot call a method on a null-valued expression.

Whereas I expected True. So clearly $_ wasn't set.

Likewise

$ff = { echo "args: $args`nauto: $_" }; $ff.Invoke( 'apple' )

outputs

args: apple
auto:

So $_ is clearly not getting set.

'apple' | %{ $_.contains('pp') }

Works, but I want the scriptblock to be a variable and

$f = { $_.contains('pp') }; 'apple' | %$f

Is a compile error.


tl;dr: So how do I set/pass the value of $_ inside a scriptblock I am invoking?

Sled
  • 18,541
  • 27
  • 119
  • 168

3 Answers3

2

Note, this answer only covers how does $_ gets populated in the context of a process block of a script block. Other use cases can be found in the about_PSItem documentation.


In the context of a process block of a Script Block, the $_ ($PSItem) variable is automatically populated and represents each element coming from the pipeline, i.e.:

$f = { process { $_.contains('pp') }}
'apple' | & $f # True

You can however achieve the same using InvokeWithContext method from the ScriptBlock Class:

$f = { $_.contains('pp') }
$f.InvokeWithContext($null, [psvariable]::new('_', 'apple')) # True

Do note, this method always returns Collection`1. Output is not enumerated.


Worth noting as zett42 points out, the scoping rules of script blocks invoked via it's methods or via the call operator & still apply.

Script Blocks are able to see parent scope variables (does not include Remoting):

$foo = 'hello'
{ $foo }.Invoke() # hello

But are not able to update them:

$foo = 'hello'
{ $foo = 'world' }.Invoke()
$foo # hello

Unless using a scope a modifier (applies only to Value Types):

$foo = 'hello'
{ $script:foo = 'world' }.Invoke()
$foo # world

Or via the dot sourcing operator .:

$foo = 'hello'
. { $foo = 'world' }
$foo # world

# still applies with pipelines!
$foo = 'hello'
'world' | . { process { $foo = $_ }}
$foo # world

See about Scopes for more details.

Santiago Squarzon
  • 41,465
  • 5
  • 14
  • 37
  • 1
    Unfortunately, `InvokeWithContext()` always runs the script in a new scope. – zett42 Jan 27 '23 at 16:43
  • @zett42 thats correct, any assignment there is lost unless using a scope modifier, similar to `&` without enumeration – Santiago Squarzon Jan 27 '23 at 16:45
  • 1
    A way to avoid creating a new scope is the dot-sourcing operator: `'apple' | . $f`. This way it works similarly to `ForEach-Object`. – zett42 Jan 27 '23 at 17:00
  • @zett42 and I was planning on a short answer zett! what have you done! hope it looks good now? – Santiago Squarzon Jan 27 '23 at 17:04
  • [`$f = { $_.contains('pp') }; 'apple' | . $f`](https://replit.com/@ArthurBugorski/fails?v=1) does not seem to work. – Sled Jan 27 '23 at 20:05
  • @Sled no because there is no `process` block :) only in a `process` block the `$_` is automatically defined. – Santiago Squarzon Jan 27 '23 at 20:07
  • 1
    Thanks, it's more complete now. @Sled To avoid a `process {}` block in the predicate script block, you could use `ForEach-Object` as demonstrated by [this answer](https://stackoverflow.com/a/75260906/7571258) or write `$f = { $_.contains('pp') }; 'apple' | . { process { . $f }}` – zett42 Jan 27 '23 at 20:18
1

Using the .Invoke() method (and its variants, .InvokeReturnAsIs() and .InvokeWithContext()) to execute a script block in PowerShell code is best avoided, because it changes the semantics of the call in several respects - see this answer for more information.

While the PowerShell-idiomatic equivalent is &, the call operator, it is not enough here, given that you want want the automatic $_ variable to be defined in your script block.

The easiest way to define $_ based on input is indeed ForEach-Object (one of whose built-in aliases is %):

$f = { $_.contains('pp') }
ForEach-Object -Process $f -InputObject 'apple'  # -> $true

Note, however, that -InputObject only works meaningfully for a single input object (though you may pass an array / collection in which case $_ then refers to it as a whole); to provide multiple ones, use the pipeline:

'apple', 'pear' | ForEach-Object $f  # $true, $false

# Equivalent, with alias
'apple', 'pear' | % $f

If, by contrast, your intent is simply for your script block to accept arguments, you don't need $_ at all and can simply make your script either formally declare parameter(s) or use the automatic $args variable which contains all (unbound) positional arguments:

# With $args: $args[0] is the first positional argument.
$f = { $args[0].contains('pp') }
& $f 'apple'


# With declared parameter.
$f = { param([string] $fruit) $fruit.contains('pp') }
& $f 'apple'

For more information about the parameter-declaration syntax, see the conceptual about_Functions help topic (script blocks are basically unnamed functions, and only the param(...) declaration style can be used in script blocks).

Sled
  • 18,541
  • 27
  • 119
  • 168
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • Nice, but piping to a script block with a `process {}` block (as shown by [Santiago's answer](https://stackoverflow.com/a/75260923/7571258)) might be preferable, due to the performance issues of `ForEach-Object`, when it calls a script block. – zett42 Jan 27 '23 at 16:46
  • Thanks, @zett42, that's worth considering _if and when_ performance becomes a concern, but that is really a separate issue, and the workaround adds additional complexity, whereas my aim here is to explain the fundamentals. – mklement0 Jan 27 '23 at 16:51
0

I got it to work by wrapping $f in () like

$f = { $_.contains('pp') }; 'apple' | %($f)

...or (thanks to @zett42) by placing a space between the % and $ like

$f = { $_.contains('pp') }; 'apple' | % $f

Can even pass in the value from a variable

$f = { $_.contains('pp') }; $a = 'apple'; $a | %($f)

Or use it inside an If-statement

$f = { $_.contains('pp') }; $a = 'apple'; If ( $a | %($f) ){ echo 'yes' }    

So it appears that $_ is only set by having things 'piped' (aka \) into it? But why this is and how it works, and if this can be done through .invoke() is unknown to me. If anyone can explain this please do.

From What does $_ mean in PowerShell? and the related documentation, it seems like $PSItem is indeed a better name since it isn't like Perl's $_

Sled
  • 18,541
  • 27
  • 119
  • 168
  • 2
    It's not the parentheses in `($f)` but the piping to `ForEach-Object`, which defines `$_` as the current pipeline object. Your sample from the question works, if you insert a space between percent and dollar sign: `$f = { $_.contains('pp') }; 'apple' | % $f` – zett42 Jan 27 '23 at 16:36