schuelermine
schuelermine

Reputation: 2288

Why does the scope of variables change depending on if it's a .ps1 or .psm1 file, and how can this be mitigated?

I have a function that executes a script block. For convenience, the script block does not need to have explicitly defined parameters, but instead can use $_ and $A to refer to the inputs.

In the code, this is done as such:

$_ = $Value
$A = $Value2
& $ScriptBlock

This whole thing is wrapped in a function. Minimal example:

function F {
    param(
        [ScriptBlock]$ScriptBlock,
        [Object]$Value
        [Object]$Value2
    )

    $_ = $Value
    $A = $Value2
    & $ScriptBlock
}

If this function is written in a PowerShell script file (.ps1), but imported using Import-Module, the behaviour of F is as expected:

PS> F -Value 7 -Value2 1 -ScriptBlock {$_ * 2 + $A}
15
PS>

However, when the function is written in a PowerShell module file (.psm1) and imported using Import-Module, the behaviour is unexpected:

PS> F -Value 7 -Value2 1 -ScriptBlock {$_ * 2 + $A}
PS>

Using {$_ + 1} instead gives 1. It seems that $_ has a value of $null instead. Presumably, some security measure restricts the scope of the $_ variable or otherwise protects it. Or, possibly, the $_ variable is assigned by some automatic process. Regardless, if only the $_ variable was affected, the first unsuccessful example would return 1.

Ideally, the solution would involve the ability to explicitly specify the environment in which a script block is run. Something like:

Invoke-ScriptBlock -Variables @{"_" = $Value; "A" = $Value2} -InputObject $ScriptBlock

In conclusion, the questions are:

Upvotes: 2

Views: 231

Answers (1)

Mathias R. Jessen
Mathias R. Jessen

Reputation: 174555

Out of order:

  • Is there some other way of solving this that does not involve including an explicit parameter declaration in the script block?

Yes, if you just want to populate $_, use ForEach-Object!

ForEach-Object executes in the caller's local scope, which helps you work around the issue - except you won't have to, because it also automatically binds input to $_/$PSItem:

# this will work both in module-exported commands and standalone functions
function F {
    param(
        [ScriptBlock]$ScriptBlock,
        [Object]$Value
    )

    ForEach-Object -InputObject $Value -Process $ScriptBlock
}

Now F will work as expected:

PS C:\> F -Value 7 -ScriptBlock {$_ * 2}

Ideally, the solution would involve the ability to explicitly specify the environment in which a script block is run. Something like:

Invoke-ScriptBlock -Variables @{"_" = $Value; "A" = $Value2} -InputObject $ScriptBlock

Execute the scripblock using ScriptBlock.InvokeWithContext():

$functionsToDefine = @{
  'Do-Stuff' = {
    param($a,$b)
    Write-Host "$a - $b"
  }
}

$variablesToDefine = @(
  [PSVariable]::new("var1", "one")
  [PSVariable]::new("var2", "two")
)

$argumentList = @()

{Do-Stuff -a $var1 -b two}.InvokeWithContext($functionsToDefine, $variablesToDefine, $argumentList)

Or, wrapped in a function like your original example:

function F
{
  param(
    [scriptblock]$ScriptBlock
    [object]$Value
  )

  $ScriptBlock.InvokeWithContext(@{},@([PSVariable]::new('_',$Value)),@())
}

Now you know how to solve your problem, let's get back to the question(s) about module scoping.

At first, it's worth noting that you could actually achieve the above using modules, but sort of in reverse.

(In the following, I use in-memory modules defined with New-Module, but the module scope resolution behavior describe is the same as when you import a script module from disk)

While module scoping "bypasses" normal scope resolution rules (see below for explanation), PowerShell actually supports the inverse - explicit execution in a specific module's scope.

Simply pass a module reference as the first argument to the & call operator, and PowerShell will treat the subsequent arguments as a command to be invoked in said module:

# Our non-module test function
$twoPlusTwo = { return $two + $two }
$two = 2

& $twoPlusTwo # yields 4

# let's try it with explicit module-scoped execution
$myEnv = New-Module {
  $two = 2.5
}

& $myEnv $twoPlusTwo # Hell froze over, 2+2=5 (returns 5)

  • Why can't script blocks in module files access variables defined in functions from which they were called?
  • If they can, why can't the $_ automatic variable?

Because loaded modules maintain state, and the implementers of PowerShell wanted to isolate module state from the caller's environment.

Why might that be useful, and why might one preclude the other, you ask?

Consider the following example, a non-module function to test for odd numbers:

$two = 2
function Test-IsOdd
{
  param([int]$n)

  return $n % $two -ne 0
}

If we run the above statements in a script or an interactive prompt, subsequently invocating Test-IsOdd should yield the expected result:

PS C:\> Test-IsOdd 123
True

So far, so great, but relying on the non-local $two variable bears a flaw in this scenario - if, somewhere in our script or in the shell we accidentally reassign the local variable $two, we might break Test-IsOdd completely:

PS C:\> $two = 1 # oops!
PS C:\> Test-IsOdd 123
False

This is expected since, by default, variable scope resolution just wanders up the call stack until it reaches the global scope.

But sometimes you might require state to be kept across executions of one or more functions, like in our example above.

Modules solve this by following slightly different scope resolution rules - module-exported functions defer to something we call module scope (before reaching the global scope).

To illustrate how this solves our problem from before, considering this module-exported version of the same function:

$oddModule = New-Module {
  function Test-IsOdd
  {
    param([int]$n)

    return $n % $two -ne 0
  }

  $two = 2
}

Now, if we invoke our new module-exported Test-IsOdd, we predictably get the expected result, regardless of "contamination" in the callers scope:

PS C:\> Test-IsOdd 123
True
PS C:\> $two = 1
PS C:\> Test-IsOdd 123 # still works
True

This behavior, while maybe surprising, basicly serves to solidify the implicit contract between the module author and the user - the module author doesn't need to worry too much about what's "out there" (the callers session state), and the user can expect whatever going on "in there" (the loaded module's state) to work correctly without worrying about what they assign to variables in the local scope.


Module scoping behavior poorly documented in the help files, but is explained in some depth in chapter 8 of Bruce Payette's "PowerShell In Action" (ISBN:9781633430297)

Upvotes: 3

Related Questions