Windows OS において、バックアップスクリプトを書こうとしたのですが、cmd.exe のバッチファイル〈.batファイル〉で書くのは辛いので Powershell のスクリプト〈.ps1ファイル〉として書くことにしました。
Powershell は、シェル(OSのコマンドラインUI)としては使ってますが、プログラミング言語としての Powershell はあまり知りませんでした*1。
ちょっとだけ(バックアップスクリプトを書くために必要なだけ)プログラミング言語 Powershell を調べてみました。
内容:
- 全般的な印象
- コマンド、コマンドレット、関数、演算子
- 関数の入出力
- Powershell のコードハイライト
- ネーミングコンベンションと動詞
- 型の指定
- エラー処理
- 余談: バッチファイルにウンザリした話
- その他の言語機能
- おわりに
全般的な印象
プログラミング言語としての Powershell は、シェルスクリプト言語の伝統に沿っています。パイプラインのようなシェル独自の機能を持つので、通常のプログラミング言語とはちょっと違った(奇妙とも言える)言語仕様を持ちます。後で述べる関数の入出力はその最たるものでしょう。
Powershell は、.NET Framework に載っている言語処理系です。.NET が持つ豊富なデータ型やそのプロパティ/メソッドを利用できます。この部分は、bash のような従来のシェルスクリプト言語とは大きく違う部分です。例えば、[Math]::cos([Math]::PI)
(三角関数の呼び出し)なんて計算ができます。.NET の機能にアクセスする部分は(おそらく必然的に) C# っぽい印象があります。
Perl や Ruby のような軽量言語の影響も受けているようです。Schema の香りを感じることもあります。Powershell は2000年代に生まれた言語なので、20世紀の言語達を色々と参考にしているようです。
そんな事情で、Powershell の言語仕様は一貫性がない寄せ集めの印象があります。一貫性がない言語は、構文や挙動が予測しにくくて、憶えることが多いのがデメリットです。もっとも、一貫性がないのはシェルスクリプト言語の伝統だとも言えます(「これだからシェルは…」参照)。
クセの強さを楽しんだり、様々な言語から受け継いだ特性のブレンド具合を愛でるという付き合い方があるかも知れません。
コマンド、コマンドレット、関数、演算子
Powershell 独自の言葉に「コマンドレット〈commandlet〉」があります*2。普通の(従来よりよく知られた)言葉で言えば「内部コマンド」です。シェル自体に内蔵されているコマンドですね。
単にコマンドといえば、外部にある実行可能ファイルのことのようですが、紛らわしいので、実行可能ファイルは外部コマンドということにします。
関数は、Powershell 言語で書かれた実行単位のことです。(コマンドレットは主に C# で実装されているようです。)ユーザーが追加する“コマンド”は関数です。Powershellプログラミングの主たる作業は、関数を定義することです。関数の特別なものとしてフィルターがありますが、これは関数として取り扱っていいでしょう。
'+'(足し算)や '*'(掛け算)のような演算子は普通にありますが、Powershell ではハイフンから始まる名前の演算子があります。例えば、論理演算子である AND, OR は -and, -or です。比較演算子も ==, <, <= ではなくて -eq, -lt, -le です*3。フォーマット演算子 -f とか、文字列の連結/分割を行う演算子 -join, -split とかもあります。ハイフン始まりの演算子というと、Unix系の test コマンドを思い出しますね(「testコマンドのハイフンナンチャラが憶えられない」参照)。
結局、Powershell において“実行できるもの”は以下の4種類だと思っていいでしょう。
- 外部コマンド
- コマンドレット
- 関数(フィルターを含む)
- 演算子
外部コマンド、コマンドレット、関数は、ある程度は同じように扱えるようになっています。あくまで「ある程度は」ですが。
Powershell言語の関数に CmdletBinding という属性を付けて、お作法に従った書き方をすると、ネイティブなコマンドレット(コンパイル言語で実装されて、.NETバイナリにコンパイルされたコード)と区別できない関数を作れるようですが、詳細は知らない。
関数の入出力
関数の入出力は、他の言語とはだいぶ違っていて面食らうのではないでしょうか。次の関数を考えましょう。
function Out-ReceivedValues { foreach ($a in $args) { $a } foreach ($i in $input) { $i } }
上記コードをコピーして、Powershell コマンドラインにペーストすると関数が定義できます。実行すると以下のようです。(> は、コマンドラインのプロンプト記号。)
> 1,2 | Out-ReceivedValues a b c a b c 1 2
コマンドライン引数〈パラメータ〉からの文字列達 a, b, c と、パイプ入力からの整数達 1, 2 がすべて出力に吐き出されます。絵に描くと次のようです。
$args と $input は配列(あるいはイテレータ)です。$args には引数〈パラメータ〉の値達が入っており、$input にはパイプ入力からの値達が入っています。関数 Out-ReceivedValues は何もしないで、単に受け取った値達を出力に流すだけです。
上記の場合に、foreach ループを展開すると、次のコードブロックを実行したのと同じです。
{ 'a' 'b' 'c' 1 2 }
関数は複数の戻り値〈multiple return values〉を出力に返すので、多値関数〈multiple-value function〉なのです。
return は関数を終了させますが、return文以前に出力に吐き出された戻り値は取り消しできません。単一値だけを返す return とは発想が全然違いますから注意が必要です。Out-ReceivedValues を次のように変更してみます。
function Out-ReceivedValues { foreach ($a in $args) { $a } foreach ($i in $input) { return $i } }
実行すると:
> 1,2 | Out-ReceivedValues a b c a b c 1
a, b, c は出力されます。1 を return して終わるので、2 は出力されません。
混乱を避けるために、次のような言葉を使うと良いかと思います。
- 引数〈パラメータ〉値 : 配列 $args に入っている値
- パイプライン入力値 : 配列 $input に入っている値
- 受け取り値 : 引数値またはパイプライン入力値(総称的に)
- 戻り値 : 出力に返す値(複数かも知れない)
関数の入出力とパイプラインに関しては、jq を彷彿とさせますね。「パイプライン指向JSON処理プログラミング言語 jq」参照。
Powershell のコードハイライト
はてなブログでの話ですが、Powershell のコードハイライト〈カラーリング〉は出来ないもんだと思っていました。プログラミング言語名に powershell, pwsh, ps などを入れてもハイライトしなかったからです。
Powershell の(はてなブログでの)名前は ps1 でした。なるほど、拡張子と同じだったのか。気が付かなかったよ。
ネーミングコンベンションと動詞
Powershell の関数名は、動詞と目的語をハイフンで繋いだ名前にする、というネーミングコンベンションがあります。このコンベンションを守らなくても動きますが、色々と不都合、というか受けられるべき恩恵を受けられなくなるので守ったほうがいいです*4。
さらに、動詞には“承認されている動詞”が決まっています。VSCodeのPowershell拡張だと、“承認されていない動詞”を使うと警告が出ます。僕は、Make-LogFilePath という関数名を使ったのですが、Make, Build, Create, Construct などの動詞は承認されてないので、New-LogFilePath にしました。Load, Check も承認されてないので次のようなリネームをしました。
Load-SettingsFile → Read-SettingsFile
Check-SettingsObject → Test-SettingsObject
既存のコマンドレットは、当然にネーミングコンベンションを守っています。Get-ChildItem(ls 相当)、Set-Location(cd 相当)とか。承認された動詞の一覧は、コマンドレット Get-Verb で得られます。Put, Take, Peek, Lookup とかも未承認かぁ、ちと辛い。
Powershell では、名前の大文字小文字は区別されません。が、サンプルコードやVSCodeの補完などを見ると、関数名はパスカルケースにするようです。なので、僕もそうしています、New-LogFilePath のように。new-logfilepath とか NEW-LOGFILEPATH にしても実行には問題ありませんが、可読性や検索では問題が出るので、あたかも大文字小文字を区別するかのごとく扱ったほうが無難です。
もうひとつ名前に関する注意事項として; Powershell が最初から提供している変数(自動変数という)と同じ名前を、引数変数名に使うとロクデモナイことが起きるようです。以下に書いてあります。
型の指定
Powershell は型をゆるく扱う言語なので、変数の型を宣言する義務はありません。つうか、型を宣言するメカニズムは整備されていません。
関数の引数〈パラメータ〉に対しては、型を指定することができます。型は、型の名前をブラケットで囲んだ形式で書きます。次は、実際のバックアップスクリプト内の関数の例です。
function Get-SettingsFilePath([String[]] $array) { if ($array.Length -eq 0) { throw "No arg" } $first = $array[0] if (Test-Path -Path $first -PathType Leaf) { $jsonFile = $first } elseif (Test-Path -Path $first -PathType Container) { $jsonFile = Join-Path -Path $first -ChildPath $DEFAULT_SETTINGS_FILE_NAME } else { throw "File not exist" } return [String]($jsonFile) }
関数 Get-SettingsFilePath の引数 $array の型は [String[]] で、これは文字列配列のことです。
戻り値の型を指定する方法はあるにはあります(後述)が、あまり役に立ちません。上記の関数は単一戻り値なので、return のなかで [String] 型であることを示しています。[String]‥‥ という書き方はキャスト(型変換)の書き方なのですが、戻り値型の注釈として使っています。戻り値に限らず、適宜 [型名](‥‥) という注釈(実際はキャストだが)を挟んでおくと、型を追いかけるのが容易になります。
さて、戻り値型の指定ですが、次のような書き方ができます*5。
function Get-Sum { [OutputType([Int32])] param([String] $a, [String] $b) return ($a + $b) }
この関数 Get-Sum は文字列型の引数を2つ取って、整数型を返すと宣言されています。しかし、宣言に反して、戻り値の型は文字列です。Powershell の実行系がエラーにしてくれると期待したのですが:
> Get-Sum hello world helloworld
特に何も起きません(ガクッ)。OutputType の指定は、関数が持つプロパティとしてはセットされているようです。
> (Get-Command Get-Sum).OutputType Name Type TypeDefinitionAst ---- ---- ----------------- System.Int32 System.Int32
戻り値(複数あるかも知れない)をキチンと型付けして実行時に型チェックするのは難しそうではあります。同様に、パイプライン入力値の型チェックは、宣言的にはどうも無理そう。都度、コード内で人手でチェックするしかないようです(なんかワザがあるのか?)。
エラー処理
Powershell のエラー処理がまっとうなのにちょっと驚きました。シェルスクリプト言語のエラー処理というと、bash のように trap でやりくりして頑張るもんだと思っていたので。
以下は、バックアップスクリプトのなかでの try-catch-finally ブロックです。中身はどうでもいいので、エラー処理構文のまっとうさを感じ取ってください。
try { $settingsFilePath = Get-SettingsFilePath $args $settingsObject = Read-SettingsFile $settingsFilePath $settingsObject = Test-SettingsObject $settingsObject $robocopyCommandLine = New-RobocopyCommandLine $settingsObject Write-Host $robocopyCommandLine Invoke-Expression $robocopyCommandLine } catch { Write-Host $_ Write-Host $_.ScriptStackTrace } finally { Read-Host -Prompt 'Hit Enter key' }
余談: バッチファイルにウンザリした話
バックアップスクリプトでは、日付時刻を含むファイル名が欲しい場面があります。cmd.exe の変数 %date%, %time% に日付と時刻が文字列で入っています。これらの文字列を加工してファイル名を作ります。例えば、%date:/=-% とすると、日付文字列内のスラッシュをハイフンに置き換えることができます。%time:~0,8% で、時刻文字列の最初の8文字を切り出せます。
しかし困ったことに、日付と時刻の文字列のフォーマットはシステムの設定*6で変わってしまいます。設定に依存しない処理が書けません。「設定は固定されている」前提でも、バッチファイル内の文字列処理はぎごちなくて気持ち悪い。やりたくない。
Powershell の Get-Date コマンドレットは、.NET のDateTime型オブジェクトを返します。(Get-Date).Year は、文字列ではなくてInt32型データです。ファイル名の生成は、フォーマット演算子 -f を使って、テンプレート文字列に対して整数値データ達を送り込むことで達成できます(古い言い方をすると sprintf 方式)*7。
バッチファイルで、try-catch-finally 方式のエラー処理は無理です。外部コマンドの終了ステータス〈エラーレベル〉を調べて条件分岐するくらいがせいぜい。if と goto が入り乱れることになるでしょう。やりたくない。
Powershellのエラー処理は前節に示したとおりです。まとも。
「Powershell スクリプトを実行するために、バッチファイルを作っておく」とかいう説明を見て、「なんでそんなことするんだ?」と思ったのですが、GUIシェル〈ファイルエクスプローラー〉から .ps1 ファイルをダブルクリックしても実行できないとのこと。バッチファイル〈.bat ファイル〉はダブルクリックで実行できる、と。
さらには、そのバッチファイルを [Win]+[R] から実行するとか、コマンドプロンプト〈cmd.exe〉ウィンドウを開いてバッチファイルをドラッグ&ドロップするとか ‥‥ えええっ、みんなそんなことするの?
「ターミナルとシェルも人間が使うUIである」という認識がだんだん無くなっているのかも知れない。
[/追記の余談]
直前の「追記の余談」を書いた後で、今の人はターミナルUI〈キャラクタUI〉をどう使ってんだろう? と興味がわいて Youtube 検索して見たら、copy コマンドの引数のファイル名をファイルエクスプローラーの右クリック「パスのコピー」してからペーストとか、ファイルアイコンをターミナルウィンドウにドラッグとかしていて、老人の僕はちょっと驚いた。
なるほどね、ターミナル+シェルだけを使う、ってことはあまりなくて、「たまに使うけっこう便利なツール」がシェルなのね。
[/追記の余談 2]
その他の言語機能
Powershellスクリプトは、拡張子 .ps1 のファイルに書きます。スクリプトを実行するには、例えば次のようにします。
> powershell -NoProfile .\backup.ps1 .\backup-settings.json
ドットソース構文(ドットの後にスクリプトファイル名)を使うと、現在実行中のシェル(対話的インタプリタ)内にスクリプトを読み込んで実行できます。ただし、別プロセスでの実行を想定したスクリプトをドットソースで実行するとうまくいかないこともあります。
関数の定義だけを集めたライブラリのようなスクリプトファイルならドットソースで読み込んで利用できます。
> . .\MyUsefulFunctions.ps1
Powershell にはモジュールシステムがあるので、スクリプトファイルをモジュールにして、インポートすることもできます。
> Import-Module MyUsefulFunctions
モジュールにメタデータを付けるなど約束に従った作業をすると、パッケージに仕立てることができます。パッケージは、Powershellプログラムを公開・共有・配布する単位です。インターネット上のパッケージリポジトリ・サイトとして https://www.powershellgallery.com/(Powershellギャラリー)があります。パッケージ管理に必要な機能はコマンドレットとして用意されています。
Powershell では、クラスも定義できます。クラス定義をすると、.NET のクラス(型)とだいたい同じように扱えます。クラスのオブジェクトを作って、プロパティやメソッドを使える、ということです。現時点で、インターフェイスは無いようです。
モダンなプログラミング言語機能がひと通り揃っているんですよね。従来のシェルスクリプト言語のイメージ(少なくとも僕のイメージ)をくつがえすものです。
あっ、そうだ。みんな大好きラムダ抽象〈匿名関数 | 関数オブジェクト〉も書けます。
{param($x, $y) $x + $y}
評価〈エバル〉するには、Invokeメソッドを呼びます。
> {param($x, $y) $x + $y}.Invoke(2, 3) 5
不格好ですね、可愛いですね。
おわりに
Windows OS に付属の Powershell はバージョン5.1からしばらくバージョンアップしていません。が、クロスプラットフォーム版のPowershellの最新は .NET 9.0 上のバージョン7.5です。Windows Powershell のUnicodeテキストをうまく扱えないという問題(「PowerShellの困った話:文字エンコーディング」参照)も、クロスプラットフォーム版では解決されているようです。
シェルスクリプト言語という性格上、Powershell言語は地味な存在ですが、クロスプラットフォームなシェルスクリプト言語としての進化は継続していくでしょう。言語仕様は美しくはありませんが、変な言語仕様に惹かれる人もけっこういるので、少しずつユーザも増えそうですね。
*1:10年くらい前にちょっとスクリプトを書いたことはあります。その当時の記事 → 「PowerShellの困った話:文字エンコーディング」。が、おおむね忘れました。
*2:UnrealEngine でも「コマンドレット」があるようですが。
*3:'<' や '>' 、それと '|' はパイプラインで使うので演算子には使いにくい、という事情もあります。
*4:守れないとき/守りたくないときがありますが、どのようなときに規約を破るかは自分で決めておいたほうがいいでしょう。
*5:クラスのメソッドの場合は、メソッド名の前に戻り値型を書けます。
*6:intl.cpl を起動して設定できます。
*7:簡単なフォーマットなら、Get-Date だけでも文字列を生成できます。 Get-Date -Format "yyyy-MM-dd HH:mm:ss" とかします。