VBA

VBA ほぼタイプセーフなコレクション

More than 3 years have passed since last update.

VBA やってますか?

VBAってプログラマーに人気ないですよね、たぶん(´・ω・`)
でも私のような個人で小さなお仕事をこなして食いつないでいる、ワープア系プログラマにとってはマクロ作成依頼が大事な収入源だったりします。
言語の機能はとても貧弱だし、私の大好きな C# とは比べることすら失礼なほどに手の掛かる子ですけど、慣れるとかわいいものです( ´∀`)

いまどき配列使ってる男の人って・・・

ダメなところは色々あるけど、やっぱり配列だけは最悪・・・
固定配列は扱いにくいし、可変配列はもっと困ります。
基本的には素直に Collection クラスを使うべきなんですけど、すでにあるマクロの保守で配列を使わざるをえないケースもあると思うので問題点を考えてみます。

可変配列は ReDim でメモリーを確保します。
要素数がいくつあるのかは UBound で配列の添え字の最大値から間接的に調べることが出来ます。
そこで以下のように処理を書いてみると

' ここではまだ要素数は決らない
Dim arr() As String

Sub test
    ' いろいろ処理があって・・・

    ' 中身がなかったら要素を5個確保しようね
    ' ### 例外発生 ###
    If UBound(arr) < 0 Then
        ReDim arr(5) As String
    End If
End Sub

2014-08-23_172406.png

宣言して1度も ReDim していない配列は要素数すら調べることができないのです。
宣言と同時に ReDim するか、または宣言時に要素数がまだ分からない場合は、ReDim 済みかそうでないかを管理するフラグ変数をセットで持つしかないのです。(ひどいぉ)

結論、配列はかなぐり捨てるものだっ!

それでも配列を使わなければならないなら

なんとかして ReDim していない配列でも UBound で例外を発生させずに値を返せるようにしよう(゚∀。)アヒャ

標準モジュール
Public Enum SAFEARRAY_VT
    VT_BYTE = 1
    VT_BOOL = 2
    VT_INTEGER = 2
    VT_LONG = 4
    VT_SINGLE = 4
    VT_DOUBLE = 8
    VT_CURRENCY = 8
    VT_DECIMAL = 14
    VT_DATE = 8
    VT_OBJECT = 4
    VT_STRING = 10
    VT_VARIANT = 16
End Enum

Private Declare Sub CopyMemory Lib "KERNEL32.DLL" Alias "RtlMoveMemory" (ByRef Destination As Any, ByRef Source As Any, ByVal Length As Long)
Private Declare Function SafeArrayAllocDescriptor Lib "oleaut32" Alias "SafeArrayAllocDescriptor" (ByVal cDims As Long, ByVal ppsaOut As Long) As Long

Public Sub CreateEmptyArray(ByRef vArray As Variant, _
                            ByVal eType As SAFEARRAY_VT)
    Dim lpArray As Long
    Call CopyMemory(lpArray, ByVal VarPtr(vArray) + 8, 4)
    Call SafeArrayAllocDescriptor(1, lpArray)
    Call CopyMemory(lpArray, ByVal lpArray, 4)
    Call CopyMemory(ByVal lpArray + 4, eType, 4)
End Sub

Sub test()
    Dim arr() As Integer
    Call CreateEmptyArray(arr, VT_INTEGER)
    Debug.Print UBound(arr)  ' -1
End Sub

VARIANT 構造体のドキュメントを見ながら書いてみたけど、コードが合っているのかも正直よくわかりませんw
いちおう UBound で -1 が返ってくるようにはなりましたが、素直にフラグ管理しよう(´・ω・`)

やっぱりコレクションだよね

コレクションを使うと要素数は Count で取れるし、追加も削除もメソッドがあるのでサクサクできますよね。
でもコレクションはどんな型でも追加出来るから、使う側がミスしても実行時エラーが発生するまで分からない・・・。
そこで、Collection をラップして String 型専用のコレクションを作ってみます。

StringCollection (クラスモジュール)
Private m_strings As Collection

Private Sub Class_Initialize()
    Set m_strings = New Collection
End Sub

Public Function Add(ByVal node As String) As String
    Call m_strings.Add(node)
    Add = node
End Function

Public Sub Remove(ByVal index As Integer)
    Call m_strings.Remove(index + 1)
End Sub

Public Property Get Count() As Long
    Count = m_strings.Count
End Property

Public Property Get Item(ByVal index As Long) As String
    Item = m_strings(index + 1)
End Property

このようにして作った自作のコレクションは組み込みの Collection と違って、要素にアクセスするのに Item プロパティを明示的に呼ばなくてはいけません。
VBA のプロパティ省略機能って罠のような気もするので、これはこれでいいかもしれないけど For Each で回せないのはすっごい微妙です。

標準モジュール
Sub test()
    Dim list As New StringCollection
    Call list.Add("りんご")
    Call list.Add("みかん")
    Call list.Add("いちご")
    Debug.Print list.Item(0) ' りんご

    For Each item In list ' エラー
        Debug.Print item
    Next
End Sub

VBA のクラスにも属性が存在します

省略プロパティと For Each 対応は属性を使うことで解決できます。
でも属性は VBA のエディタ画面では設定できません。
一旦クラスモジュールをエクスポートして、*.cls ファイルをメモ帳などで開いて編集してください。
まずはクラスのデフォルトプロパティを Item プロパティにしてみます。

StringCollection
Public Property Get Item(ByVal index As Long) As String
Attribute Item.VB_UserMemId = 0
    Item = m_strings(index + 1)
End Property

2行目の Attribute で始まる記述がデフォルトプロパティを指定する属性です。
意味は分からなくてもいいと思います。仕様ですw

次は For Each 構文に対応します。
C# のコレクションでおなじみの GetEnumerator() メソッドで取得できる IEnumerator と大体一緒で、要素を列挙するクラスを Collection が持っています。
COM の IEnumVARIANT というインターフェースで、このインターフェースをもったクラス自体を自作することも可能ですけど、LINQ の VBA 版でも自分で作ろうと思わない限り使い道はないと思います。

StringCollection
Public Function NewEnum() As stdole.IUnknown
Attribute NewEnum.VB_UserMemId = -4
Attribute NewEnum.VB_MemberFlags = "40"
    Set NewEnum = m_strings.[_NewEnum]
End Function

上の Attribute はこのメソッドが For Each 構文でイテレーターを返すメソッドだぞーっていう指定です(たぶん)。
下の Attribute はこのメソッドはインテリセンスに表示しないようにしてね、っていう C# の EditorBrowsable 属性と大体おなじです。
m_strings.[_NewEnum] という見慣れない記述ですが、VBA はプロパティ名を括弧で囲むことで構文違反になるような名前のメンバーでも呼び出せる仕組みがあるんですね。

オブジェクトブラウザで Collection クラスを見てみると非表示メンバの中に _NewEnum メソッドがあるのが分かります。
2014-08-23_190753.png

これで先ほどのサンプルコードを実行するとちゃんと For Each で列挙できるようになります。
上手く使えば読み取り専用のコレクションを作ったり、使いやすいライブラリも作れますので是非お試しください。