为什么变量的范围会根据它是 .ps1 还是 .psm1 文件而变化,如何缓解这种情况?

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

我有一个执行脚本块的函数。为方便起见,脚本块不需要显式定义参数,而是可以使用 $_$A 来引用输入。

在代码中,这样做是这样的:

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

这一切都包裹在一个函数中。最小示例:

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

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

如果此函数写在 PowerShell 脚本文件 (.ps1) 中,但使用 Import-Module 导入,F 的行为符合预期:

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

但是,当函数写入 PowerShell 模块文件 (.psm1) 并使用 Import-Module 导入时,行为是意外的:

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

使用 {$_ + 1} 代替 1$_ 的值似乎改为 $null。据推测,某些安全措施限制了 $_ 变量的范围或以其他方式保护它。或者,$_ 变量可能是由某个自动过程分配的。无论如何,如果只有 $_ 变量受到影响,第一个不成功的示例将 return 1.

理想情况下,解决方案将涉及明确指定脚本块所在环境的能力 运行。类似于:

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

总之,问题是:

乱序:

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

是的,如果您只想填充 $_,请使用 ForEach-Object

ForEach-Object 在调用者的本地范围内执行,这有助于您解决问题 - 除了 您不会 ,因为它还会自动将输入绑定到$_/$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
}

现在 F 将按预期工作:

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

使用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)

或者,像您的原始示例一样包装在一个函数中:

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

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

现在你知道如何解决你的问题了,让我们回到关于模块作用域的问题。

首先,值得注意的是,您实际上可以使用模块实现上述目标,但有点相反。

(在下文中,我使用了用New-Module定义的内存模块,但模块范围解析行为描述与从磁盘导入脚本模块时相同)

虽然模块作用域 "bypasses" 正常范围解析规则(解释见下文),但 PowerShell 实际上支持反向 - 在特定模块的范围内显式执行。

只需将模块引用作为第一个参数传递给 & 调用运算符,PowerShell 会将后续参数视为要在所述模块中调用的命令:

# 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?

因为加载的模块保持 状态,而 PowerShell 的实现者希望从调用者的环境中隔离模块状态

为什么这可能有用,为什么一个会排除另一个,你问?

考虑以下示例,一个用于测试奇数的非模块函数:

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

  return $n % $two -ne 0
}

如果我们 运行 在脚本或交互式提示中执行上述语句,随后调用 Test-IsOdd 应该会产生预期的结果:

PS C:\> Test-IsOdd 123
True

到目前为止,非常好,但是依赖非本地 $two 变量在这种情况下存在缺陷 - 如果在我们的脚本或 shell 中的某处我们不小心重新分配了本地变量变量 $two,我们可能会完全破坏 Test-IsOdd

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

这是意料之中的,因为默认情况下,变量作用域解析只会在调用堆栈中徘徊,直到到达全局作用域。

但有时您可能需要在一个或多个函数的执行过程中保持状态,如我们上面的示例。

模块通过遵循稍微不同的作用域解析规则来解决这个问题——模块导出的函数遵循我们称之为 模块作用域 的东西(在到达全局作用域之前)。

为了说明这如何解决我们之前的问题,考虑这个相同功能的模块导出版本:

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

    return $n % $two -ne 0
  }

  $two = 2
}

现在,如果我们调用我们新的模块导出 Test-IsOdd,我们可以预见地得到预期的结果,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

这种行为虽然可能令人惊讶,但基本上 用于巩固模块作者与用户之间的隐式契约 - 模块作者不需要太担心什么是"out there"(调用者会话状态),用户可以期望发生的任何事情"in there"(加载模块的状态)都能正常工作,而不必担心他们分配给本地范围内变量的内容。


模块作用域行为不佳 documented in the help files,但在 Bruce Payette 的 "PowerShell In Action"(ISBN:9781633430297)

的第 8 章中有一定深度的解释