Delay-bind script block does not work when functio

2019-06-06 19:43发布

I have following function:

function PipeScript {
    param(
        [Parameter(ValueFromPipeline)]
        [Object] $InputObject,

        [Object] $ScriptBlock
    )

    process {
        $value = Invoke-Command -ScriptBlock $ScriptBlock
        Write-Host "Script: $value"
    }
}

When I define this function directly in script and pipe input into it I get following result which is expected:

@{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name }

# Outputs: "Script: Test"

But when I define this function inside module and export it with Export-ModuleMember -Function PipeScript then pipeline variable $_ inside script block is always null:

Import-Module PipeModule
@{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name }

# Outputs: "Script: "

Full repro is available at: https://github.com/lpatalas/DelayBindScriptBlock

Can someone explain this behaviour?

1条回答
贼婆χ
2楼-- · 2019-06-06 20:41

Tip of the hat to PetSerAl for all his help.

Here's a simple solution, but note that it runs the script block directly in the caller's scope, i.e. it effectively "dot-sources", which allows modification of the caller's variables.

By contrast, your use of Invoke-Command runs the script block in a child scope of the caller's scope - if that is truly the intent, see the variant solution below.

"Dot-sourcing" the script block is also what standard cmdlets such as Where-Object and ForEach-Object do.

# Define the function in an (in-memory) module.
# An in-memory module is automatically imported.
$null = New-Module {

  function PipeScript {
    param(
      [Parameter(ValueFromPipeline)]
      [Object] $InputObject
      ,
      [scriptblock] $ScriptBlock
    )

    process {

      # Use ForEach-Object to create the automatic $_ variable
      # in the script block's origin scope.
      $value = ForEach-Object -Process $ScriptBlock -InputObject $InputObject

      # Output the value
      "Script: $value"
    }

  }

}

# Test the function:
$var = 42; @{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name; ++$var }
$var # -> 43 - the script block ran in the caller's scope.

The above outputs string Script: Test and 43 afterwards, proving that the input object was seen as $_ and that dot-sourcing worked ($var was successfully incremented in the caller's scope).


Here's a variant, via the PowerShell SDK, that runs the script block in a child scope of the caller's scope.

This can be helpful if you don't want the execution of the script block to accidentally modify the caller's variables.

It is the same behavior you get with the engine-level delay-bind script-block and calculated-property features - though it's unclear whether that behavior was chosen intentionally.

$null = New-Module {

  function PipeScript {
    param(
      [Parameter(ValueFromPipeline)]
      [Object] $InputObject
      ,
      [scriptblock] $ScriptBlock
    )

    process {
      # Use ScriptBlock.InvokeContext() to inject a $_ variable
      # into the child scope that the script block runs in:
      # Creating a custom version of what is normally an *automatic* variable
      # seems hacky, but the docs do state:
      # "The list of variables may include the special variables 
      #  $input, $_ and $this." - see https://docs.microsoft.com/en-us/dotnet/api/system.management.automation.scriptblock.invokewithcontext
      $value = $ScriptBlock.InvokeWithContext(
        $null, # extra functions to define (none here)
        [psvariable]::new('_', $InputObject) # actual parameter type is List<PSVariable>
      )
      # Output the value
      "Script: $value"
    }

  }

}

# Test the function:
$var = 42
@{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name; ++$var }
$var # -> 42 - unaltered, because the script block ran in a child scope.

The above outputs string Script: Test, followed by 42, proving that the script block saw the input object as $_ and that variable $var - although seen in the script block, was not modified, due to running in a child scope.

The ScriptBlock.InvokeWithContext() method is documented here.


As for why your attempt didn't work:

  • Generally, script blocks are bound to the scope and scope domain in which they are created (except if they're created expressly as unbound script blocks, with [scriptblock]::Create('...')).

  • A scope outside of a module is part of the default scope domain. Every module has its own scope domain, and except for the global scope, which all scopes across all scope domains see, scopes in different scope domains do not see one another.

  • Your script block is created in the default scope domain, and when the module-defined function invokes it, the $_ is looked for in the scope of origin, i.e., in the (non-module) caller scope, where it isn't defined, because the automatic $_ variable is created by PowerShell on demand in the local scope, which is in the enclosing module's scope domain.

  • By using .InvokeWithContext(), the script block runs in a child scope of the caller's scope (as would be the case with .Invoke() and Invoke-Command by default), into which the above code injects a custom $_ variable so that the script block can reference it.


Providing better SDK support for these scenarios is being discussed in this GitHub issue.

查看更多
登录 后发表回答