PowerShell のスコープ完全に理解した

はじめに

本記事は PowerShell Advent Calendar 2018 の 18 日目としてエントリーしていた記事です。
qiita.com

が、18 日中に公開できなかったばかりか、Advent Calendar 期間中にも間に合いませんでした。申し訳ない。

PowerShell のスコープの特殊性

C# でも C++ でも VB でも Java でも、変数のスコープの概念は大差ないと思います。
それらの言語と比べると、PowerShell のスコープは独特なので、戸惑うかもしれません。
しかし、基本を押さえてしまえば難しくないと思います。

変数を削除する方法

以下、サンプル コードを多数掲載します。中には「ここでエラーになります」と書いてあるものもあります。
が、「試してみてもエラーにならない…🤔」と悩むことがあるかもしれません。

そういう時は、意図しないところに変数が残っている可能性があります。
PowerShell を再起動するか、実行する前に、以下のように Remove-Variable コマンドを使って、変数を定義されていない状態に戻してみてください。

Remove-Variable -Name x -Force -ErrorAction Ignore

C# との違い

たとえば、以下のような C# コードはコンパイルできません。
これは、if 文のブロックがスコープを形成し、変数 x はそのブロック内でしか有効ではないからです。

void Hoge()
{
    if (true)
    {
        int x = 10;
    }

    Console.WriteLine(x);
}

一方、以下のような PowerShell コードは実行でき、"10" と表示されます。
PowerShell では if 文のブロックはスコープを形成しないからです。

Set-StrictMode -Version Latest

function Hoge {

    if ($true) {
        [int] $x = 10
    }

    Write-Host $x
}

Hoge

C# では以下のようなコードもエラーになります。
if がなくても { } だけでスコープを生成します。

void Hoge()
{
    {
        int x = 10;
    }

    Console.WriteLine(x);
}

PowerShell だと…

Set-StrictMode -Version Latest

function Hoge {

    {
        [int] $x = 10
    }

    Write-Host $x
}

Hoge

エラーになりましたね。
PowerShell では、if や for などの制御文なしで { } で囲った部分はスクリプト ブロックとして扱われます。匿名メソッドのようなものです。
が、そのスクリプト ブロックを実行していないため、変数 $x は定義されていません。

そうそう、Set-StrictMode コマンドを実行していることに注意してください。
あらかじめこのコマンドを実行しておくと、定義されていない変数を参照したときにエラーが出るようになります。

これがない場合、または、

Set-StrictMode -Off

とやった場合は、定義されていない変数を参照すると $null が返ります。

スコープを生成するものとしないもの

PowerShell でスコープが形成されるのは、以下のような場合です。

たとえば、先ほどのコードをこう書き換えて実行するとエラーになります。。

Set-StrictMode -Version Latest

function Hoge {

    if ($true) {
        [int] $x = 10
    }

    Write-Host $x
}

Hoge

$x # ここでエラー

Hoge の実行時に変数 $x が生成されますが、それは Hoge のスコープの内部でのみ有効であり、その外側からは見えないためです。

一方、以下のようなものはスコープを生成しません。

  • if や for といった制御構文
  • try - catch - finally
  • begin - process - end

そのため、これらの構文のブロック内で定義された変数は、同じ関数内であれば、ブロックの外でも参照できます。

foreach と ForEach-Object

foreach は制御構文なので、スコープを生成しません。
以下のコードでは、$x = 3 と表示されます。

Set-StrictMode -Version Latest

for ($i = 1; $i -le 3; ++$i) {
    $x = $i
}

Write-Host "`$x = $x"

では、これはどうでしょうか。

Set-StrictMode -Version Latest

1..3 | ForEach-Object {
    $y = $_
}

Write-Host "`$y = $y"

変数 $yスクリプト ブロック中で定義されているので、その外では参照できないはずですね。
実行してみましょう。……あれ、エラーにならない。

ForEach-Object コマンドのソースコードを見ると、useLocalScope という引数に false を渡していますね。
このため、スクリプト ブロックがスコープを形成しないのだと思います。

ForEach メソッドでも同様です。

Set-StrictMode -Version Latest

(1..3).ForEach({
    $z = $_
})

Write-Host "`$z = $z"

まぁ、こういうのは例外です。たぶん。

スコープを持つもの

PowerShell では、以下のものがスコープを持ちます。

たとえば以下のコードは(Set-StrictMode をしていなくても)エラーになります。
関数 Inner は関数 Outer のスコープ内でのみ有効であり、その外からは見えないからです。

function Outer {

    function Inner {

    }

    Inner
}

Outer

Inner # ここでエラー

本記事の内容は、変数にも関数にもあてはまるのですが、いちいち「変数や関数」と書くのは面倒くさいので、以降「変数」で統一します。

ダイナミック スコープ

PowerShell はダイナミック スコープを採用している言語です。
つまり、こういうことです。

$scriptblock = {
    $x
}

function Hello1 {
    $x = 10
    & $scriptblock
}

function Hello2 {
    $x = 20
    & $scriptblock
}

Hello1
Hello2

結果はこう。

10
20

Hello1 の $x と Hello2 の $x は同名ですが別の変数です。
Hello1 の中から $scriptblock を実行した場合は Hello1 のスコープの $x が見え、Hello2 の中から実行した場合は Hello2 のスコープの $x が見えています。

変数が参照された地点から、呼び出しスタックを遡っていって、変数の定義を探す感じですね。

先程「いちいち『変数や関数』とは書かない」と言ったばかりですが、もう一つだけダイナミック スコープの例を見ておきます。

$scriptblock = {
    Inner
}

function Hello1 {
    function Inner {
        Write-Host 'Inner1'
    }
    
    & $scriptblock
}

function Hello2 {
    function Inner {
        Write-Host 'Inner2'
    }

    & $scriptblock
}

Hello1
Hello2

結果はどうなるかわかりますね。
関数もダイナミック スコープに従って解決されます。

「ダイナミック スコープ」は、スコープの実装方法の名前であり、後述するスコープの名前ではありません。

基本はローカルスコープ

PowerShell のスコープには 4 種類あります」とかいう説明を見たことはありますか? 見たことがある人は忘れてください。スコープに種類なんてありません。
とはいえ、いくつか覚えておくとよいキーワードはあります。

まず「ローカル スコープ」です。これは「現在のスコープ」のこと、「今いるココ」のことです。

先程の例を再掲します。

$scriptblock = {
    $x
}

function Hello1 {
    $x = 10
    & $scriptblock
}

function Hello2 {
    $x = 20
    & $scriptblock
}

Hello1
Hello2

このコードには 4 つの(4 種類の、ではない)スコープがあります。
一番外側と、Hello1 の中と、Hello2 の中と、$scriptblock の中です。
それぞれの場所を実行しているとき、「そこ」がローカル スコープです。

Write-Host $x

とか

$x = 1

のような変数への参照は、ローカル スコープに対して行われます。

スコープの親子関係

スコープは親子関係を持ちます。
呼び出し元が親で、呼ばれる先が子です。
ですから、上記のコードで言えば、

一番外側 ー Hello1 ー $scriptblock という親子(孫)関係と
一番外側 ー Hello2 ー $scriptblock という親子(孫)関係があります。

スコープ間の可視性

子スコープからは親スコープで宣言された変数や関数を読み取ることができます。書き込むことはできません(今はまだ、そういうことにしておいてください)。

以下のようなコードを実行してみましょう。

function Hello {
    $x #10
    $x = 20
    $x #20
}

$x = 10
$x #10
Hello
$x #10

Hello の中で変数 $x の値を 20 に変更できたように思えますが、Hello から出ると $x の値は 10 に戻っています。
Hello の中では、あくまで Hello のスコープにおける $x の値を書き換えただけであって、外側の変数 $x は書き換わっていないのです。

スタック図で考える

イメージしやすくするために、スタック図(と勝手に命名したもの)を書いてみましょう。

上記のコードを再掲します。コードのところどころに番号を付けています。

function Hello {
    # [2]

    $x #10

    $x = 20

    # [3]

    $x #20
}

# [0]

$x = 10

# [1]

$x #10

Hello

# [4]

$x #10

初期状態では、スタックは空っぽです。コードの番号では [0] の状態です。

(空)

[1] の時点では、外側のスコープに変数 $x が宣言されています。

外側10
$x

[2] の時点では、Hello のスコープが作られます。が、そこにはまだ、$x はありません。
この状態で Hello 内から $x を参照すると、呼び出しチェーンを辿って、親スコープの $x が見えます。

Hello
外側10
$x

[3] の時点では、Hello スコープ内に変数 $x が宣言されています。これは親スコープの変数 $x とは別物で、Hello スコープ内からは Hello スコープの $x を見に行きます。

Hello20
外側10
$x

Hello の実行が終わって [4] の時点では、Hello スコープは無くなっています。

外側10
$x

概ね「スコープとは、呼び出しスタックの段のこと」だと思ってよさそうです。
ローカル スコープに無い変数は、親スコープを見に行くということと、子スコープ内で変数を設定しても、それは子スコープの変数を設定しただけだということは覚えておいてください。

設定されるまで値が存在していない証拠

上記のスタック図で、[2] の時点では、Hello スコープは存在していても、そこに変数 $x はありません。このことを確認してみましょう。
以下のようなコードを実行してみましょう。

Set-StrictMode -Version Latest

function Hello {
    $local:x # [2] ここでエラー
    $x = 20
    $local:x # [3]
}

$x = 10
$local:x # [1]
Hello
$local:x # [4]

$local:x とすることで、「ローカル スコープのみを参照する」ということを指示します。つまり、親スコープを見に行かなくなります。
実行すると [2] のところでエラーになります。[3] は正常に実行されます。
これが、変数を設定するまで、ローカル スコープにはその変数が存在しないことの証拠です。

スクリプト スコープ

いつまでも「一番外側のスコープ」などと呼んでいてはまどろっこしいので、もう少しマシな呼び名をつけましょう。
上記のコードが test1.ps1 というスクリプト ファイルに書かれて実行されていたとすると、「一番外側のスコープ」は「スクリプト スコープ」と呼ばれます。

スクリプト スコープとは、スクリプト ファイルに書かれていて、関数やスクリプト ブロックの中にないコードが属するスコープです。
スクリプト ファイルごとに固有のスコープを持つため、あるスクリプト ファイルから別のスクリプト ファイルを呼び出している場合、それぞれのスクリプト スコープは(親子関係を持ちますが)別のスコープになります。

コンソールから以下のように実行するとエラーになります。

PS> Set-StrictMode -Version Latest
PS> & .\test1.ps1
PS> $x

また、test2.ps1 に以下のように書いて実行してもエラーになります。

Set-StrictMode -Version Latest
& .\test1.ps1
$x

$x は test1.ps1 のスクリプト スコープの変数であって、その外側では見えないからです。

逆に、呼び出された側のスクリプト ファイルからは、親の変数が見えます。

# test1.ps1
Set-StrictMode -Version Latest
$x = 10
& .\test2.ps1
# test2.ps1
$x # 10

なお、スコープは実行が終了すると消滅してしまうため、同じスクリプト ファイルを 2 回実行しても、スクリプト スコープの変数は維持されません。

ここで注意しなければならないのは、スコープの名前は排他的ではないということです。
つまり、上記のコードの test1.ps1 の中では、そこは「test1.ps1 のスクリプト スコープ」であると同時に「ローカル スコープ」でもあるということです。
test2.ps1 の中では、そこが「test2.ps1 のスクリプト スコープ」であり、同時に「ローカル スコープ」でもあります。

グローバル スコープ

先程まで、スクリプト スコープのことを、便宜上「一番外側のスコープ」と呼んできましたが、さらに外側のスコープがあります。
test1.ps1 を、コンソールから以下のようにして呼び出しているとしましょう。

PS> & .\test1.ps1

このとき、このプロンプトが出ている場所は「グローバル スコープ」と言います。正真正銘、グローバル スコープがもっとも外側のスコープであり、親子関係で言えば、すべてのスコープの親にあたります。
くどいようですがもう一度言います。このプロンプトが出ている時点では、そこは「グローバル スコープ」兼「ローカル スコープ」です。

グローバル スコープは、PowerShell が実行を開始するときのスコープであり、コンソールのスコープです。
また、PowerShell プロファイルの内容はグローバル スコープに置かれます(後述しますが、つまり、プロファイルの内容はドット ソースで実行されているのです)。

明示的にスコープを指定した読み取り

先程、$local:x という書き方をしました。このように : の前に特定のキーワードをつけることで、スコープを限定することができます。これを「スコープ修飾子」と言います。

スコープ修飾子には、以下の 4 つがあります。

  • global
  • script
  • local
  • private

PowerShell のスコープには 4 種類あります」というようなのは、この 4 つのことなのですが、しかし、これらを「スコープの種類」と呼ぶのは適切ではありません。
既に見たように、グローバル スコープやスクリプト スコープが同時にローカル スコープでもあることはありますし、private はちょっと毛色が異なるものです。

以下の内容を test.ps1 としましょう。

Set-StrictMode -Version Latest

function Hello {

    $global:x # 1
    $script:x # 10
    $local:x # [3] エラー

    $x = 20 # [4]

    $global:x # 1
    $script:x # 10
    $local:x # 20

}

$global:x # 1
$script:x # [1] エラー
$local:x # エラー

$x = 10 # [2]

$global:x # 1
$script:x # 10
$local:x # 10

Hello

$global:x # 1 [5]
$script:x # 10
$local:x # 10

そしてこれを、コンソールから、以下のようにして実行します。

PS> $x = 1
PS> & .\test.ps1

各行の結果は、行末のコメントのようになります。

コンソールから実行した

PS> $x = 1

は、グローバル スコープに $x を宣言しています。

  • [1] の時点では、スクリプト スコープ=ローカル スコープには $x がないのでエラーになります。
  • [2] で $x に値を設定しています。スコープ修飾子をつけない場合、$local:x と同じ意味になります。この時点では、スクリプト スコープ=ローカル スコープなので、$script:x$local:x の結果は同じになります。
  • [3] の時点では、Hello のローカル スコープに $x がないのでエラー。これは先ほど見ましたね。
  • [4] で、Hello のローカル スコープに値を設定しているので、これ以降、$global:x$script:x$local:x はすべて異なる値を持ちます。
  • [5] では Hello のスコープが消滅して、[2] と同じ状態になります。

なんとなくイメージができてきましたか? 頭の中にスタック図が描けているでしょうか。

相対スコープ

スコープは親子関係を持つということは既に言いましたね。
現在のスコープから見て、相対的に1 つ上の親スコープ、2 つ上のスコープ…というのを参照する方法があります。
スコープ修飾子ではできないので、Get-Variable コマンドを使います。

# test.ps1

function Hello {

    Get-Variable -Name x -Scope 0 # ローカル スコープ
    Get-Variable -Name x -Scope 1 # 1つ上のスコープ=この場合はスクリプト スコープ
    Get-Variable -Name x -Scope 2 # 2つ上のスコープ=この場合はグローバル スコープ
    Get-Variable -Name x -Scope 3 # エラー。グローバルより上のスコープはない

}

Hello

関数が再帰処理をしている場合に、現在のローカルスコープから相対的に上のスコープの変数を参照することができます。

明示的にスコープを指定した書き込み

先程は

子スコープからは親スコープで宣言された変数や関数を読み取ることができます。書き込むことはできません(今はまだ、そういうことにしておいてください)。

と書きましたが、これは、ローカル スコープだけを相手にしていたからです。
既に見たように、スコープ修飾子を指定したり、Set-Variable コマンドの -Scope オプションを明示することで、親スコープの変数に書き込むことが可能です。
スコープを明示しない場合は、ローカル スコープへの書き込みになります。

現在のローカル スコープがどこであろうとも、

$global:x = 10

と書けば、グローバル スコープに変数を書き込むことができます。

スコープ修飾子を省略すると local と同じ意味になりますので、

$local:x = 10

のような記述には意味がありません(単に $x = 10 と書いても同じことです)。
local 修飾子は、読み込む際に、親スコープの変数を見ないという意図がある場合にのみ書く必要があります。

コンソールから実行した場合のスクリプト スコープ

コンソールから

PS> $x = 10

というコードを実行すると、$script:x でも参照することができます。
逆に、$script:x = 20 のように書いたものが、$global:x でも $local:x でも参照できます。
どうやら、コンソール上では、グローバル スコープ=スクリプト スコープ=ローカル スコープになっているようです。

プライベート スコープ

スコープ修飾子にはもう一つ、private というのがあります。これは他の修飾子とは、ちょっと性質が違います。

こんなコードを実行してみましょう。

# test.ps1

Set-StrictMode -Version Latest

function Hello {
    $x # エラー
}

$private:x = 10
$local:x # 10

Hello

エラーになりますね。
private 修飾子をつけた変数は、子スコープから見えなくなります。

$local:x で参照できることからもわかりますが、変数が定義される場所はローカル スコープです。
private 修飾子は、変数を定義する場所を指定するものではないのです。

プライベート スコープの変数にアクセスする方法

#test.ps1

Set-StrictMode -Version Latest

function Hello {
    $x # エラー

    $script:x # エラー

    Get-Variable -Name x -ValueOnly # エラー
    Get-Variable -Name x -Scope 1 -ValueOnly # 10
    Get-Variable -Name x -Scope Script -ValueOnly # 10

    $script:x = 20
}

$private:x = 10

Write-Host "`$local:x = $local:x"
Write-Host "`$script:x = $script:x"

Hello

$x # 20

変数 $xスクリプト スコープで定義されていますが、private 指定されているため、関数内から $script:x とやっても参照できません。
が、Get-Variable コマンドを使うと参照できてしまいます。Set-Variable でスコープを明示すると設定することもできます。
また、スコープ修飾子を指定して書き込むこともできます。

AllScope

New-Variable コマンドに -Option AllScope という引数をつけると、AllScope な変数を作ることができます。
AllScope な変数は、子に対して透過的に見えます。

Set-StrictMode -Version Latest

function Hello {
    $x #10
    $x = 20
}

New-Variable -Name x -Value 10 -Option AllScope

Hello

$x # 20

Hello 内でスコープを明示せずに書き込んでいるのに、親のスコープにその変更が反映されています。

スタック図を描くと、こんな感じです。

Hello10
外側
$x

AllScope であっても、グローバル スコープになるわけではなく、親スコープからは見えないのは変わりありません。

-Scope Private と -Option Private

以下のコードは等価です。

$private:x = 10
New-Variable -Name x -Option Private -Value 10

これは意味が違います。

New-Variable -Name x -Scope Private -Value 10

New-Variable コマンドの -Scope に与えられる引数はチェックされ、不正な値(たとえば 'XXX' とか)を渡すとエラーになります。
-Scope Private は受け入れられる値ですが、意味がありません(-Scope Local と同じ意味です)。

-Visibility Private

紛らわしいことに、New-Variable には -Visibility というオプションがあり、ここにも Private という値を渡すことができます。どんだけ Private 好きなんだこのコマンド…。

-Visibility には、Private と Public という値を渡すことができます。既定値は Public で、$x = 10 のように、普通に変数を定義した場合も Public です。
公式ドキュメントによると、モジュールの外部から見えない変数を作れるようなことが書いてありますね。
より詳細に言うと、Private には、「その変数を Runspace の外から参照できなくする」という効果があるらしいのですが……すいません、正直よくわかりません。

ドットソース

これまで、スクリプト ファイルやスクリプト ブロックは、以下のように & で実行してきました。

& .\test.ps1
& $scriptblock

& で実行する場合、これまで説明してきたように、新しいスコープが生成され、その中で実行されます。
実行されているスコープは、呼び出し元のスコープの子になります。
そのため、以下のコードは 10 と表示されます。
$x はグローバル スコープに定義されていますね。
スクリプト ブロックの中は、その子スコープであるため、そこから相対的に一段上のスコープ=グローバル スコープだからです。

PS> $x = 10
PS> & { Get-Variable -Name x -Scope 1 }

PowerShell にはもうひとつ、.(ドット)で実行する方法があります。これを「ドットソース」と呼びます。

. .\test.ps1
. $scriptblock

ドットソースで実行した場合、新しいスコープが作られません。
ファイルやスクリプト ブロックの中身が、現在のローカル スコープに展開されたように実行されます。

以下のように実行するとどうなるでしょうか?

PS> $x = 10
PS> . { Get-Variable -Name x -Scope 1 }

エラーになりますね。
ドットソースで実行したため、スクリプト ブロックの中身が、最上位のグローバル スコープで実行されているのです。それより上のスコープはありませんから、Get-Variable は失敗します。

VSCode での実行時の注意

Visual Studio CodePowerShell 拡張をインストールして検証していたのですが、どうも、これの機能を使ってスクリプト ファイルをデバッグ実行すると、ドットソース相当の実行になるらしく、

PS> .\test.ps1

のように実行した場合とは(グローバル スコープが関与する場合に)挙動が異なります。

オブジェクト内部の書き換え

既に見てきたように、親子関係にある子スコープ(ローカル スコープ)で変数に書き込もうとすると、(スコープを明示していない限りは)親スコープとは別の変数が子スコープ内に割り当てられます。
しかし、親スコープで宣言されたオブジェクトの一部を書き換えるような操作では、親スコープ内のオブジェクトが直接書き換えられます。
たとえば、以下のような操作がこれにあたります。

  • 配列の要素の書き換え
  • ハッシュテーブルの要素の書き換え
  • オブジェクトのメンバー変数の書き換え
  • etc
function Hello {
    $array[1] = 20
}

$array = 1,2,3

Write-Host '--- Before ---'
$array # 1,2,3

Hello

Write-Host '--- After ---'
$array # 1,20,3

クラスとスコープ

PowerShell 5 から PowerShell にもクラス構文が実装されました。
このクラスは、おおよそ C# のクラスと同じと思えばよいです。
つまり、変数の解決はダイナミック スコープではなくレキシカル スコープに基づいて行われますし、if 等の制御構文のブロック内で定義された変数は、ブロック外から見えなくなります。
ただし、制御構文は、PowerShell の通常の意味での(親子関係を持つ)スコープは形成しません。
制御構文の中と外は、親子関係的にはフラットです。

クラスのメソッド内で $x のように書くと、そのメソッド内の変数のみを参照します。メソッド内にその変数がなければ、(Set-StrictMode が -Off であっても)エラーになります。
クラスのフィールドを参照したい場合、明示的に $this.x と修飾しなければなりません(静的フィールドの場合はクラス名で修飾)。

ただし、$script:x などのようにスコープ修飾子を明示するか、Get-Variable 等のコマンドを使うことで、メソッド内からでもダイナミック スコープによる変数解決をさせることは可能です。

class Hoge
{
    $x = 1 # この x は $this.x と書かないと見えない
    static $sx = 1 # この変数は同クラス内からでも [Hoge]::sx とクラス名で修飾する必要がある

<#
    # これらのメソッドがあると Set-StrictMode -Off でも構文エラーになって実行できない

    [int] Invalid1() {

        # これはエラー。下記の Hello1 や Hello2 の中の $x は見えない。
        return $x

    }

    [int] Invalid2() {

        if ($true) {
            $x = 1
        }

        # これもエラー。if 文のブロック内で定義された $x は見えない。
        return $x      

    }
#>

    [int] GetX() {
        return Get-Variable -Name x -Scope 2 -ValueOnly
    }

    [int] GetY() {
        return $script:y
    }
}

$c = [Hoge]::new()

function GetX() {
    $c.GetX()
}

function Hello1() {
    $x = 100
    GetX
}

function Hello2() {
    $x = 200
    GetX
}

Hello1 # 100
Hello2 # 200

$y = 300
$c.GetY() # 300

クラス構文内では、クラスやメソッドの入れ子はできない仕様なので、そのへんは気にする必要はありません。

モジュールとスコープ

スクリプト モジュール(*.psm1)のスクリプト スコープで宣言された変数は、そのモジュールがロードされている限り維持されます。

以下のようなモジュールを作って…

# test.psm1

$script:foo = 10 # [1]

function GetVar {
    $script:foo
}

function SetVar {
    $script:foo = 100
}

読み込みます。

Set-StrictMode -Version Latest

Import-Module -Name .\test.psm1 -Force -Verbose

Import-Module コマンドを実行すると、test.psm1 内に書かれた [1] のコードが実行されます。
これが普通のスクリプト ファイルの実行であれば、この実行が終わると、スクリプト スコープは解体され、$foo は消滅します。
しかし、モジュールの場合は…

PS> GetVar
10

PS> SetVar
PS> GetVar
100

このように、変数の値が維持されていることが確認できます。
かつ、親スコープから子スコープの変数を書き換えることはできない(相対スコープは親スコープしか参照できない)ため、モジュール外からの書き換えを防ぐことができます。

バイナリ モジュール内での変数へのアクセス

PSCmdlet クラスから派生したコマンドレットであれば、以下のようなコードで、変数を設定、取得できます。

this.SessionState.PSVariable.Set("global:x", 100); // 設定
this.SessionState.PSVariable.GetValue("global:x"); // 取得

this.GetVariableValue("global:x"); // 取得はこれでも可能

スクリプト モジュールのように、インポートされたタイミングで変数の初期化をしたい場合は、以下のようなコードで可能です。

public class Module :
    IModuleAssemblyInitializer
{
    public void OnImport()
    {
        EngineIntrinsics engine;

        using (var ps = PowerShell.Create(RunspaceMode.CurrentRunspace))
        {
            ps.AddScript("$ExecutionContext", true);
            engine = ps.Invoke<EngineIntrinsics>().Single();
        }

        var varX = new PSVariable("global:x") {
            Value = 100
        };

        engine.SessionState.PSVariable.Set(varX);
    }
}

IModuleAssemblyInitializer インターフェイスを実装したクラスの OnImport メソッドは、そのモジュールがインポートされたタイミングで呼ばれます。

正直、バイナリ モジュールと PowerShell 変数の相性はよくありません。
上記のコードではグローバル スコープに設定していますが、それ故、モジュール外から書き換えることができてしまいます。
しかし、グローバル スコープ以外だと、バイナリ モジュールの .dll をどのように(コンソールから直接/スクリプト ファイル経由で)読み込んだかによって、変数が見える場合と見えない場合があったりして……ぶっちゃけ、気力が尽きました……。

バイナリ モジュールでも、コマンド間で共有され、モジュール外からは見えないような変数を作りたいのですが、うまい方法はわかりません。
どなたか、いい方法をご存知でしたら、教えて頂けないでしょうか。

クロージャ

ScriptBlock には GetNewClosure というメソッドがあります。
このメソッドは、新しい ScriptBlock オブジェクトを返しますが、このオブジェクトは、GetNewClosure メソッドを呼び出した時点での変数の値をキャプチャし、それ以降に変更されたダイナミック スコープの影響から逃れます。

文章だけだとわかりにくいので動きを見てみましょう。

$x = 1

$scriptblock = { $x }
$closure = $scriptblock.GetNewClosure()

& $scriptblock # 1
& $closure # 1

$x = 2

& $scriptblock # 2
& $closure # 1

$scriptblock は、普通のスクリプト ブロックなので、ダイナミック スコープに従い、実行された時点での最新の $x を参照します。
一方、$closure にとっての $x は、GetNewClosure メソッドが呼ばれた時点での値に固定されるため、$x の値を書き換えた後でも、以前の値を保持します。

クロージャ内での変数の書き換え

クロージャ内で、様々なスコープの変数を書き換えてみます。

ローカル スコープの場合
# test.ps1

$x = 1

$scriptblock = {
    ++$x
    $x
}

$closure = $scriptblock.GetNewClosure()

& $scriptblock # 2
& $scriptblock # 2

& $closure # 2
& $closure # 2

$x = 2

& $scriptblock # 3
& $closure # 2

$x # 2

$scriptblock の中でスコープを明示していないので、++$x は、スクリプト スコープの $x から値を読み取り、ローカル スコープに新しい変数を作ります。
その変数は実行するたびに作られて解体されるので、実行結果は保存されません。
$script:x は書き換わっていません。

そういえば、PowerShell だと ++ 演算子って値を返さないんですね。初めて知りました。

スクリプト スコープの場合
# test.ps1

$x = 1

$scriptblock = {
    ++$script:x
    $x
}

$closure = $scriptblock.GetNewClosure()

& $scriptblock # 2
& $scriptblock # 3

& $closure # 2
& $closure # 3

$x = 2

& $scriptblock # 3
& $closure # 4

$x # 3

$scriptblock の中でスクリプト スコープを明示しました。
$scriptblock を実行したときは、スクリプト スコープの $x が書き換わっています。
$closure の方は、クロージャの中にキャプチャされた変数を書き換えているため、書き換えた結果は保存されますが、外部には伝播していません。

グローバル スコープの場合
# test.ps1

$global:x = 1

$scriptblock = {
    ++$global:x
    $global:x
}

$closure = $scriptblock.GetNewClosure()

& $scriptblock # 2
& $scriptblock # 3

& $closure # 4
& $closure # 5

$global:x = 2

& $scriptblock # 3
& $closure # 4

$global:x # 4

この場合は、$closureクロージャの外の($scriptblock によって変更された)変数を読み取り、クロージャの外の変数に書き込んでいるのが分かります。

よくわからないケース

$scriptblock が返す変数から global 修飾子を外すと…

# test.ps1

$global:x = 1

$scriptblock = {
    ++$global:x
    $x
}

$closure = $scriptblock.GetNewClosure()

& $scriptblock # 2
& $scriptblock # 3

& $closure # 1
& $closure # 1

$global:x = 2

& $scriptblock # 3
& $closure # 1

$global:x # 4

この挙動が説明できるでしょうか。
$closure$global:x をインクリメントし、その変更は外部に及びますが、返している $x はキャプチャされた時点から変更されないので、常に 1 を返しているように思われます。

うーん…なんだか、スクリプト スコープの場合と、挙動に整合性がないような気がします。
このケースが良くわからないというより、スクリプト スコープのケースが、$scriptblock 内で返す変数に script 修飾子をつけてもつけなくても挙動が変わらない理由がよくわかりません。

構文上似ているやつら

PowerShell では、以下のような構文もあります。

$using:x

$env:x

using は、リモート セッション内のスクリプトから、ローカル側で定義した変数を参照するための構文です。これはスコープ修飾子ではありません。
また、$env:x は、環境変数 x を参照します。この env もスコープ修飾子ではありません。というかこれはキーワードですらなく、ドライブ名です。

4 つのスコープ修飾子と using 以外で : で修飾された名前は、Get-Content コマンド相当の挙動をします。
たとえば、以下の実行結果は同じになります。

$env:x
Get-Content -Path env:\x

こんなこともできます。

PS D:\Foo\Bar> echo hoge > hoge.txt
PS D:\Foo\Bar> ${D:hoge.txt}

hoge

PS D:\Foo\Bar> Get-Content -Path .\hoge.txt

hoge

ワイルドカードも使えますが、合致するアイテムが 1 つでないとエラーになります。

PS> echo hoge1 > hoge1.txt
PS> ${c:hoge*.txt}

hoge1

PS> echo hoge2 > hoge2.txt
PS> ${c:hoge*.txt}

変数を処理できません。変数パス 'c:hoge*.txt' が複数項目に解決されました。変数値を取得または設定できる項目は一度に 1 個だけです。
発生場所 行:1 文字:1
+ ${c:hoge*.txt}
+ ~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidArgument: (:) []、PSArgumentException
    + FullyQualifiedErrorId : Argument

指定したドライブのプロバイダーが FileSystem のような階層構造を持つものの場合、そのドライブのカレント ディレクトリ下で、指定したアイテムを探します。
正直、わかりにくいので、env ドライブや function ドライブのような、階層構造を持たないドライブでの使用に留めるべきでしょう。
FileSystem 等のプロバイダーでは、素直に Get-Content コマンドを使いましょう。

おわりに

正直、この記事を書くまで、自分でも「4 種類のスコープとかよくわからん…」と思っていました。
深堀りしようと思えば、まだ掘れるとは思いますが、キリがないのでまたの機会にして、一旦、本記事はここで切り上げることにします。

最後のエントリーがこんな感じで、実に締まらない締めくくりとなってしまいましたが、PowerShell Advent Calendar 2018 は、皆様のご協力のおかげで、ひとまず、全日程が埋まりました。
ありがとうございます!

2018 年は PowerShell Core 元年でした。
クロスプラットフォーム展開も果たし、今後、PowerShell の注目度はどんどん向上していくはずです(いいですね?)。

月並みではありますが、PowerShell と、PowerSheller 諸兄の益々の成功を祈念して、Advent Calendar の締めとしたいと思います。
皆様、良いお年をお迎えください。

qiita.com