【VBA】可変長引数の罠

# 可変長引数とは
可変長引数とは、引数の個数が固定ではなく、任意の個数を渡せる引数です。
例を挙げると2016で追加されたワークシート関数の`CONCAT`関数。この関数は渡された引数を全て文字列結合した値を返します。
VBAで可変長引数のプロシージャを実装したい場合、引数を`ByVal`や`ByRef`ではなく、`ParamArray`を付けて宣言します。
渡されたn個の引数は、`ParamArray`で宣言した引数にまとめられて、プロシージャの中では配列として扱うことができます。

## `ParamArray`のおやくそく
- `Variant`型の配列として宣言する(`ParamArray 引数名() As Variant`のように明示的に型まで指定を推奨)
- ひとつのプロシージャにつきひとつだけ宣言できる
- 他にも引数がある場合、末尾でのみ宣言できる
- `ParamArray`に先行する引数は、省略可能にできない(`Optional`を付けた引数とは共存できない)
- 配列または`ByRef`で`Variant`型を想定している別のプロシージャには、引数として渡せない(一度`Variant`型の変数に格納してから渡す必要がある)
- `ParamArray`の引数に対して、`Erase`または`Redim`を使用できない
- `ParamArray`の引数の配列は、`Option Base 数値`の影響を受けない(`LBound`関数の戻り値は必ず「0」)
- 渡された引数が0個の場合、`ParamArray`の引数の配列の`UBound`関数の戻り値は、「-1」となる

## ParamArrayで可変長引数を受け取る
Aさんは、下記の指示を受けて、`Concat`プロシージャを実装しました。
> 複数の文字列を指定した区切り文字を挟んで連結した文字列を返却するプロシージャを実装する。
> 渡す文字列の個数は任意に設定できること。
> 渡した文字列が0個の場合は、空文字列を返却すること。

```vb
Option Explicit

Sub Test()
    Debug.Print "値なし" & Concat("/")
    Debug.Print Concat("/", "あああ", "いいい", "ううう")
End Sub

' 指定された区切り文字を挟みながら渡された文字列を全て結合する
Private Function Concat(ByVal separator As String, ParamArray values() As Variant) As String
    Concat = vbNullString
    Dim value As Variant: For Each value In values
        If 0 < Len(Concat) Then
            Concat = Concat & separator
        End If
        Concat = Concat & value
    Next
End Function
```

### 実行結果

```
値なし
あああ/いいい/ううう
```

### ParamArrayの扱われ方
`Concat`の`Concat = vbNullString`にブレークポイントを設定し、実行中の引数をローカルウィンドウで表示してみましょう。
呼出し時の引数が、先頭インデックス0の配列として`values`にすべて渡されたのが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117413/96ebeb05-7c85-a200-3776-efd43af6044e.png)

# ParamArrayからParamArrayへ
先ほどの`Concat`プロシージャですが、以下の要望が出ました。
> 区切り文字は大抵決まった文字でしか呼び出さないので、省略したい。
> `Optional`を使えば固定の1種類なら実現できるけど、「,」と「-」の2種類から選べるようにしたい。

## ParamArrayで受け取った引数を別のプロシージャのParamArrayの引数に渡す
Aさんは上記の要望を受けて、下記のようなプロシージャを2つ追加することにしました。

```vb
' 区切り文字「,」で文字列結合
Private Function ConcatComma(ParamArray values() As Variant) As String
    ConcatComma = Concat(",", values)
End Function

' 区切り文字「-」で文字列結合
Private Function ConcatHyphen(ParamArray values() As Variant) As String
    ConcatHyphen = Concat("-", values)
End Function
```
`Test`プロシージャにも上記のプロシージャを追加して…

```vb
Sub Test()
    Debug.Print "値なし" & Concat("/")
    Debug.Print Concat("/", "あああ", "いいい", "ううう")
    Debug.Print ConcatComma("かかか", "ききき", "くくく")
    Debug.Print ConcatHyphen("さささ", "ししし", "すすす")
End Sub
```

### 実行結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117413/0322307f-9448-ee54-eeb4-da53131809f8.png)
コンパイルエラーは起きなかったけど、実行エラーが発生してしまいました。
エラーは`Concat`プロシージャの`Concat = Concat & value`の行で発生しています。
…`String`型しかないのにどうして？

こんな時は、ローカルウィンドウで変数を確認してみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117413/278dd2a5-af7f-834e-61dc-4eaa11868795.png)
`ParamArray`の引数`values`が**ジャグ配列(入れ子の配列)になってる…**
そうです。`ParamArray`で受け取った引数を`ParamArray`に渡すと、受け取った側はもう一度配列でラップしてしまうのです。
これでは`ParamArray`で渡すたびに配列の入れ子が深くなってしまう…
エラー内容としては、文字列と配列を`&`演算子で結合しようとしているから。
だから型が一致しないエラーが発生していたんですね。

## 解決方法
解決方法は、2つあります。

### 1. `Concat`プロシージャの引数を`ParamArray`ではなく、普通の配列にする
`Private Function Concat(ByVal separator As String, ByVal values As Variant) As String`に変更します。
この解決法は簡単ですが、呼び出し側で必ず配列にしてから渡すことになり、ひと手間増えます。
直接`Concat`プロシージャを呼び出している箇所は、すべて`Array`関数で括って配列に変換するように修正する必要が出てきます。
既存のコードに呼び出し箇所がたくさんある場合、大変ですね…

### 2. `ParamArray`の引数について、配列の入れ子を解消する
`ParamArray`の引数を持つプロシージャでは、本来の処理を行う前に、配列のネストが一番深い配列を取り出して入れ子を解消する必要があることがわかりました。
下記の実装例では、複数回`ParamArray`で渡された場合の考慮として再帰呼出しを行うことで、ネストの一番深い配列を返却できるようにしました。
`Do～Loop`で実装できなくもないのですが、VBAはショートサーキット演算子がないので。
誤って配列じゃない引数は配列でラップして返却、明らかに`ParamArray`以外での入れ子の配列は未編集で返却します。

```vb
' 可変長引数を可変長引数として渡した場合の対応
Private Function NormalizeParamArray(ByVal values As Variant) As Variant
   NormalizeParamArray = values
    If Not IsArray(values) Then
        ' 配列以外の場合、ラップして配列で返却
        NormalizeParamArray = Array(values)
        Exit Function
    End If
    If Not (UBound(values) = 0 And LBound(values) = UBound(values)) Then
        ' 要素数1以外、または先頭インデックスが「0」以外の場合、そのまま返却
        Exit Function
    End If
    If IsArray(values(0)) Then
        ' 入れ子の配列の場合、再帰呼出し
        NormalizeParamArray = NormalizeParamArray(values(0))
        Exit Function
    End If
End Function
```
`Concat`プロシージャでも`ConcatComma`・`ConcatHyphen`プロシージャに追加するのでも構いませんが、今回は`Concat`プロシージャに追加します。
`ParamArray`の引数は、`ByVal`としてしか扱えません。[^1]
副作用がありませんので、変換結果を`values`に上書き代入しています。

```vb
Private Function Concat(ByVal separator As String, ParamArray values() As Variant) As String
    values = NormalizeParamArray(values)	' ここに追加
    Concat = vbNullString
    Dim value As Variant: For Each value In values
        If 0 < Len(Concat) Then
            Concat = Concat & separator
        End If
        Concat = Concat & value
    Next
End Function
```

### 実行結果
エラーも発生せず、意図した通りの実行結果になりました。

```
値なし
あああ/いいい/ううう
かかか,ききき,くくく
さささ-ししし-すすす
```

---
他言語、例えばJavaは、可変長引数を別のメソッドにそのまま渡しても、配列でラップされません。
VBAでは、割と他言語の知識が邪魔をしますね…

[^1]: `ParamArray`で渡されたのが値の場合。渡されたのが配列丸ごとである場合、`ByRef`となり副作用が発生する。`ByVal`として渡したい場合、`ParamArray`に限ったことではないが、各配列(の変数名)を`()`で括る。