しばたテックブログ

PowerShellを中心に気分で書いている技術ブログです。

Get-ChildItemの動作に関する問題についての指摘事項

本エントリはブログに書くべきものなのか迷いましたが、文章量が多くなったためここに記します。
また、出題者を責める意図は無いためリンクは張りませんのでご了承ください。

出題された問題

先日Twitter上でPowerShellに関する以下の問題を見かけました。

問題

Windows Powershellにおいて、以下のコマンドを実行した。
実行結果はどうなる?

PS > (Get-ChildItem).Length

回答欄

  1. オブジェクトが無いのでエラーになる
  2. ディレクトリ配下のファイルの総容量が表示される
  3. ディレクトリ配下のファイルの数が表示される
  4. コマンドが存在しないためエラーになる

出題者の意図としては3. ディレクトリ配下のファイルの数が表示されるを選ばせたかったのでしょうが、残念ながらこの選択肢は全て正しくありません。
(前提条件次第では回答欄の内容にマッチする場合もあるのですが、本エントリでは回答欄として不適切の意味で"正しくない"と表現しています)

以下、何が正しくないのかについて順に指摘していきます。

PowerShellにおけるロケーション

まず、DocsにあるGet-ChildItemのヘルプを確認してみると、

Gets the items and child items in one or more specified locations.

とあり、このコマンドレットは「特定の ロケーション にある要素および子要素を取得する」ものです。

blog.shibata.tech

でも触れたとおり、PowerShellにおいて ロケーション はファイルシステム以外のデータ構造も対象とするより抽象度の高い概念です。

このためGet-ChildItemGet-DirectoryItemの様な名前でなくGet-ChildItemが選ばれているといえます。
(とはいえ、ファイルシステムを扱うのが主であるため、現実としては利便性のためにdirlsといったエイリアスが設定されていますが...)

先述の問題の場合、例えばロケーションをレジストリにすると

> cd HKCU:\Software\
> (Get-ChildItem).Length

で取得できるのは「レジストリの値とサブキーの総数」になります。

この問題を正しく出題するためにはロケーションをドライブレベルで確定する必要があります。

PowerShellのバージョン間での挙動の違い

PowerShellにおけるLengthプロパティはバージョン間で挙動が違います。

PowerShell 2.0まで

LengthプロパティはSystem.Arrayのプロパティであり、配列でないスカラ値には使用できません。

例えば

# PowerShell 2.0ではスカラ値にLengthは使えない
> (123).Length
> 

といった指定は何も返しませんし、Strict Modeのレベルによってはエラーとなります。

> Set-StrictMode -Version 2.0
> (123).Length
. : このオブジェクトにプロパティ 'Length' が見つかりません。このプロパティが存在することを確認してください。
発生場所 行:1 文字:7
+ (123). <<<< Length
    + CategoryInfo          : InvalidOperation: (.:OperatorToken) []、RuntimeException
    + FullyQualifiedErrorId : PropertyNotFoundStrict

PowerShell 3.0以降

配列でないスカラ値に対してLengthプロパティが拡張され、

# スカラ値のLengthは1
> (123).Length 
> 1

# $nullのLengthは0
> $null.Length
> 0

といった値を返す様になっています。

最初の問題に戻ると、出題時にバージョンを指定していないので挙動が不定となり回答を確定することが出来ません。
ロケーションの明示もないため、(Get-ChildItem).Lengthが返しうる結果としては、

  • エラー
  • $null
  • 0
  • 1以上の数

のいずれかになります。

敢えて選択肢から答えを選ぶとするなら

  • 1 オブジェクトが無いのでエラーになる
  • 3 ディレクトリ配下のファイルの数が表示される

のいずれか、が近い感じでしょうか。

System.IO.FileInfo.Length プロパティ

【2018/06/11追記】

一点指摘漏れがありました。
(ついさっきまで完全に失念していました...)

例として適当なディレクトリで単一のファイルを作成するケースを考えます。

mkdir sample
cd .\sample\
# 適当なファイル(BOM付きUTF-16)を作成
"Hello World!" | Out-File -FilePath .\sample.txt

ここで(Get-ChildItem).Lengthを実行すると、

> (Get-ChildItem).Length
30

sample.txtのファイルサイズを返します。

これは対象フォルダ内にファイルが一つしかない場合はGet-ChildItemの戻り値はSystem.IO.FileInfo型となり、この型にあるLengthプロパティが使われるためです。

この場合2. ディレクトリ配下のファイルの総容量が表示されるが正答になります。

ちなみに、

mkdir sample2
cd .\sample2\
# 適当なサブディレクトリを作成
mkdir subdir

とサブディレクトリ1つだけの場合はGet-ChildItemの戻り値はSystem.IO.DirectoryInfo型となり、この型にはLengthプロパティはありませんので、(Get-ChildItem).Lengthの結果は

  • エラー
  • $null
  • 1

のいずれかになります。

【追記ここまで】

どう出題すればよかったか?

この問題で不足していたのは環境定義です。

PowerShellの環境はその気になればいくらでもカスタマイズできるので抜け道を全て埋めるのは難しいのですが、一般的な余興レベルとしては

問題

Windows 10においてWindows PowerShellを起動し、直ちに以下のコマンドを実行した。
実行結果はどうなる?

PS > (Get-ChildItem).Length

回答欄

  1. オブジェクトが無いのでエラーになる
  2. ディレクトリ配下のフォルダ、ファイルの総容量が表示される
  3. ディレクトリ配下のフォルダ、ファイルの数が表示される
  4. コマンドが存在しないためエラーになる

とし、

Windows 10

  • PowerShell 5.0~5.1 であることが確定している

Windows PowerShellを起動し、直ちに以下のコマンドを実行した。

  • 環境をカスタマイズしない限りはユーザープロファイルのフォルダ、またはC:\WINDOWS\system32(管理者として実行した場合)がカレントロケーションとなる

の条件を暗示するくらいで良いでしょう。

補足

ちなみに、PowerShellの言語モードや、対象となるロケーションのアクセス権などによっても(Get-ChildItem).Lengthの結果が変わってしまいますが、さすがにそれは穿ち過ぎなので指摘の対象から外しています。

PowerShellの"罠"と呼ばれるモノについて

私は普段ブログのネタ探しのためにいくつかのSNSやフォーラムなどを巡回しているのですが、その際に

PowerShellの罠にハマる

といった表現をそれなりに目にしてきました。

大抵のツールに初学者にとってハマりやすい"罠"はあると思いますが、PowerShellにも"罠"と言われても仕方のない部分は結構あります。
本エントリではこの"罠"について思うところを書き連ねていきます。

"罠"の一覧

PowerShellはPowerShell 1.0がリリースされてからもうすぐ12年目を迎えつつある歴史の長いツールです。
このため"罠"の数もそれなりにあり、既に有志によって一覧はまとめられています。

github.com

個々の"罠"の詳細についてはこちらのリポジトリをご覧いただければ十分でしょう。

また、日本語の情報としては、

がPowerShellを使う上でハマりやすいポイントを良く解説してくれており参考になります。

PowerShell 3.0 での破壊的変更

こちらは"罠"と少し違うのですが、PowerShellはPowerShell 3.0でランタイムが刷新され、それに合わせて幾つかの破壊的変更が生じています。
この破壊的変更の一覧はWMF 3.0のリリースノートに軽く記載されているだけで詳細な解説がありません。

情報の得にくさとしてはこれも"罠"といっても良いかもしれません。*1

【2018/12/01追記】

PowerShell 3.0での破壊的変更の一覧をブログにまとめました。

blog.shibata.tech

【追記ここまで】

"罠"を感じる心理的な問題

あくまで私の経験上ですが、PowerShellを学ぶ人が"罠"を感じるのには心理的な要因もあるのではないかと思っています。

非常に大事な前提なのですが、

PowerShellはコマンドプロンプトでは無いし*shでも無い。

この前提を誤解して"罠"と見做されるケースが多い様に感じます。
これは一見当たり前の様に見えますが非常に大切なことです。

まず、PowerShellは時代的な経緯からコマンドプロンプトの後継の様に誤解されることが多いのですが、後発ではあるものの後継ではありませんしコンセプトが異なるツールです。
また、Bash等のシェルから影響を受けている部分があるため、いくつか概念や語彙に重なる部分はあるのですが、大原則としてPowerShellはBashなど*shなシェルとはまるで異質のものです。

PowerShellと他のシェルを混同してしまっているため(他のシェルに対しての)期待した動作を得られず「PowerShellには"罠"がある。」と言われるケースを多く見てきました。

PowerShellの異質さ

PowerShellの異質さについては私が以前発表したスライドを見てもらうと理解して頂けるかと思います。

www.slideshare.net

www.slideshare.net

これらで触れている

PowerShellはAPIを志向している

PowerShellは.NET Frameworkのオブジェクトを扱うシェルである

といった点は他のシェルに慣れた方からするとかなり異質に感じることでしょう。
とはいえ、これはPowerShellを扱う上で必須の前提であり、この前提に立てば"罠"と感じることも減ると思います。*2

私的"罠"四天王

心理的要因とは関係なく普通にハマってしまうポイントも当然あります。
私自身もいくつもの"罠"を体験してPowerShellを学習してきました。

最初の項の内容とかぶる部分もありますが、私がハマってきたいくつかの"罠"を最後に紹介します。

1. 文字コード

2018年現在、PowerShell Coreが登場してPowerShellはクロスプラットフォームなアプリケーションとなっていますが、元々はWindowsの.NET Framework上で動作するアプリケーションでした。

このためPowerShellで取り扱える文字コードは.NET FrameworkおよびWindowsの影響を大きく受けています。

リダイレクト演算子やファイル操作に関わるコマンドレットの既定のエンコーディングがBOM付きUTF-16であることやUTF-8がBOM付きなのは、基本的には.NET Frameworkにおけるエンコーディングがそうである影響です。*3

また、非英語圏におけるエンコーディングは俗にANSIエンコーディングとも呼ばれ、OSのロケール設定に依存し日本語環境であればSHIFT-JIS(CP932)です。
PowerShellではコンソール上で扱える文字種に影響を及ぼしています。

コマンドプロンプトを常用する人であれば文字コードにSHIFT-JISを期待するでしょうし、Bashを常用する人であればBOM無しUTF-8を期待するでしょう。
PowerShellでは扱う文字コードを常に意識しておくと"罠"を回避できます。

2. -Pathパラメーターにおけるワイルドカード

これは罠を超えてPowerShell最大最悪の失敗仕様であり、多くの方にとってPowerShellへのヘイトを募らせる要因となっているかと思います。

PowerShellはコマンドプロンプトからの影響を受け、パラメーターに*?[]の4種の文字をワイルドカードとして使用できます。
それぞれ、

  • * : 0文字以上の任意の文字列
  • ? : 1文字の任意の文字
  • [ ] : []内で指定したパターンにマッチする文字列

となっています。

各コマンドレットがワイルドカード検索に対応するかどうかは一応任意なのですが、開発ガイドラインとしては

  • A cmdlet should support wildcard characters if possible.

  • The name of the parameter should be Path, with an alias of PSPath. Additionally, the Path parameter should support wildcard characters. If support for wildcard characters is not required, define a LiteralPath parameter.

となっており、Microsoft製のコマンドレットであれば-Name-Pathパラメーターはほぼ100%ワイルドカード検索をサポートしています。

ここで-Pathパラメーターが大問題で、Windowsの仕様では[]がディレクトリ・ファイル名として使用可能です。*4
またディレクトリ・ファイルを扱うコマンドレットにおいて-Pathパラメーターが既定となっており、例えば

mkdir .\[Test]
cd [Test]

なんてことをすると、ディレクトリ名にある[]を正しく認識できずエラーになってしまいます。
(ワイルドカードとして扱われてしまう)

f:id:stknohg:20180531205356p:plain

この場合は

cd -LiteralPath [test]

の様に-LiteralPathを使うか

cd '.\`[Test`]\'

の様にワイルドカード文字をエスケープする必要があります。

f:id:stknohg:20180531205723p:plain

この点に関しては、

ディレクトリ・ファイルを扱うコマンドレットでは原則 -LiteralPath パラメーターを使う。

ことを徹底するのをお勧めします。

3. 配列の平坦化 (パイプライン文)

PowerShellではコマンドレットなどの実行結果を変数に代入する際、実行結果のオブジェクトが複数ある場合はオブジェクトの配列(Object[])として扱われ、単一のオブジェクトの場合は配列ではなくそのオブジェクト自身を取得します。
挙動だけ見ると配列を期待した処理に対して平坦化が行われている様に見え、非常に直感的でない挙動に感じてしまうでしょう。

この挙動はパイプライン文(Pipeline statements)と呼ばれるPowerShell独自の構文に依るものです。
パイプライン文は名前の通りパイプライン(|)を扱うために用意された文なのですが、PowerShellのパイプラインは.NET Frameworkのオブジェクトを扱うオブジェクトパイプラインであり、他のシェルのそれとは完全に異質のものです。

わかりやすい表現をすると、これは厳密には間違っているのですが、PowerShellにおいてコマンドレットなどの実行結果は最終的にパイプライン文に行きつき、このパイプライン文は評価されたオブジェクトを一つずつストリーム(他のシェルの標準出力に近いもの)に放流する動作をします。
ストリームに放流されたオブジェクトはパイプライン演算子(|)でパイプすることができます。
放流されたオブジェクトは、途中で代入やパイプされなかった場合は最終的にPowerShell内部で| Out-Defaultと暗黙的にパイプされコンソールにその結果を表示する様になっています。

オブジェクトの取得(代入)はストリームに放流されたオブジェクトを途中で浚うイメージです。
(あくまでイメージであり正確な動作ではありません)
浚ったオブジェクトが単一であればそのまま扱い、複数個あれば配列(Object[])になる配列化が実際の挙動に近く、このため、必ず配列が欲しいといった場合は配列部分式演算子(@())などによる明示的なキャストが必要になります。

このパイプライン文の挙動は非常にわかりにくく、私もまだ厳密に正しい挙動を押さえることができていません...
ただ、パイプライン文やストリームといったものがあることを認識するだけでもPowerShellに対する理解はかなり変わると思います。

これらの詳細については本ブログの以下のエントリが役に立つでしょう。

blog.shibata.tech

blog.shibata.tech

加えて、PowerShellの関数もこのパイプライン文の影響下にあり独特の挙動をします。
こちらについては以下のエントリをご覧ください。

blog.shibata.tech

4. 配列に対する-eq演算子

最後の罠です。

blog.shibata.tech

で触れているのですが、配列に対して-eq演算子はフィルターとして動作します。

これを回避するには

if ($null -eq $array) {
    # do something
}

の様にヨーダ記法にする必要があります。

この点に関して私はハマる前に気が付けたので実害は被っていないのですが、罠としてはかなり凶悪な部類に入ると思います。

最後に

思うがままにPowerShellの"罠"について書いてきました。

PowerShellは他のシェルと比べて似て非なるツールであり誤解を生みやすい部分は残念ながらあります。
この点に関してPowerShellの設計は「悪い」としか言いようがありません。

しかしながら、PowerShellの前提・仕様を正しく押さえ、"罠"があることを知ってしまえば回避は容易です。

PowerShellは設計に悪い部分はありますが使いこなすと十分な実利を得ることができるツールだと私は思っています。
本エントリの内容が"罠"を回避してPowerShellで実利を得る一助になれば幸いです。

*1:時間があれば本ブログでもまとめたいですね...

*2:ただし仕様の理不尽さが無くなるとは言わないです...

*3:なお、最新のPowerShell Core 6.0ではクロスプラットフォーム化の影響もあり既定のエンコーディングはBOMなしUTF-8になっています

*4:*と?は不可

Azure Cloud ShellのPowerShellは起動時に何をしているのか

Azure Cloud ShellでPowerShellを起動した場合、通常のPowerShellの起動とは異なる初期処理が行われAzureドライブ(Azure:\で始まるドライブ)にロケーションが移動された状態で開始されます。

  • Cloud Shell(PowerShell)起動時

f:id:stknohg:20180524170552p:plain

  • Cloud Shell(Bash)からPowerShell Coreを起動した場合

f:id:stknohg:20180524170600p:plain

(PowerShellのコンテナでPowerShell Coreを起動しても同様)

本エントリではこの処理がどの様にして行われているのか解説します。

Cloud Shell(PowerShell)の場合

Cloud Shell(PowerShell)の場合、シェルの起動時に初期処理が行われています。
PowerShellの初期化処理といえばプロファイルに記述するのが一般的ですが、Cloud Shell(PowerShell)ではWindows PowerShellのプロファイルには何も記述されていません。

Cloud Shell(PowerShell)ではpowershell.exeの引数に初期化処理が記述されています。
powershell.exeの起動時引数は以下の様にすれば確認でき、

Get-CimInstance Win32_Process -Filter "processid = $PID" | Select-Object -ExpandProperty CommandLine

その結果

PS Azure:\> Get-CimInstance Win32_Process -Filter "processid = $PID" | Select-Object -ExpandProperty CommandLine
powershell.exe  -NoProfile -NoExit -NoLogo -command "Set-PSReadlineOption -TokenKind String -ForegroundColor Cyan; . ~\PSCloudShellStartup.ps1"

$env:USERPROFILE\PSCloudShellStartup.ps1を実行する様になっています。

また、PowerShell Coreのプロファイル(C:\Program Files\PowerShell\<version>\profile.ps1)は

#
# This profile script is used by PowerShell Core.
# Windows Powershell is started with -NoProfile to optimize its start time.
#

if (Test-Path $env:USERPROFILE\PSCloudShellStartup.ps1)
{
    . $env:USERPROFILE\PSCloudShellStartup.ps1
}

if (Get-Module -Name PSReadLine)
{
    # Set PSReadLine colors to be compatible with Cloud Shell UX requirements
    Set-PSReadlineOption -TokenKind String -ForegroundColor Cyan
}

と記述されており、こちらもPSCloudShellStartup.ps1を呼び出す様になっています。

Cloud Shell(Bash)の場合

Cloud Shell(Bash)の場合は、PowerShell Coreのプロファイル(/opt/microsoft/powershell/<version>/profile.ps1)に直接初期化処理が記述されており、その内容はPSCloudShellStartup.ps1と同一です。

初期化処理の詳細

現時点のPSCloudShellStartup.ps1(とprofile.ps1)の内容をGistに上げていますのでスクリプトの詳細はそちらで確認してください。

初期化処理は主に

  • Azure関連のモジュールの読み込みと認証
  • プロファイルパスの差し替え
  • prompt関数の差し替え
  • Azureドライブへの移動

を行っています。

この中でも特徴的なのがプロファイルパスの差し替えで、4つあるプロファイルの内

  • CurrentUserAllHosts
  • CurrentUserCurrentHost

のパスをそれぞれ

プロファイル Windows Ubuntu
CurrentUserAllHosts $env:USERPROFILE\CloudDrive\profile.ps1 $env:HOME/.config/PowerShell/profile.ps1
CurrentUserCurrentHost $env:USERPROFILE\CloudDrive\Microsoft.PowerShell_profile.ps1 $env:HOME/.config/PowerShell/Microsoft.PowerShell_profile.ps1

に差し替えて*1実行します。

加えて$PROFILE変数に付与されるNotePropertyからAllUsersAllHostsAllUsersCurrentHostを削除しています。
(正確には$PROFILE変数を上書き定義する際にNotePropertyの追加をしない様にしている)

Format-Listを使ってNotePropertyを確認すると以下の様になります。

PS Azure:\> $PROFILE | Format-List -Force

CurrentUserAllHosts    : C:\Users\ContainerAdministrator\CloudDrive\profile.ps1
CurrentUserCurrentHost : C:\Users\ContainerAdministrator\CloudDrive\Microsoft.PowerShell_profile.ps1
Length                 : 75

*1:細かい話をするとLinuxではパスが差し替わっていませんが、これはLinuxでは$env:HOME配下がCloud Drive上のVHDでマウントされ永続化対象となっているためです。Windowsではその様なマウントがないので永続化されるパスにプロファイルを差し替えています