えくせるちゅんちゅん

ことりがエクセルをちゅんちゅんするブログ

VBAのクラスでNewと同時に引数付きコンストラクタを起動する代替案

VBAのクラスをNewと同時に引数付きコンストラクタを起動する方法を、思いつく限りの方法をまとめておく。


VBAのコンストラク

コンストラクタとは、クラス(設計図)からインスタンス(実体)を生成した時に、強制的に実行される特別なプロシージャのことです。

逆にインスタンスが破棄された時に実行されるものをデストラクタと呼びます。

VBAにおいては、クラス名に関わらずClass_Initialize()Class_Terminate()という名称で決まっています。

さて、このコンストラクタですが、他言語開発者からすると信じられないような仕様となっています。

なんと、コンストラクタに引数を書くことが出来ません。

多くのVBAerが代替手段を探して試行錯誤しています。

この記事にたどり着いた方も同じような状況なのではないでしょうか?


私がTwitterでワイワイ・ガヤガヤする中で発見された、様々な書き方を全通り紹介します。

なお、どれも何らかの問題を備えているため、ベストプラクティスはありません。ご自分で決断してください。


コンストラクタに引数を書くための代替案

コンストラクタに引数が付けられないため、どうしても避けられないのが変わりのプロシージャの作成です。

要するに、Publicレベルで普通のプロシージャを作ります。

名前は何でも構いませんが、Init()を使う人が多いように感じます。

また、戻り値は自分自身にしておくと後々便利かと思います。


これから比較していく手法の基本形はこのようなプログラムとします。


MyClass

Option Explicit

Private name_
Private age_

Public Function Init1(pName, pAge) As MyClass
    Set Init1 = Me
    name_ = pName
    age_ = pAge
End Function

Public Function ToString() As String
    ToString = "名前:=" & name_ & " 年齢:=" & age_ & "歳"
End Function

※この記事では重要箇所アピールのため敢えて余分な型指定はしません。

※後々で別パターンのInitが発生するため、ソースコード上はInit1とします。


Module1

Sub Sample00()
    Dim obj As MyClass
    Set obj = New MyClass
    obj.Init1 "太郎", 10
    Debug.Print obj.ToString
End Sub


実行結果

名前:=太郎 年齢:=10歳


変数の宣言からコンストラクタの実行までに3行を必要としています。この部分の行数を抑えるのが目標となります。

Dim obj As MyClass
Set obj = New MyClass
obj.Init1 "太郎", 10


1.宣言と生成を同時に実行する

DimステートメントNewを付けることで、1行削減することが可能です。

Rem 1.宣言と生成を同時に実行する
Sub Sample10()
    Dim obj As New MyClass
    obj.Init1 "太郎", 10
    Debug.Print obj.ToString
End Sub


宣言と同時にNew

Dim obj As New MyClass
obj.Init1 "太郎", 10

この書き方に副作用があり、極めて限定的な状況下で注意が必要です。

objがNothingのまま使用された時にコンストラクタが実行されてobjにインスタンスが格納されます。

この性質のせいで、Set obj = Nothingを実行しても、if obj Is Nothing Thenとすると必ずFalseとなってしまいます。

また、インスタンス生成とコンストラクタが分離してしまうのは問題です。

宣言とインスタンス生成が繋がってしまうのも可読性が悪くなります。


例えば次のオブジェクト配列を作るような事例では、採用できません。

Sub Sample11()
    Dim arr(10)
    Dim i As Long
    For i = 0 To 10
        Set arr(i) = New MyClass
        arr(i).Init "a" & i, i
        Debug.Print arr(i).ToString
    Next
End Sub


2.コロンを使ってマルチステートメントにする

コロン:を使うことで、1行に複数のステートメントを書くことができます。

Rem 2.コロンを使ってマルチステートメントにする
Sub Sample20()
    Dim obj As MyClass
    Set obj = New MyClass: obj.Init1 "太郎", 10
    Set obj = New MyClass: Call obj.Init1("太郎", 10)
    Set obj = New MyClass: Set obj = obj.Init1("太郎", 10)
    Debug.Print obj.ToString
End Sub


命令調方式

Set obj = New MyClass: obj.Init1 "太郎", 10

Call方式

Callを使用して関数と同じように引数を(~)でブロックします。

Set obj = New MyClass: Call obj.Init1("太郎", 10)

関数方式

戻り値をobjに代入します。

Set obj = New MyClass: Set obj = obj.Init1("太郎", 10)


良い点

  • 生成とコンストラクタが1行になった。
  • 原文通りなので誰にでも読める。
  • Init()の入力補完が働く。

悪い点

  • objが2回登場するのが気持ち悪い
  • まだステートメントが2つある(Shift+F8が2回)


3.生成式を組み込み関数で包む

New MyClassをVariant型を返す関数でラップすることで、先の.Init()を書けるようになります。

Rem 3.生成式を組み込み関数で包む
Sub Sample30()
    Dim obj As MyClass
    Set obj = Array(New MyClass)(0).Init1("太郎", 10)
    Set obj = VBA.Array(New MyClass)(0).Init1("太郎", 10)
    Set obj = VBA.CVar(New MyClass).Init1("太郎", 10)
'    Set obj = (New MyClass).Init("太郎", 10)
'    Set obj = CVar(New MyClass).Init("太郎", 10)
    Debug.Print obj.ToString
End Sub


Array(~)(0)または(1)

Set obj = Array(New MyClass)(?).Init1("太郎", 10)

この書き方(Array()Option Baseによって要素番号が01を固定できないため不適切です。次のVBA.Array()を使ってください。

VBA.Array(~)(0)

Set obj = VBA.Array(New MyClass)(0).Init1("太郎", 10)  

VBA.を付与したこちらの方法は、Option Base 1の影響を受けず常にLBound(arr,1) = 0なため安心して使用可能です。

VBA.CVar(~)

Set obj = VBA.CVar(New MyClass).Init1("太郎", 10)

なお、単なるCVar()()は書けません。(コンパイルエラーになります)

この中ではVBA.CVar()が最も簡潔に書けます。


良い点

悪い点

  • 初見でVBA.CVar()なにそれ?となる。
  • Init()コンパイルチェックと入力補完が働かず、必要な引数が分からない。
  • ちょっと長い。


4.生成式を自作関数で包む

組み込み関数はVariantを返すため、入力補完が働かないという問題がありました。

Rem 4.生成式を自作関数で包む
Sub Sample40()
    Dim obj As MyClass
    Set obj = ToMyClass(New MyClass).Init1("太郎", 10)
    Debug.Print obj.ToString
End Sub

Function ToMyClass(obj) As MyClass
    Set ToMyClass = obj
End Function


キャスト関数 ToMyClass(~).Init()

Set obj = ToMyClass(New MyClass).Init1("太郎", 10)

良い点

悪い点

  • 標準モジュール側にユーティリティ関数ToMyClassの実装が必須となる。
  • ちょっと長い。


5.自作関数で生成とコンストラクタ実行を行う

Rem 5.生成とコンストラクタ実行を自作関数で行う
Sub Sample50()
    Dim obj As MyClass
    Set obj = CreateMyClass("太郎", 10)
    Debug.Print obj.ToString
End Sub

Function CreateMyClass(pName, pAge) As MyClass
    Set CreateMyClass = New MyClass
    Call CreateMyClass.Init1(pName, pAge)
End Function


Create関数を自作する

Set obj = CreateMyClass("太郎", 10)


良い点

  • 最も簡潔に書ける。

悪い点

  • わずかな速度の低下(引数の受け渡しが二段階になるため)
  • メンテコストの増加(クラス側の仕様変更に追従しなければならないため)
  • 標準モジュール側にユーティリティ関数CreateMyClassの実装が必須となる。
  • Newが見えないのが怖いと感じる。


6.CallByNameを使う

Rem 6.CallByNameを使う
Sub Sample60()
    Dim obj As MyClass
    Set obj = CallByName(New MyClass, "Init1", VbMethod, "太郎", 10)
    Debug.Print obj.ToString
End Sub


CallByName

Set obj = CallByName(New MyClass, "Init1", VbMethod, "太郎", 10)

※他にAPIrtlCallByNameもありますが割愛します。


良い点

悪い点

  • 癖の強いCallByName()を読める人は少ない。
  • "Init"コンパイルチェックと入力補完が働かず、必要な引数が分からない。
  • ちょっと長い。


7.デフォルトインスタンスを使う

デフォルトインスタンス(関数名と同じ名前で自動的にNewされるグローバルインスタンス)を使うことで、生成工程を飛ばしてクラスメンバを呼べるようになります。

ここから先の実行は、モジュールをエクスポートしてテキストエディタで開き

Attribute VB_PredeclaredId = True

と書き換えが必要です。


デフォルトインスタンス失敗例1

Rem 7.デフォルトインスタンスを使う
Sub Sample70()
    Dim obj As MyClass
    Set obj = MyClass.Init1("太郎", 10)
    Debug.Print obj.ToString
End Sub

しかし、この方法は実用に耐えられません。


何がダメなのか

インスタンスが1つしか生成されていないからです。

たとえば、次のような例では二度目以降MyClass.Init1ではインスタンスが生成されていません。

'NG
Sub Sample71()
    Dim obj As MyClass
    Set obj = MyClass.Init1("太郎", 10)
    
    Dim obj2 As MyClass
    Set obj2 = MyClass.Init1("花子", 8)
    
    Debug.Print obj.ToString
    Debug.Print obj2.ToString
End Sub

実行結果

名前:=花子 年齢:=8歳
名前:=花子 年齢:=8歳

デフォルトインスタンスを格納しているMyClassはグローバル変数です。

要するに標準モジュールの何処かにPublic MyClass As New MyClassが記載されているのと同じ状態で、初めて使用された時(正確にはNothingの時)にNew MyClassが実行される状態です。

結果としてobjobj2MyClassも、同一のインスタンスを参照しているため、このような場面では使い物になりません。


デフォルトインスタンス失敗例2

上記問題を解決するために使われるのが、Initの内部で更にNewしてしまう方法です。

Public Function Init1(pName, pAge) As MyClass
    Set Init1 = Me
    name_ = pName
    age_ = pAge
End Function

の代わりに

Rem デフォルトインスタンス用
Public Function Init2(pName, pAge) As MyClass
    Set Init2 = New MyClass
    Init2.Name = pName
    Init2.Age = pAge
End Function

Public Property Let Name(pName): name_ = pName: End Property
Public Property Let Age(pAge): age_ = pAge: End Property

を使います。


しかし、これも実用に耐えられません。


何がダメなのか

コンストラクタ内でPrivateメンバにアクセスすることが出来ないからです。

フィールドname_age_Privateで宣言していますので、Init3.name_という書き方では直接代入できません。

Public Property Let・・・のように、本来は公開したくないものまで公開状態にする羽目になります。

いっそのこと、プロパティを経由せずにフィールドをPuclicのまま突き通すのも選択肢の一つではありますが気持ち悪いですよね。


また、Init()の中では絶対にInit.~~~と書くのを忘れないでください。

もし

Rem NG
Public Function Init3(pName, pAge) As MyClass
    Set Init3 = New MyClass
    name_ = pName
    age_ = pAge
End Function

と書いてしまった場合、この時のname_age_はデフォルトインスタンスに対する操作となるため、戻り値とするInit3インスタンスには反映されません。

'NG
Sub Sample81()
    Dim obj As MyClass
    Set obj = MyClass.Init3("太郎", 10)
    Debug.Print "obj", obj.ToString
    Debug.Print "MyClass", MyClass.ToString
End Sub

を実行すると、

obj      名前:= 年齢:=歳
MyClass     名前:=太郎 年齢:=10歳

となります。

Init3を実行するためだけに準備した、デフォルトインスタンスMyClassの方にデータが格納されてしまうのです。


(以下、追記)

デフォルトインスタンス成功例

そこで、コンストラクタ宣言を次のように改修します。

Rem デフォルトインスタンス用
Public Function Init4(pName, pAge) As MyClass
    If Me Is MyClass Then
        With New MyClass
            Set Init4 = .Init4(pName, pAge)
        End With
        Exit Function
    End If
    Set Init4 = Me
    name_ = pName
    age_ = pAge
End Function

当初のInit1を改良して、最初にMeがデフォルトインスタンスかどうか判定し、必要なら新規インスタンスを生成して自身をもう一度呼び出しします。


デフォルトインスタンスのInitでNewする

使い方は今まで通りです。

Set obj = MyClass.Init4("太郎", 10)


良い点

  • 生成とコンストラクタ実行が1ステートメントにまとまっている。
  • 別途用意すべき関数がない。
  • 入力補完が働く。
  • 最も簡潔。

悪い点

  • わずかな速度の低下(引数の受け渡しが二段階になるため)
  • メンテコストの増加(コンストラクタの仕様変更に追従しなければならないため)
  • Newが見えないのが怖いと感じる。
  • デフォルトインスタンス公開により、思わぬ使い方をされる可能性がある。


8.クラス内に生成関数を追加する

クラス内で解決させるもう一つの方法が、Initとは別に生成用の関数を作ってしまうことです。

前に登場したCreateMyClassをクラス内に記載するような形になります。

Option Explicit

Private name_
Private age_

Public Function Init1(pName, pAge) As MyClass
    Set Init1 = Me
    name_ = pName
    age_ = pAge
End Function
    
Public Function CreateMyClass(pName, pAge) As MyClass
    Set CreateMyClass = New MyClass
    Call CreateMyClass.Init1(pName, pAge)
End Function

Public Function ToString() As String
    ToString = "名前:=" & name_ & " 年齢:=" & age_ & "歳"
End Function

呼び出し側はこうなります。

Rem 8.クラス内に生成関数を追加する
Sub Sample80()
    Dim obj As MyClass
    Set obj = MyClass.CreateMyClass("太郎", 10)
    Debug.Print obj.ToString
End Sub


クラス内のCreate関数を使う

Set obj = MyClass.CreateMyClass("太郎", 10)


良い点

  • 生成とコンストラクタ実行が1ステートメントにまとまっている。
  • 別途用意すべき関数がない。
  • 入力補完が働く。
  • とても簡潔。

悪い点

  • わずかな速度の低下(引数の受け渡しが二段階になるため)
  • メンテコストの増加(コンストラクタの仕様変更に追従しなければならないため)
  • Newが見えないのが怖いと感じる。
  • デフォルトインスタンス公開により、思わぬ使い方をされる可能性がある。


まとめ

これらの式を比較すると次のようになります。

問題点 文字数
0.標準
 Set obj = New MyClass
 obj.Init1 "太郎", 10
・2行になる 40
1.宣言と生成を同時に実行する
 Dim obj As New MyClass
 obj.Init1 "太郎", 10
・汎用性皆無
・2行になる
41
2.コロンを使ってマルチステートメントにする
 Set obj = New MyClass: obj.Init1 "太郎", 10
・2ステートメントになる 41
3.生成式を組み込み関数で包む
 Set obj = VBA.CVar(New MyClass).Init1("太郎", 10)
・入力補完が無い 47
4.生成式を自作関数で包む
 Set obj = ToMyClass(New MyClass).Init1("太郎", 10)
・別途関数を要する 48
5.自作関数で生成とコンストラクタ実行を行う
 Set obj = CreateMyClass("太郎", 10)
・New不可視
・別途関数を要する
33
6.CallByNameを使う
 Set obj = CallByName(New MyClass, "Init1", VbMethod, "太郎", 10)
・入力補完無し
・明らかに難しい
62
7.デフォルトインスタンスを使う
 Set obj = MyClass.Init4("太郎", 10)
・デフォルトインスタンス公開 33
8.クラス内に生成関数を追加する
 Set obj = MyClass.CreateMyClass("太郎", 10)
・デフォルトインスタンス公開 41


はじめに書いたように、いかなる状況でもベストな方法はありません。

また、本当の目的は「開発者に引数付きコンストラクタを必ず実行させる」ではないかと思います。

後半のデフォルトインスタンスを生成する方法は、便利な反面「Initされていないことを検知する仕組み」と「デフォルトインスタンスのプロシージャを呼ばれた場合にどうするか」の対策を講じなければなりません。(堅牢なコードにするならば)

そんなわけで、コードの見た目や使いやすさだけではなく、様々な視点で検討してください。

ご自身が最も適していると感じた方法で実装されるのが良いと思います。


この記事で使用したソースコード全文は、GISTにて公開しています。


以上


何か御座いましたらコメント欄、またはTwitterからどうぞ♪

それではまた来週♪ ちゅんちゅん(・8・)