Freigeben über


calling the script blocks in PowerShell

The script blocks are the PowerShell way to pass a chunk of code as an argument to a command. So when you write something like

dir -Recurse . | where { $_ -match "\.txt" }

that thing in the braces {} is a script block. Essentially, an anonymous function. Some might also say that it's a closure but technically it isn't: it doesn't bring the variable evaluation context with it, it's just a function.

As a side note, if you're used to the Unix shells, back-slash "\" is not an escape character in the PowerShell double-quoted strings, the escape character for them is back-quote "`". However the back-slash is still the escape character for the PowerShell regular expressions. Which comes pretty convenient, and avoids the problem of back-slashing the back-slashes like everyone is used to in the Unix shells and Python.

As another side note, the PowerShell dir returns not the file names but objects but when these objects are converted to the string type for matching, they return the file name, so the matching works on the resulting file names.

The {} creates a script block and returns a reference to it. You can store it in a variable and use that variable instead:

$a = { $_ -match "\.txt" }
dir -Recurse . | where $a

You can also call them directly by applying the operator "&". For example:

PS > $_ = "qwe.txt"
PS > &$a
True
PS > $_ = "qwe.tx"
PS > &$a
False

This operator, by the way, causes an interesting peculiarity of the PowerShell syntax. In a Unix shell you can write something like

{ ls .; ls ..; } | grep '\.txt'

and it will cause both directory listings to be piped sequentially through the grep to find the files with a ".txt" in their names. If you try to use something similar on PowerShell:

{ dir .; dir .. } | where { $_ -match "\.txt" }

you will get nothing. The trick is that when you use {}, you create a script block and then pipe a reference to it through "where". Which is probably not what you wanted. Instead you need to execute that script block and pipe its result. Do that by adding a "&" in front of the block:

&{ dir .; dir .. } | where { $_ -match "\.txt" }

The lesson is, when you want to execute a block, don't forget to bring an "&".

Just like any other function, a script block can have parameters. For example:

PS > $b = { param($x); "---$x---" }
PS > &$b abc
---abc---

Now the real interesting part, how you can create a command like "where" that takes a script block as an argument and supplies the current pipeline entry to it as $_. It starts fairly easy:

function Where-My
{
    param(
        [scriptblock] $Block
    )

    process {
        "trying: $_"
        if (&$Block) {
            "passed: $_"
        }
    }
}

To show things more clearly, it works on strings, and returns both the inputs it had considered and the inputs it had decided to pass through. "process" is the part of the function called for each input object from pipeline, with the current object set in $_. So all it needs to do then is call the script block, which will also get that $_, and decide the fate of the input based on what the script block returns. Try it and it works:

dir . | Where-My { $_ -match "\.txt" }

Then you'd want to put your great new function into a module. You can put it into a file with extension ".psm1", or to play from command line just wrap it into an anonymous module:

$null = new-module {
    function Where-My
    {
        param(
            [scriptblock] $Block
        )

        process {
            "trying: $_"
            if (&$Block) {
                "passing: $_"
            }
        }
    }
}

Try it again. It won't work right any more, the match will not let any strings pass. The "trying" lines will still show but no "passing". What happened?

As it turns out, the special variable $_ is a script-level variable, it exists per-module. When process sets it, that happens in the module where the function Where-My is defined. However the script block in the pipeline gets defined outside that module, so it will see the copy of $_ from its own module that will be empty. In this way the script blocks are somewhat like closures, they remember the module where they've been defined. That's been quite surprising, and it took me a trip down the hall to the PowerShell guys who've been very nice and explained it.

OK, so the next attempt: A script block is an object that has a few useful methods on it. One of them is InvokeWithContext(), described in https://msdn.microsoft.com/en-us/library/system.management.automation.scriptblock.invokewithcontext%28v=vs.85%29.aspx . It lets you define the extra functions and variables that will be visible to it. We don't need the functions right now (leave that argument empty), just the variables, so the code becomes:

$null = new-module {
    function Where-My
    {
        param(
            [scriptblock] $Block
        )

        process {
            "trying: $_"
            if ($Block.InvokeWithContext(@{}, @(New-Variable -Name "_" -Value $_))) {
                "passing: $_"
            }
        }
    }
}

Which ends up even worse than before, now it gives an error on each processed string:

New-Variable : A variable with name '_' already exists.

What went wrong this time? As it turns out (thanks again to an explanation from the nice PowerShell people down the hall), New-Variable both creates a variable and places it into the current scope. And the variable $_ is special and is already present in the current scope, so that fails. What is needed is a variable not in any scope, and InvokeWithContext() will place  it into the new scope that it constructs for the execution of the script block.

To create that variable without a scope, we go directly to the .NET calls:

$v = New-Object "PSVariable" @("_", $_)

It's a somewhat weird introspection: the internals of PowerShell variable implementation visible through the .NET interface of PowerShell that we use to create an object that we then use at the PowerShell level.

The code now becomes:

$null = new-module {
    function Where-My
    {
        param(
            [scriptblock] $Block
        )

        process {
            "trying: $_"
            if ($Block.InvokeWithContext(@{}, @(New-Object "PSVariable" @("_", $_)))) {
                "passing: $_"
            }
        }
    }
}

And it finally works correctly when called from any module. That's been the trick. Note that the second argument of InvokeWithContext() is an array, so this way you can create multiple pre-defined variables for a script block, placing them all into this array.

P.S. As I've found out later, the script blocks are not closures after all. They simply use the variable values from the nearest enclosing block in the same module. For example:

PS > function g { param($block) $a=234; &$block; }
PS > function f { $a=123; g { "The value of `$a is $a" } }
PS > f
The value of $a is 234

 But there is a way to get a sort-of-closure too:

PS > function f { $a=123; g { "The value of `$a is $a" }.GetNewClosure() }
PS > f
The value of $a is 123

 The sort-of part is because the value of $a in f() cannot be changed from inside the script block. see more in the next installment.

Comments

  • Anonymous
    May 09, 2016
    Looks like you can just pass existing $_ variable (no need to create a new one):if ($Block.InvokeWithContext(@{}, (gv ''))) { "passing: $"}
    • Anonymous
      May 31, 2016
      If you tried it and it works, sure :-) I vaguely remember that I've had issues with reusing the same variable but I don't remember what they were, maybe I was trying something different.