🎁

【Powershell】クロージャを自作してみた

に公開
2025/08/24

コードだけ見たい人用

見たけりゃ先に見せてやるよ…

自作クロージャ

スクリプトブロック内の変数のみ内側のスクリプトブロックに伝搬します。
function定義の読み込みについては対応していません。
そちらの改修は各自に任せたい(怠慢)

Closure.ps1
using namespace System.Management.Automation.Language
class Closure{
    hidden [ValidateNotNull()][scriptblock]$function
    hidden [ValidateNotNull()][hashtable]$capturedScope = @{}

    Closure(){}
    Closure([scriptblock]$func, [hashtable]$capturedScope = @{}){
        $this.function = $func
        $this.capturedScope = $capturedScope
    }
    Closure([scriptblock]$func){
        $this.function = $func
        # スクリプトブロック定義時のスコープをキャプチャ
        $this.capturedScope = @{}
        $varNames = [Closure]::getVarNamesInFuncScope($func)
        foreach($varName in $varNames){
            $exitVar =  Get-Variable -Name $varName -ValueOnly -Scope Script `
                -ErrorAction SilentlyContinue
            if($null -ne $exitVar){ $this.capturedScope[$varName] = $exitVar; continue }
            $exitVar = Get-Variable -Name $varName -ValueOnly -Scope Global `
                -ErrorAction SilentlyContinue
            if($null -ne $exitVar){ $this.capturedScope[$varName] = $exitVar }
        }
    }

    [object]Invoke([object[]]$arg){
        #キャプチャした変数をスクリプトブロックのスコープに展開
        foreach($key in $this.capturedScope.Keys){
            Set-Variable -Name $key -Value $this.capturedScope[$key] -Scope Local
        }
        #ドットソースでスクリプトブロックを実行(スコープをメソッド内と同一にするため)
        $result = . $this.function @arg

        #戻り値がスクリプトブロック/クロージャでなければ
        #現在のスコープをキャプチャし直してから結果を返す
        if($result -isnot [scriptblock] -and $result -isnot [Closure]){
            $varNames = [Closure]::getVarNamesInFuncScope($this.function)
            foreach($varName in $varNames){
                $this.capturedScope[$varName] = Get-Variable -Name $varName -ValueOnly `
                    -Scope Local -ErrorAction SilentlyContinue
            }
            return $result
        }

        #戻り値がクロージャの場合
        if($result -is [Closure]){
            #内側のクロージャがキャプチャ済の変数を取得
            $caputerdScopeInInner = $result.capturedScope.Clone() #シャローコピーなので注意
            
            #現在展開済のメソッド内スコープから内側のクロージャに必要な変数を抽出
            #重複した変数は上書きする(優先度:メソッド内スコープ > 内側クロージャスコープ)
            $varNames = [Closure]::getVarNamesInFuncScope($result.function)
            foreach($varName in $varNames){
                $varExit = Get-Variable -Name $varName -ValueOnly -Scope Local `
                    -ErrorAction SilentlyContinue
                if($null -ne $varExit){ $caputerdScopeInInner[$varName]=$varExit;continue }
                #どのスコープにも存在しない変数にはnullを割り当てる
                if(-not $caputerdScopeInInner.ContainsKey($varName)){
                    $caputerdScopeInInner[$varName] = $null
                }
            }

            #新しいクロージャを返す
            return [Closure]@{
                function = $result.function
                capturedScope = $caputerdScopeInInner
            }
        }

        #戻り値がスクリプトブロックの場合
        #現在展開済のメソッド内スコープからスクリプトブロックに必要な変数を抽出
        $varScopeToCapture = @{}
        $varNames = [Closure]::getVarNamesInFuncScope($result)
        foreach($varName in $varNames){
            $varScopeToCapture[$varName] = Get-Variable -Name $varName -ValueOnly `
                -Scope Local -ErrorAction SilentlyContinue
        }

        #新しいClosureを作成
        return [Closure]@{
            function = $result
            capturedScope = $varScopeToCapture
        }
    }

    #スクリプトブロックのスコープ内で参照されている変数の名前を取得するヘルパーメソッド
    hidden static [string[]]getVarNamesInFuncScope([scriptblock]$func){
        $ast = [Parser]::ParseInput($func.ToString(), [ref]$null, [ref]$null)
        $varNames = $ast.FindAll({$args[0] -is [VariableExpressionAst]}, $true) | 
            ForEach-Object { $_.VariablePath.UserPath } | Select-Object -Unique
        return $varNames
    }
}
使い方
$add_curry = {
    param($x) return {
        param($y) return {
            param($z) return $x + $y + $z
        }
    }
}

[Closure]::new($add_curry).Invoke(1).Invoke(2).Invoke(3) #6

はじめに

初投稿です。
Powershellを勉強し始めて約2年。
私はGetNewClosureとの戦いによって大きな傷を負い、余命幾ばくも無い状態にあります。

皆さんには自分と同じ轍を踏んでほしくない──その一心で最期の力を振り絞り、この記事を残します…。

この記事では、以下を扱います👇

  • GetNewClosure の基本的な動作
  • その制限を回避する応急処置的アプローチ
  • そしてより直感的に扱える「自作クロージャ」の紹介

拙い文章ではありますが、誰かの学習や開発の助けになれば幸いです。

対象読者

  • Powershellをある程度使用できる方
  • スクリプトブロック(関数)とクラスを扱える方

GetNewClosure でご家族を亡くされた方

😡そもそもクロージャってなに?

ざっくりいうと「関数が作られたときの外側の変数を覚えておいて、あとから呼び出してもその変数を参照できる仕組み」です。
内部的な所は、はてなブログさんが端的に説明してくれてました。

関数内に出現する自由変数(関数内で宣言されていない変数)の解決の際、実行時の環境ではなく、関数を定義した環境の変数を参照できるようなデータ構造。(はてなブログより)

はえ^〜すっごく単純・・・

雰囲気サンプル👇(JavaScript)

function MakeCounter() {
  var count = 0
  return () => ++count
}

let counter = MakeCounter()
console.log(counter()) # 1
console.log(counter()) # 2
console.log(counter()) # 3

つまり便利ってこと👊😁

👿悪魔「GetNewClosure」の挙動

まずは、MakeCounterをPowershellで実装してみます。

function MakeCounter() {
    $count = 0

    #「$script:」を使用しないと変更不可(参照は可能)
    return { (++$script:count) }.GetNewClosure()
}
$counter = MakeCounter
& $counter # 1
& $counter # 2
& $counter # 3

まだクロージャとしてちゃんと機能しています。
が、以下のような例でこの動きは破綻します。

さらに一段ネスト

「外側の文脈をだんだん受け渡す」系(カリー化/部分適用)で破綻が明確になります。

# 3段カリー化のつもり
$addC = {
    param($x) return {
        param($y) return {
            param($z) return $x + $y + $z
        }.GetNewClosure()
    }.GetNewClosure()
}                                    

# ここまでは良さそうに見えるが…
$addy = & $addC 1     # 内側の { param($y) ... } が返る
$addz = & $addy 2     # 最深部の { param($z) ... } が返る
# ここで謎の値が返る
& $addz 3             # 期待: 6 / 実際: 5

おい😡

なぜこうなる?

結論から言うと、$xが内側の関数から参照できていません。
GetNewClosureは呼び出された時の階層の「通常スコープ」を「独自スコープ」にコピーするメソッドです。
キャプチャ範囲は通常スコープだけだけど、キャプチャした状態は独自スコープに束縛されるので、前回束縛した状態は内側のGetNewClosureのキャプチャ範囲外になります。
その結果、$xは束縛範囲外になって参照から漏れます。はぁ~つっかえ

🤕応急措置

「とりあえず動かしたい」時の現実解(多分)

①同じ階層で変数を再宣言する(おすすめ)

たぶんこれが一番早いと思います。

$addC = {
    param($x) return {
        param($y)
        $local:x = $x    # ← ここ入れたら動く
        return {
            param($z) return $x + $y + $z
        }.GetNewClosure()
    }.GetNewClosure()
}                                    
  • ✅コードへの変更が少ない
  • ❌事情を知らない人からみると謎の再宣言

②ちゃんと引数で渡す(王道)

横着してはいけない(戒め)

$addC = {
    param($x,$y,$z)
    return $x + $y + $z
}                                
  • ✅ 明示的で安全、読みやすい
  • ❌ 参照したい変数が増えるほど引数が増殖してしんどい

③スクリプト/グローバル変数に設定(最終手段)

$addC = {
    param($x)
    $script:x = $x
    return {
        param($y)
        $script:y = $y
        return {
            param($z) return $x + $y + $z
        }
    }
}                             
  • ✅ 簡単
  • ❌ 環境汚染

🛠️自作クロージャへの挑戦

ここまでで分かったのは、

  • GetNewClosure単体では「上の階層の変数をキャプチャできない」
  • 応急処置はあるけど、どれもイマイチ格好がつかない

ということ。

「じゃあ、自分で”変数キャプチャ機能”を作っちゃえ!」

…そう考えて実装したのが、今回のClosureクラスです。

🎩自作クロージャのアイデア

シンプルに言うと、やっていることは3つ

  1. スクリプトブロック内で参照している変数名をASTで解析
  2. その変数をScript/Globalスコープから回収してハッシュに保持(=キャプチャ)
  3. 実行時にローカル変数として復元してからスクリプトブロックを呼び出す

これで関数定義の環境を再現できます。
さらにInvoke()内で工夫しているので、クロージャがクロージャを返す場合でもスコープが伝搬します。つまりカリー化・部分適用にも応用できます。

📑コード全文(再度)

自作クロージャ
Closure.ps1
using namespace System.Management.Automation.Language
class Closure{
    hidden [ValidateNotNull()][scriptblock]$function
    hidden [ValidateNotNull()][hashtable]$capturedScope = @{}

    Closure(){}
    Closure([scriptblock]$func, [hashtable]$capturedScope = @{}){
        $this.function = $func
        $this.capturedScope = $capturedScope
    }
    Closure([scriptblock]$func){
        $this.function = $func
        # スクリプトブロック定義時のスコープをキャプチャ
        $this.capturedScope = @{}
        $varNames = [Closure]::getVarNamesInFuncScope($func)
        foreach($varName in $varNames){
            $exitVar =  Get-Variable -Name $varName -ValueOnly -Scope Script `
                -ErrorAction SilentlyContinue
            if($null -ne $exitVar){ $this.capturedScope[$varName] = $exitVar; continue }
            $exitVar = Get-Variable -Name $varName -ValueOnly -Scope Global `
                -ErrorAction SilentlyContinue
            if($null -ne $exitVar){ $this.capturedScope[$varName] = $exitVar }
        }
    }

    [object]Invoke([object[]]$arg){
        #キャプチャした変数をスクリプトブロックのスコープに展開
        foreach($key in $this.capturedScope.Keys){
            Set-Variable -Name $key -Value $this.capturedScope[$key] -Scope Local
        }
        #ドットソースでスクリプトブロックを実行(スコープをメソッド内と同一にするため)
        $result = . $this.function @arg

        #戻り値がスクリプトブロック/クロージャでなければ
        #現在のスコープをキャプチャし直してから結果を返す
        if($result -isnot [scriptblock] -and $result -isnot [Closure]){
            $varNames = [Closure]::getVarNamesInFuncScope($this.function)
            foreach($varName in $varNames){
                $this.capturedScope[$varName] = Get-Variable -Name $varName -ValueOnly `
                    -Scope Local -ErrorAction SilentlyContinue
            }
            return $result
        }

        #戻り値がクロージャの場合
        if($result -is [Closure]){
            #内側のクロージャがキャプチャ済の変数を取得
            $caputerdScopeInInner = $result.capturedScope.Clone() #シャローコピーなので注意
            
            #現在展開済のメソッド内スコープから内側のクロージャに必要な変数を抽出
            #重複した変数は上書きする(優先度:メソッド内スコープ > 内側クロージャスコープ)
            $varNames = [Closure]::getVarNamesInFuncScope($result.function)
            foreach($varName in $varNames){
                $varExit = Get-Variable -Name $varName -ValueOnly -Scope Local `
                    -ErrorAction SilentlyContinue
                if($null -ne $varExit){ $caputerdScopeInInner[$varName]=$varExit;continue }
                #どのスコープにも存在しない変数にはnullを割り当てる
                if(-not $caputerdScopeInInner.ContainsKey($varName)){
                    $caputerdScopeInInner[$varName] = $null
                }
            }

            #新しいクロージャを返す
            return [Closure]@{
                function = $result.function
                capturedScope = $caputerdScopeInInner
            }
        }

        #戻り値がスクリプトブロックの場合
        #現在展開済のメソッド内スコープからスクリプトブロックに必要な変数を抽出
        $varScopeToCapture = @{}
        $varNames = [Closure]::getVarNamesInFuncScope($result)
        foreach($varName in $varNames){
            $varScopeToCapture[$varName] = Get-Variable -Name $varName -ValueOnly `
                -Scope Local -ErrorAction SilentlyContinue
        }

        #新しいClosureを作成
        return [Closure]@{
            function = $result
            capturedScope = $varScopeToCapture
        }
    }

    #スクリプトブロックのスコープ内で参照されている変数の名前を取得するヘルパーメソッド
    hidden static [string[]]getVarNamesInFuncScope([scriptblock]$func){
        $ast = [Parser]::ParseInput($func.ToString(), [ref]$null, [ref]$null)
        $varNames = $ast.FindAll({$args[0] -is [VariableExpressionAst]}, $true) | 
            ForEach-Object { $_.VariablePath.UserPath } | Select-Object -Unique
        return $varNames
    }
}

🔎分割して解説

Invokeメソッドの役割

  1. キャプチャした変数を展開する
    Invoke()はまず、capturedScopeに保存しておいた変数をローカルスコープに展開します。
    ここが"外側の文脈を呼び戻す"肝です。
foreach($key in $this.capturedScope.Keys){
    Set-Variable -Name $key -Value $this.capturedScope[$key] -Scope Local
}
  • -Scope Localを明示することで、いま実行しているInvoke()のスコープに値を展開します。
  • これにより、スクリプトブロック本体から外側で定義した状態を参照できます。
  1. 本体スクリプトブロックを実行する
$result = . $this.function @arg
  • ドットソースで呼び出すことでInvoke()内のスコープと$this.function内部のスコープを揃えることができます。
  • 引数はObject[]型で受け取ることで、引数の数に対して柔軟に対応できます。
  1. 戻り値が普通の値の場合

    • 単なる値(数値や文字列など)の場合は、そのまま返します。
    • ただし$this.function内で更新された変数を次回の実行時に持ち越すため、
      値を返す前にキャプチャし直します。
  2. 戻り値がScriptBlockの場合

    • 新しく返ってきた ScriptBlock が参照する変数を再収集
    • それを基に新しいClosureに包み込んで返します。
  3. 戻り値がClosureの場合

    • 既存のClosureが持っている
      capturedScopeと現在のローカルスコープをマージ
    • 重複は「いまのローカル」を優先して新しいClosureを返す

ScriptBlockを返したとき

「ScriptBlockを返す=次の階層の関数を返す」なので、現在階層未満で必要とする変数を伝搬します。

  1. 返ってきたScriptBlockをAST解析
  2. VariableExpressionAstから参照する変数名だけを抽出
  3. **"いまのローカル"**から該当名の値を再収集し、現在の辞書の値を上書きする
  4. その辞書をcapturedScopeにした新しいClosureを返す

これで「必要な分の外部文脈」を伝搬できます。
とくにカリー化のように段を跨いで文脈を受け渡す必要のあるパターンで効いてきます。

Closureを返したとき

「Closureを返す=すでに"状態をキャプチャ済み"のScriptBlockを返す」
ここで行う必要のあるものは既存の辞書とのマージです。

  • 内側のcaptureedScope:すでに確保済みの文脈
  • 現在のローカル:直前の実行で新たに確定した最新の文脈

方針:今のローカルスコープが正
変数名衝突時はローカルを優先して上書きします。
これは「直前の段で決まった値を優先する」直感に合致します。

結果として、入れ子の連鎖でも破綻せずに文脈が伝搬していきます。

⚠️注意・限界

  • パフォーマンス:AST解析+展開の分、ネイティブGetNewClosureより動作が重くなる可能性。ホットパスは計測推奨
  • 関数定義の完全サポート:今回は「変数」の伝搬が主眼。function 定義の扱いは用途に応じて要検証

まとめ

  • GetNewClosureはネストで破綻しやすい
  • 自作Closureは ASTで参照変数を抽出 → Script/Globalからキャプチャ → 実行時にローカルへ復元
  • さらに 戻り値に応じた再キャプチャ/マージ で、次の段にも文脈が伝搬
  • 結果として、カリー化や部分適用を直感的に扱えるようになる

後半部分でだいぶ力尽きてます。やっぱり真面目に文章をまとめるのは難しいですね…

GitHubで編集を提案

Discussion