【问题标题】:Supporting lexical-scope ScriptBlock parameters (e.g. Where-Object)支持词法范围的 ScriptBlock 参数(例如 Where-Object)
【发布时间】:2019-01-23 20:18:15
【问题描述】:

考虑以下任意函数和测试用例:

Function Foo-MyBar {
    Param(
        [Parameter(Mandatory=$false)]
        [ScriptBlock] $Filter
    )

    if (!$Filter) { 
        $Filter = { $true } 
    }

    #$Filter = $Filter.GetNewClosure()

    Get-ChildItem "$env:SYSTEMROOT" | Where-Object $Filter   
}

##################################

$private:pattern = 'T*'

Get-Help Foo-MyBar -Detailed

Write-Host "`n`nUnfiltered..."
Foo-MyBar

Write-Host "`n`nTest 1:. Piped through Where-Object..."
Foo-MyBar | Where-Object { $_.Name -ilike $private:pattern  }

Write-Host "`n`nTest 2:. Supplied a naiive -Filter parameter"
Foo-MyBar -Filter { $_.Name -ilike $private:pattern }

在测试 1 中,我们将 Foo-MyBar 的结果通过 Where-Object 过滤器进行管道传输,该过滤器将返回的对象与包含在私有范围变量 $private:pattern 中的模式进行比较。在这种情况下,这将正确返回 C:\ 中以字母 T 开头的所有文件/文件夹。

在测试 2 中,我们直接将相同的过滤脚本作为参数传递给Foo-MyBar。但是,当Foo-MyBar 开始运行过滤器时,$private:pattern 不在范围内,因此不会返回任何项目。

我明白为什么会出现这种情况——因为传递给Foo-MyBar 的ScriptBlock 不是闭包,所以不会关闭$private:pattern 变量和该变量丢失了。

我从 cmets 注意到我之前有一个有缺陷的第三次测试,它试图通过 {...}.GetNewClosure(),但这并没有关闭私有范围的变量——感谢 @PetSerAl 的帮助我澄清一下。

问题是,Where-Object 如何在测试 1 中捕获$private:pattern 的值,以及我们如何在我们自己的函数/cmdlet 中实现相同的行为?

(最好不需要调用者必须知道闭包,或者知道将他们的过滤器脚本作为闭包传递。)

我注意到,如果我取消注释 Foo-MyBar 中的 $Filter = $Filter.GetNewClosure() 行,那么它永远不会返回任何结果,因为 $private:pattern 丢失了。

(正如我在顶部所说,这里的函数和参数是任意的,作为我真正问题的最短形式再现!)

【问题讨论】:

  • 您可以重新检查测试 3 吗?据我所知GetNewClosure 不捕获private 变量。
  • @PetSerAl - 你可能是对的,尽管现在我在一个 ISE 窗口(不是选项卡)中遇到了与另一个不同的行为。在我正在测试的 ISE 窗口中,$private:pattern is 被关闭,但我无法在新窗口中重新创建。不过,问题仍然存在,因为测试 1 仍然按描述工作。
  • @PetSerAl - 好的,Remove-Variable pattern 重置它——我一定在早些时候定义了$pattern。我将更新以删除对测试 3 的引用
  • 解决它的简单方法是将Foo-MyBar放在单独的模块中。

标签: function powershell scope closures scriptblock


【解决方案1】:

Where-Object 如何在测试 1 中捕获$private:pattern 的值

the source code for Where-Object in PowerShell Core 中可以看出,PowerShell 在内部调用过滤器脚本没有将其限制在自己的本地范围内_scriptFilterScript 参数的私有支持字段,请注意useLocalScope: false 参数传递给DoInvokeReturnAsIs()):

protected override void ProcessRecord()
{
    if (_inputObject == AutomationNull.Value)
        return;

    if (_script != null)
    {
        object result = _script.DoInvokeReturnAsIs(
            useLocalScope: false, // <-- notice this named argument right here
            errorHandlingBehavior: ScriptBlock.ErrorHandlingBehavior.WriteToCurrentErrorPipe,
            dollarUnder: InputObject,
            input: new object[] { _inputObject },
            scriptThis: AutomationNull.Value,
            args: Utils.EmptyArray<object>());

        if (_toBoolSite.Target.Invoke(_toBoolSite, result))
        {
            WriteObject(InputObject);
        }
    }
    // ...
}

我们如何在我们自己的函数/cmdlet 中实现相同的行为?

我们不 - DoInvokeReturnAsIs()(和类似的脚本块调用工具)被标记为 internal,因此只能由 System.Management.Automation 程序集中包含的类型调用

【讨论】:

    【解决方案2】:

    给出的示例不起作用,因为默认情况下调用函数将进入新范围。 Where-Object 仍然会调用过滤器脚本而不输入任何内容,但函数的作用域没有private 变量。

    有三种方法可以解决这个问题。

    将函数放在与调用者不同的模块中

    每个模块都有一个SessionState,它有自己的SessionStateScopes 堆栈。每个 ScriptBlock 都绑定到 SessionState 被解析。

    如果您调用模块中定义的函数,则会在该模块的SessionState 内创建一个新范围,但不会在顶级SessionState 内创建。因此,当Where-Object 调用过滤器脚本而不进入新范围时,它会在与ScriptBlock 绑定的SessionState 的当前范围内执行此操作。

    这有点脆弱,因为如果你想从你的模块中调用那个函数,那么你不能。也会有同样的问题。

    使用点源运算符调用函数

    您很可能已经知道点源运算符 (.) 用于在不创建新范围的情况下调用脚本文件。这也适用于命令名称和 ScriptBlock 对象。

    . { 'same scope' }
    . Foo-MyBar
    

    但是请注意,这将在 SessionState 的当前范围内调用函数 该函数来自,因此您不能依赖 . 始终在 调用者的当前范围。因此,如果您使用点源运算符调用与不同SessionState 关联的函数——例如在(不同)模块中定义的函数——它可能会产生意想不到的效果。创建的变量将持续存在于未来的函数调用中,并且在函数本身中定义的任何辅助函数也将持续存在。

    编写一个 Cmdlet

    编译的命令 (cmdlet) 在调用时不会创建新范围。您还可以使用与 Where-Object 使用的 API 类似的 API(尽管不完全相同)

    以下是如何使用公共 API 实现 Where-Object 的粗略实现

    using System.Management.Automation;
    
    namespace MyModule
    {
        [Cmdlet(VerbsLifecycle.Invoke, "FooMyBar")]
        public class InvokeFooMyBarCommand : PSCmdlet
        {
            [Parameter(ValueFromPipeline = true)]
            public PSObject InputObject { get; set; }
    
            [Parameter(Position = 0)]
            public ScriptBlock FilterScript { get; set; }
    
            protected override void ProcessRecord()
            {
                var filterResult = InvokeCommand.InvokeScript(
                    useLocalScope: false,
                    scriptBlock: FilterScript,
                    input: null,
                    args: new[] { InputObject });
    
                if (LanguagePrimitives.IsTrue(filterResult))
                {
                    WriteObject(filterResult, enumerateCollection: true);
                }
            }
        }
    }
    

    【讨论】:

    • 不敢相信我忽略了扩展 PSCmdlet,这应该是公认的答案,+1
    • @mklement0 在来自模块外部的调用者从模块点源函数的场景中,当前范围很可能是会话状态的最高范围。基本上,从不同的会话状态点源不会导致它在 your 当前范围内被调用,而是在命令所绑定的会话状态(模块)的当前范围内调用。 Additional reading
    • @mklement0 好问题!是的,它可以,但可能性要小得多。假设ModuleAFunctionAFunctionB,而ModuleBFunctionC。如果FunctionA 调用FunctionCFunctionC 点源FunctionB,那么FunctionB 将在FunctionA 的调用创建的范围内被调用。希望这至少可以模糊理解......范围......复杂。也感谢您的网址更正!自动更正再次出现。
    • 只是为了让它更复杂:作用域不是堆栈而是树,因为单个父作用域可以同时有多个活动的子作用域。
    • @mklement0 For examplef 的每个实例都有自己的-Scope 0 $a,但-Scope 1 $a 在它们之间共享。
    猜你喜欢
    • 1970-01-01
    • 2017-06-20
    • 1970-01-01
    • 2018-07-19
    • 2016-09-29
    • 1970-01-01
    • 1970-01-01
    • 2012-10-30
    • 1970-01-01
    相关资源
    最近更新 更多