PowerShellのGet-Contentでハマったところ

`Get-Content`コマンドレットの[オンラインヘルプ](http://go.microsoft.com/fwlink/?LinkID=113310)には、以下のような例が載っている。（2017年11月20日確認）

> ### Example 3: Get the fifth line of a text file
> ```
> PS C:\> (Get-Content Cmdlets.txt -TotalCount 5)[-1]
> ```
> This command gets the fifth line of the Cmdlets.txt text file. It uses the *TotalCount* parameter to get the first five lines and then uses array notation to get the last line (indicated by "-1") of the resulting set.

「`TotalCount`オプションで読み込む行数を指定し、`[-1]`で最後の要素を取り出すことで、指定した行だけを読み取る」という使い方の例だ。実際に試してみる。

```Cmdlets.txt
one
two
three
four
five
six
seven
```

```
PS > (Get-Content Cmdlets.txt -TotalCount 5)[-1]
five
```

確かに5行目が読み取れる。
ただし、このサンプルでは当然、Cmdlets.txtが5行未満の場合も考慮されず、単純に最後の行が返ってくる。

```Cmdlets_2.txt
one
two
three
```

```
PS > (Get-Content Cmdlets_2.txt -TotalCount 5)[-1]
three
```

ここまではいい。問題は、**テキストファイルの中身が1行のとき**だ。


```Cmdlets_3.txt
one
```

```
PS > (Get-Content Cmdlets_3.txt -TotalCount 5)[-1]
e
```

**！？**


## Why?

`Get-Content`の結果を`GetType`で確認する。

```check.ps1
$lines = Get-Content Cmdlets.txt
$lines.GetType()

$lines_2 = Get-Content Cmdlets_2.txt
$lines_2.GetType()

$lines_3 = Get-Content Cmdlets_3.txt
$lines_3.GetType()
```

```
IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Object[]                                 System.Array
True     True     Object[]                                 System.Array
True     True     String                                   System.Object
```

中身が1行しかないファイル（Cmdlets_3.txt）を読み取った場合は、**戻り値が配列（`Object[]`）ではなく文字列（`String`）になっている**。
よって`(Get-Content Cmdlets_3.txt -TotalCount 5)[-1]`は「文字列への添え字アクセス」となり、結果として`Char`＝1文字を返している。

```ps1:check.ps1に追加
$lines[-1].GetType()
$lines_2[-1].GetType()
$lines_3[-1].GetType()
```

```
IsPublic IsSerial Name                      BaseType
-------- -------- ----                      --------
True     True     String                    System.Object
True     True     String                    System.Object
True     True     Char                      System.ValueType
```

よって、文字列「one」の最後の1文字である「e」が表示された。


## 解決法

`@()`を使い、戻り値を配列に格納すればよい。

```ps1
$lines_3 = @(Get-Content Cmdlets_3.txt)
$lines_3.GetType()
$lines_3[-1].GetType()
```

```
IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Object[]                                 System.Array
True     True     String                                   System.Object
```

### 配列の展開

ここで、他のスクリプト言語の感覚だと**「元々配列が返る場合は、配列の配列（ジャグ配列）になってしまうのでは？」**と考えるのだけれど、どうやらPowerShellは**要素数が1のジャグ配列を勝手に展開する**ようだ。

```ps1
# ジャグ配列になっている例
$jagged = @(@(1, 2, 3), @(4, 5))
$jagged[0].GetType()
$jagged[1].GetType()

# ジャグ配列になっていない（1次元に展開される）例
$not_jagged = @(@(1, 2, 3))
$not_jagged[0].GetType()
$not_jagged[1].GetType()
```

```
IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Object[]                                 System.Array
True     True     Object[]                                 System.Array
True     True     Int32                                    System.ValueType
True     True     Int32                                    System.ValueType
```

`$jagged`の各要素は配列だが、`$not_jagged`の各要素には数値が直接入っている。
展開せず「要素数1のジャグ配列」を作るための書き方は下のようになるらしい。

```ps1
# 要素数1のジャグ配列になっている例
$jagged_2 = @(,@(1, 2, 3)) # 先頭にカンマをつける
$jagged_2[0].GetType()
#$jagged_2[1].GetType() # 存在しない
$jagged_2[0][0].GetType()
$jagged_2[0][1].GetType()
```

```
IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Object[]                                 System.Array
True     True     Int32                                    System.ValueType
True     True     Int32                                    System.ValueType
```


### 結論

「ファイルの5行目を読み取る。5行未満の場合は最後の行を読み取る。」という仕様を意図するなら、以下のようになる。

```
PS > @(Get-Content Cmdlets_3.txt -TotalCount 5)[-1]
one
```


### 私見

* 明示的に文字列で取得する`-Raw`オプションがあるのだから、そのオプションがないときは1行でも「要素数1の配列」で返した方がよくない？
* この仕様が上手く活きるユースケースを思いつかない
  - 実際のスクリプトは、この問題を意識していないか、`@(Get-Content ...)`の書き方に統一しているかの2択なのでは
* `Get-Content`も配列展開も「気の利かせ方」が独特で覚えるのが大変……


### 参考

* [`Get-Content`コマンドレットのオンラインヘルプ](http://go.microsoft.com/fwlink/?LinkID=113310)
* [ジャグ配列も、多次元配列も、あるんだよ - PowerShell Scripting Weblog](http://winscript.jp/powershell/219)