今回はユーザーフォームをオブジェクトとして扱うメリットについて、具体例を用いて紹介します。
まず、「ユーザーフォームをオブジェクトとして扱う」というのは、ユーザーフォームを変数に格納して扱うことを指します。これを本記事ではユーザーフォームを「オブジェクト化する」と表現することにします。
小規模なシステムや個人しか使わない場合など、オブジェクト化の必要を感じないケースはありますが、ユーザーフォームと標準モジュール間で値を渡したい場合にどうしてもグローバル(Public)変数を使うことになってくるので、バグの温床となりがちです。
ここで紹介する方法を使うことで、可読性が向上し保守もしやすくなるため、ある程度実用的な機能を持たせたい場合には、ぜひ活用してみてほしいと思います。
①今回の題材
下記のようなユーザーフォームを作ります。コンボボックス2個、ラベル1個、ボタン1個を使ったシンプルな構成にしています。

実現する機能は下記のとおりです。
- カテゴリーを選択すると項目のリストが更新される
- 選択された項目が下のラベルに表示される
- 決定ボタンをクリックするかフォームを閉じるとラベルの値を返す
上記のカテゴリーと項目の組み合わせを複数用意し、連続して項目を選択させて、その結果を結合して、最後にメッセージボックスに表示するようにします。
複数の組み合わせですが、多いと見づらいので今回は下記の2パターンのみにします。

例えば、1回目のフォームで表1を扱い、野菜カテゴリーを選び、項目としてピーマンを選んで決定し、2回目のフォームで表2を扱い、文房具カテゴリーを選び、項目としてノートを選んで決定すると、結果として「ピーマン and ノート」のようにメッセージボックスを表示するという流れです。
これを普通に実装しようとすると下記の2パターンが主な方法になるかと思います。
- 同じ形のユーザーフォームを2つ用意して、それぞれのユーザーフォーム内で各表を取得して、選択結果はグローバル変数に格納
- 単一のユーザーフォームにグローバル変数で各表の範囲を渡し、選択結果もグローバル変数に格納
Aパターンは表が2つの場合にはそこまで大きな問題はないように見えますが、3つ以上になると保守が大変になりそうです。
Bパターンはコードも短く実装は楽ですが、グローバル変数を複数用意することになるのと、どこでどのように使われるかがわかりにくくなり、可読性が低下します。
次章ではまず、上記のBパターンで一般的と思われる実装を見てみます。
②一般的な(と筆者が思う)実装
まずは、ユーザーフォームの実装から見ていきましょう。
②-1 ユーザーフォーム側~項目コンボボックス~
カテゴリー選択が変更される度に、項目コンボボックスの値を更新する処理です。(パッと見で理解できる方は読み飛ばしてください)
graphRngは標準モジュール側でグローバル変数として定義され、表のセル範囲(Range)オブジェクトを格納します。ユーザーフォーム側から見るとブラックボックスとなります。
表1を例にすると野菜カテゴリーは2列目なので、ListIndexとしては1(果物が0)となります。この列を固定して行数分ループさせてコンボボックスにAddItemしているという処理です。
|
1 2 3 4 5 6 7 8 9 10 11 |
'*********** 項目コンボボックスの値を更新 *********** Private Sub setContentComboBox() Me.cmbContent.Clear Dim rowCount As Long For rowCount = 2 To graphRng.Rows.Count Me.cmbContent.AddItem graphRng(rowCount, Me.cmbCategory.ListIndex + 1) Next rowCount Me.cmbContent.ListIndex = 0 End Sub |
②-2 ユーザーフォーム側~固有イベント~
下記はユーザーフォーム固有のClickイベントやChangeイベントなどの処理です。今回はユーザーフォームが閉じられるか決定ボタンをクリックすることで値を確定する仕様なのでQueryCloseイベントを使っています。
確定した値は、標準モジュール側でグローバル変数として定義しているoutputMsgに追記されます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
'*************** 決定ボタンクリック ***************** Private Sub btClose_Click() outputMsg = outputMsg & Me.lbSelect.Caption Unload Me End Sub '*************** カテゴリー切り替え ***************** Private Sub cmbCategory_Change() Call setContentComboBox End Sub '******************** 選択実行 ********************** Private Sub cmbContent_Change() '項目コンボボックスで選択した値をラベルに反映 Me.lbSelect.Caption = Me.cmbContent.Value End Sub '************ 決定ボタン以外で閉じた場合 ************ Private Sub UserForm_QueryClose(Cancel As Integer, CloseMode As Integer) If CloseMode = vbFormControlMenu Then outputMsg = outputMsg & Me.lbSelect.Caption End If End Sub |
②-3 ユーザーフォーム側~コンボボックス初期化~
下記はユーザーフォームを表示する直前に実行される固有の初期化処理です。2つのコンボボックスのアイテムを設定するのみです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
'********************** 初期化 ********************** Private Sub UserForm_Initialize() Me.cmbCategory.Clear 'カテゴリーコンボボックスに値を追加 Dim columnCount As Long For columnCount = 1 To graphRng.Columns.Count Me.cmbCategory.AddItem graphRng(1, columnCount) Next columnCount Me.cmbCategory.ListIndex = 0 Call setContentComboBox End Sub |
②-4 標準モジュール側
次に標準モジュール側の実装を見てみましょう。ここが重要なポイントになります。
グローバル変数が2つ定義されています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
'************* グローバル変数を定義 **************** Public graphRng As Range Public outputMsg As String '******************* 定数を定義 ******************** Const GRAPH1_RNG As String = "$C$3:$E$7" '表1範囲 Const GRAPH2_RNG As String = "$C$10:$E$14" '表2範囲 '************ Mainプロシージャ ************** Sub mainProcess() '出力値をクリア outputMsg = "" '表1で選択した内容を格納 Call formProcess(Sheet1.Range(GRAPH1_RNG)) outputMsg = outputMsg & " and " '表2で選択した内容を格納 Call formProcess(Sheet1.Range(GRAPH2_RNG)) '表1と表2の選択結果を出力 MsgBox outputMsg End Sub '******* SampleFormで選択した結果を取得 ****** Private Sub formProcess(rng As Range) 'graphRngに表の範囲を格納 Set graphRng = rng SampleForm.Show End Sub |
処理内容はとてもシンプルなのですが、表1と表2の違いはformProcessプロシージャの引数におけるRangeプロパティに与える文字列(GRAPH1か2か)のみとなるため、コメントが無いと何をしているのか判別が難しくなります。
また、ここだけ見るとoutputMsgはandという文字列を追記しただけで出力しているように見えるので、標準モジュールからだとoutputMsgがどこで更新されるか予測が難しそうです。graphRngについても同様で、これだけではどこで使われている変数なのか読み取れません。
とは言え、この規模なら少し検索をかければ解読は難しくないので、まだ弊害は少ないように感じます。
ただし、規模が大きくなってくるといかがでしょうか?
ここでは少し機能を追加してみることにしましょう。
②-5 機能追加した場合
さらに値段を入力させ、それぞれの価格と合計金額もメッセージボックスで出力することにしてみます。(値段入力部分の実装の説明は冗長なので省略)
まず、グローバル変数が増えます。さらにユーザーフォームのブラックボックス感が増した感じになりました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
'************* グローバル変数を定義 **************** Public graphRng As Range Public outputMsg As String Public outputPrice As Long '************ モジュール内変数を定義 *************** Dim totalPrice As Long '******************* 定数を定義 ******************** Const GRAPH1_RNG As String = "$C$3:$E$7" '表1範囲 Const GRAPH2_RNG As String = "$C$10:$E$14" '表2範囲 '************ Mainプロシージャ ************** Sub mainProcess() '出力値をクリア totalPrice = 0 outputMsg = "" '表1で選択した内容を格納 Call formProcess(Sheet1.Range(GRAPH1_RNG)) '表2で選択した内容を格納 Call formProcess(Sheet1.Range(GRAPH2_RNG)) '出力メッセージの末尾に合計金額を追記 outputMsg = outputMsg & "合計: ¥" & Str(totalPrice) '表1と表2の選択結果を出力 MsgBox outputMsg End Sub '******* SampleFormで選択した結果を取得 ****** Private Sub formProcess(rng As Range) 'graphRngに表の範囲を格納 Set graphRng = rng SampleForm.Show '出力メッセージに追記 outputMsg = outputMsg & ": ¥" & outputPrice & vbCrLf '合計金額を計算 totalPrice = totalPrice + outputPrice End Sub |

このまま規模が大きくなるとグローバル変数も増え続け、どこで値が更新されるのか覚えておくのも困難になっていきます。これに設計者以外の人が機能追加する必要が出てきたときには、もう全部作り直した方が早い、なんてことにもなり兼ねません。
そこで、こんな事態に陥らないために、次はユーザーフォームをオブジェクト化した場合について見ていきます。
③ユーザーフォームをオブジェクト化する
③-1 標準モジュール側
今度は標準モジュール側の実装から見ていきましょう。
さっそく先頭のグローバル変数がなくなっています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
'*************** 定数を定義 ***************** Const GRAPH1_RNG As String = "$C$3:$E$7" '表1範囲 Const GRAPH2_RNG As String = "$C$10:$E$14" '表2範囲 '************ Mainプロシージャ ************** Sub mainProcess() '変数の初期化 Dim outputMsg As string: outputMsg = "" '表1で選択した内容を格納 Dim fm1 As SampleForm Call formProcess(fm1, Sheet1.Range(GRAPH1_RNG)) outputMsg = fm1.returnMsg & " and " Unload fm1: Set fm1 = Nothing '表2で選択した内容を格納 Dim fm2 As SampleForm Call formProcess(fm2, Sheet1.Range(GRAPH2_RNG)) outputMsg = outputMsg & fm2.returnMsg Unload fm2: Set fm2 = Nothing '表1と表2の選択結果を出力 MsgBox outputMsg End Sub '******* SampleFormで選択した結果を取得 ****** Private Sub formProcess(fm As SampleForm, rng As Range) 'SampleFormオブジェクトを生成 Set fm = New SampleForm 'フォームのgrapthRngプロパティに表の範囲を格納 fm.graphRng = rng 'フォームの初期化 Call fm.initSampleForm 'フォームの表示 fm.Show End Sub |
まず、mainProcessですが、コメントがなかったとしても、fm1を使っているのは表1でfm2が表2だということが読み取れると思います。また、outputMsgにもfmのreturn値が追記されているのがプロパティ名から読み取れるようになっています。
加えて、mainProcessは出力値を操作する役割で、formProcessでユーザーフォームを操作していることも明確になったと思います。
graphRngも②ではグローバル変数だったため、どこで使われているかわかりづらかったと思いますが、ここではfmオブジェクトのプロパティということが明示されているのでユーザーフォーム内で使うということがわかります。
次に初期化した後ユーザーフォームを表示します。ここでユーザー入力を受け付け、決定ボタンをクリックしてフォームを閉じてもらうのですが1点注意が必要です。この時点でUnloadしてしまうとユーザーフォームオブジェクトが解放され、格納した値が取得できなくなってしまいます。これを回避するために、この時点では閉じずにHideメソッドで隠しておきます。値を取り出した後は忘れずに明示的にUnloadしオブジェクト解放しておきましょう。
このようにコード自体は少し長くなりますが、可読性は高まります。
また、次章のようにPropertyとして管理することで、自動メンバー表示機能が有効となるため、コーディングしやすくなります。
※ドット(.)を入力すると出てくる下の画像のようなやつです

③-2 ユーザーフォーム~Propertyの取得と設定~
ここからはユーザーフォーム側の実装を見ていきます。
これ、実は私も最近まで知らなかったのですが、ユーザーフォーム内でもクラスモジュールのようにPropertyの取得と設定ができるんです。今回、returnMsgとgraphRngはこれを使っています。
今回の場合は、2つとも一度しか値を受け取らないので、初回のみ値の更新を受け付けるようにしています。(詳しい説明は割愛します)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
'**************** 読み取り専用変数 ***************** Private returnMsg_ As String Private graphRng_ As Range '***************** returnMsgを取得 ***************** Public Property Get returnMsg() As String returnMsg = returnMsg_ End Property '********** returnMsgに値を設定(初回のみ) ********** Public Property Let returnMsg(val As String) If returnMsg_ <> "" Then Debug.Print "returnMsgは上書きできません" Else returnMsg_ = val End If End Property '***************** graphRngを取得 ****************** Public Property Get graphRng() As Range Set graphRng = graphRng_ End Property '************ graphRngを設定(初回のみ) ************* Public Property Let graphRng(rng As Range) If Not (graphRng_ Is Nothing) Then Debug.Print "graphRngは上書きできません" Else Set graphRng_ = rng End If End Property |
③-3 ユーザーフォーム~コンボボックス初期化と更新~
次にユーザーフォームの初期化と更新部分です。setContentComboBoxは②と同じで、initSampleFormはUserForm_Initializeの代わりですね。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
'******** 標準モジュールから呼ばれる初期化 ********* Sub initSampleForm() Me.cmbCategory.Clear 'カテゴリーコンボボックスに値を追加 Dim columnCount As Long For columnCount = 1 To Me.graphRng.Columns.Count Me.cmbCategory.AddItem Me.graphRng(1, columnCount) Next columnCount Me.cmbCategory.ListIndex = 0 Call setContentComboBox End Sub '*********** 項目コンボボックスの値を更新 *********** Private Sub setContentComboBox() Me.cmbContent.Clear Dim rowCount As Long For rowCount = 2 To Me.graphRng.Rows.Count Me.cmbContent.AddItem Me.graphRng(rowCount, Me.cmbCategory.ListIndex + 1) Next rowCount Me.cmbContent.ListIndex = 0 End Sub |
③-4 ユーザーフォーム~固有イベント~
最後にユーザーフォーム固有イベントの処理です。②からの変化点は下記3点です。
- UnloadではなくHideメソッドを使用
- QueryCloseで閉じらないようにCancelを1に設定
- グローバル変数outputMsgの代わりにreturnMsgプロパティを使用
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
'*************** 決定ボタンクリック ***************** Private Sub btClose_Click() 'returnMsgに値をセットしユーザーフォームを隠す Me.returnMsg = Me.lbSelect.Caption Me.Hide End Sub '*************** カテゴリー切り替え ***************** Private Sub cmbCategory_Change() Call setContentComboBox End Sub '******************** 選択実行 ********************** Private Sub cmbContent_Change() '項目コンボボックスで選択した値をラベルに反映 Me.lbSelect.Caption = Me.cmbContent.Value End Sub '************ 決定ボタン以外で閉じた場合 ************ Private Sub UserForm_QueryClose(Cancel As Integer, CloseMode As Integer) If CloseMode = vbFormControlMenu Then 'ここではユーザーフォームを閉じない Cancel = 1 'returnMsgに値をセットしユーザーフォームを隠す Me.returnMsg = Me.lbSelect.Caption Me.Hide End If End Sub |
③-5 機能追加した場合
②と同様の機能追加してみます。
増えた変数を管理するために構造体(Type)を追加しています。
ユーザーフォームから値を取得する部分と出力結果をまとめる部分でプロシージャを分けることでmainProcessの役割がわかりやすくなるようにしています。
細かい処理内容の説明はこれまでと大きく変わらないので割愛します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
Option Explicit '************** 構造体を定義 **************** Private Type outputType msg As String totalPrice As Long End Type '*************** 定数を定義 ***************** Const GRAPH1_RNG As String = "$C$3:$E$7" '表1範囲 Const GRAPH2_RNG As String = "$C$10:$E$14" '表2範囲 '************ Mainプロシージャ ************** Sub mainProcess() '変数の初期化 Dim out As outputType out.msg = "" out.totalPrice = 0 '表1のフォームを表示し選択した内容を格納 Dim fm1 As SampleForm Call formProcess(fm1, Sheet1.Range(GRAPH1_RNG)) '表1で選択した結果を成形してフォーム解放 Call resultProcess(fm1, out) Unload fm1: Set fm1 = Nothing '表2のフォームを表示し選択した内容を格納 Dim fm2 As SampleForm Call formProcess(fm2, Sheet1.Range(GRAPH2_RNG)) '表2で選択した結果を成形してフォーム解放 Call resultProcess(fm2, out) Unload fm2: Set fm2 = Nothing '合計金額を追記 out.msg = out.msg & "合計: ¥" & Str(out.totalPrice) '表1と表2の選択結果を出力 MsgBox out.msg End Sub '******* SampleFormで選択した結果を取得 ****** Private Sub formProcess(fm As SampleForm, rng As Range) 'SampleFormオブジェクトを生成 Set fm = New SampleForm 'フォームのgrapthRngプロパティに表の範囲を格納 fm.graphRng = rng 'フォームの初期化 Call fm.initSampleForm 'フォームの表示 fm.Show End Sub '******* SampleFormで選択した結果を取得 ****** Private Sub resultProcess(fm As SampleForm, out As outputType) '金額を加算 out.totalPrice = out.totalPrice + fm.returnPrice '出力メッセージを追記 out.msg = out.msg & fm.returnMsg & ": ¥" & fm.returnPrice & vbCrLf End Sub |
このように、オブジェクト(変数)として扱うことで、表1と表2どちらを扱っているかを明示しやすくなり、ユーザーフォームと標準モジュール間での値の受け渡しがどこで実施されているか読み取りやすくなりました。
多少下準備が必要なため、少しコードが長くなってしまうことと、慣れていないとコーディングが難しく感じる部分もあるかもしれませんが、総合的に見ると③の方がメリットが大きいと思いますので、ぜひご活用いただけたらと思います。
④おわりに
最後に今回のまとめです。
- 小規模なシステムの場合は、しなくても特にユーザーフォームのオブジェクト化をしなくても不都合はなく、コードも短くなる
- ユーザーフォームをオブジェクト化することで、何をしたいのか、何を受け渡ししているのかを明確にできるため可読性が向上する
- ユーザーフォームをオブジェクト化することで、グローバル変数を削減できるため、バグの少ない信頼性の高いコードを書くことにつながる。
以上、長くなりましたが、最後まで読んでいただき、ありがとうございました。


コメント