VBAのクラスをNewと同時に引数付きコンストラクタを起動する方法を、思いつく限りの方法をまとめておく。
- VBAのコンストラクタ
- コンストラクタに引数を書くための代替案
- 1.宣言と生成を同時に実行する
- 2.コロンを使ってマルチステートメントにする
- 3.生成式を組み込み関数で包む
- 4.生成式を自作関数で包む
- 5.自作関数で生成とコンストラクタ実行を行う
- 6.CallByNameを使う
- 7.デフォルトインスタンスを使う
- 8.クラス内に生成関数を追加する
- まとめ
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
によって要素番号が0
か1
を固定できないため不適切です。次の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)
※他にAPIのrtlCallByName
もありますが割愛します。
良い点
悪い点
- 癖の強い
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
が実行される状態です。
結果としてobj
もobj2
もMyClass
も、同一のインスタンスを参照しているため、このような場面では使い物になりません。
デフォルトインスタンス失敗例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)
良い点
悪い点
- わずかな速度の低下(引数の受け渡しが二段階になるため)
- メンテコストの増加(コンストラクタの仕様変更に追従しなければならないため)
- 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)
良い点
悪い点
- わずかな速度の低下(引数の受け渡しが二段階になるため)
- メンテコストの増加(コンストラクタの仕様変更に追従しなければならないため)
- 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・)