はじめに
これは、Visual Basic Advent Calendar 2024 の15日目の記事です。
この記事は、今年の春先に投稿したVBAのオブジェクト変数についてまとめた同名の記事の書き直しです。前の記事を書き直す気になれず、削除して今回新たに書き直しました。前の記事にいいねしていただい方々、すみません
オブジェクト変数はショートカット
VBAのオブジェクト変数とは何でしょうか
詳しいことは以下をご参照ください。ここでは自分がオブジェクト変数の挙動確認のために試したことを書きます。
組み込み型とオブジェクト型
VBAのデータ型には大きく分けて組み込み型とオブジェクト型があります。
-
組み込み型
Boolean
、Long
、String
、Date
、Variant
、Type
など -
オブジェクト型
Collection
、Dictionary
、FileSystemObject
など
(クラスモジュールで自作したクラスも含まれる)
この二つはデータの扱われ方が違います。違いがはっきりわかるのは、これらをFunction
やSub
の引数として使うときです。
引数の渡し方、ByValとByRef
Function
やSub
(以後、関数という)への引数の渡し方としてByVal
とByRef
があります。引数が必要な関数を使う際、通常は引数に変数を渡します。
-
ByVal(By Value: 値渡し)
変数の値のコピーが作成され、関数内での引数の変更は呼び出し元の変数に影響を与えません -
ByRef(By Reference: 参照渡し)
変数への参照(メモリ上の値のアドレス)が渡され、関数内での変更が呼び出し元の変数に影響を与えます
渡し方を書かない引数は、デフォルトでByRef
になります。渡し方をByVal
にすることで引数は変数の値のコピーとなり、呼び出し元の変数が書き換わることはありません。
' 値渡し
Sub ProcHage(ByVal txt As String)
txt = "Hage"
End Sub
Sub Main()
Dim txt As String: txt = "Hoge"
Call ProcHage(txt)
Debug.Print "txt = " & txt ' <--ホゲのまま、ハゲにならない
End Sub
呼び出し元の変数を書き換えたくないとき、組み込み型の場合はこれで安心できますが、オブジェクト型だったらどうでしょうか。
オブジェクト型変数をByVal
の引数に渡した場合、単に参照先オブジェクトのアドレスを受け渡ししているだけでオブジェクト自体のコピーが生成されるわけではありません。
参照先オブジェクトを指し示すアドレスをコピーしているだけなので、関数内の書き換えは呼び出し元の変数が参照するオブジェクトを書き換えることになります。
これはファイルシステムで言うところのショートカットに似ています。例えて言うと、オブジェクト変数はファイルそのものではなく、ファイルを開くためのショートカットです。
- オブジェクト変数はファイルそのものではなく、ファイルを開くためのショートカット
-
ByVal
であっても関数内の書き換えは呼び出し元のオブジェクトを書き換えることになる
Nothingの勘違い
では、オブジェクト変数を渡された引数に関数内でNothing
を代入するとどうなるでしょうか。ByVal
とByRef
、それぞれで試してみました。
まずはByVal
(値渡し)から。
' 値渡しでオブジェクト変数を受ける
Sub ProcVal(ByVal dic As Dictionary)
dic.Add "SHIB", 0.0042
Set dic = Nothing
End Sub
' 実行する関数
Sub Test01()
On Error GoTo Catch
Dim dic1 As Dictionary: Set dic1 = New Dictionary
dic1.Add "PEPE", 0.0035
' ここで値渡し
Call ProcVal(dic1)
Debug.Print "dic1 Is Nothing = " & (dic1 Is Nothing)
Debug.Print "dic1(""PEPE"") = " & dic1("PEPE")
Debug.Print "dic1(""SHIB"") = " & dic1("SHIB")
Exit Sub
Catch:
Debug.Print Err.Description
End Sub
dic1 Is Nothing = False
dic1("PEPE") = 0.0035
dic1("SHIB") = 0.0042
関数内でdic
にNothing
を代入しても呼び出し元のdic1
はNothing
にならず、使い続けられます。
また、関数内で追加した "SHIBA"
キーが呼び出し元のdic1
に追加されていることがわかります。
次に、ByRef
(参照渡し)です。
' 参照渡しでオブジェクト変数を受ける
Sub ProcRef(ByRef dic As Dictionary)
dic.Add "SHIB", 0.0042
Set dic = Nothing
End Sub
' 実行する関数
Sub Test02()
On Error GoTo Catch
Dim dic1 As Dictionary: Set dic1 = New Dictionary
dic1.Add "PEPE", 0.0035
' ここで参照渡し
Call ProcRef(dic1)
Debug.Print "dic1 Is Nothing = " & (dic1 Is Nothing)
Debug.Print "dic1(""PEPE"") = " & dic1("PEPE") ' <--ここでエラー
Debug.Print "dic1(""SHIB"") = " & dic1("SHIB")
Exit Sub
Catch:
Debug.Print Err.Description
End Sub
dic1 Is Nothing = True
オブジェクト変数または With ブロック変数が設定されていません。
関数内でdic
にNothing
を代入すると呼び出し元のdic1
もNothing
になりました。
当然ながら"SHIBA"
キーは参照できず、ここでエラーとなります。
もう一例見てみましょう。呼び出し元のSub
にもう一つのディクショナリ変数dic2
を追記した上でdic1
を参照渡ししてみます。
引数として渡す前にdic2
にdic1
を代入しているのがポイントです。
Sub Test03()
On Error GoTo Catch
Dim dic1 As Dictionary: Set dic1 = New Dictionary
dic1.Add "PEPE", 0.0035
' 二つ目のディクショナリ
Dim dic2 As Dictionary: Set dic2 = dic1 ' <--ここがポイント
' ここで参照渡し
Call ProcRef(dic1)
Debug.Print "dic2 Is Nothing = " & (dic2 Is Nothing)
Debug.Print "dic2(""PEPE"") = " & dic2("PEPE")
Debug.Print "dic2(""SHIB"") = " & dic2("SHIB")
Debug.Print "dic1 Is Nothing = " & (dic1 Is Nothing)
Debug.Print "dic1(""PEPE"") = " & dic1("PEPE") ' <--ここでエラー
Debug.Print "dic1(""SHIB"") = " & dic1("SHIB")
Exit Sub
Catch:
Debug.Print Err.Description
End Sub
dic2 Is Nothing = False
dic2("PEPE") = 0.0035
dic2("SHIB") = 0.0042
dic1 Is Nothing = True
オブジェクト変数または With ブロック変数が設定されていません。
ByRef
の場合、通常はショートカットが消えると共に参照先オブジェクトも解放されますが、参照先オブジェクトのもう一つのショートカットである変数dic2
が呼び出し元のSub
に残っている場合、ショートカットとしての変数dic1
が消えるだけで参照先オブジェクトは残り続けます。
- オブジェクト変数はオブジェクトのショートカットであり、オブジェクトの解放スイッチでもある
- オブジェクトが同じスコープで複数の変数に参照されていたら、全てを
Nothing
しないと解放されない
この様な仕組みを参照カウンタと言うんですね
ゾンビ変数
わき道にそれますが、上述のテスト関数Test02
を1行書き換えて実行してみました。
' Dim dic1 As Dictionary: Set dic1 = New Dictionary
Dim dic1 As New Dictionary
dic1 Is Nothing = False
dic1("PEPE") =
dic1("SHIB") =
この場合、dic1
はNothing
にはなりません。キーは残ったまま値だけが消去されました。
この書き方だとNothing
を代入されたにもかかわらず、即座に新しいインスタンスを生成するようです。しかも純粋な空のオブジェクトではなく、元の変数の抜け殻のような状態で生き返ります。まるでゾンビのようですね
これはちょっと闇仕様だと思います。この書き方を結構多用していましたが、今回詳しく調べてみて考え直さなければならないかなと思いました。
ちなみにVB.NETではこの現象は起こりません。
Visual Studio 2022なら、プロジェクトでMicrosoft Scripting Runtimeを参照した上で、
Imports Scripting
Module Program
' 参照渡し
Sub ProcRef(ByRef dic As Dictionary)
dic.Add("SHIB", 0.0042)
dic = Nothing
End Sub
Sub Main(args As String())
Dim dic1 As New Dictionary
Try
Try
dic1.Add("PEPE", 0.0035)
'ここで参照渡し
ProcRef(dic1)
Console.WriteLine($"dic1 Is Nothing = {dic1 Is Nothing}")
Console.WriteLine($"dic1(""PEPE"") = {dic1("PEPE")}") ' <-ここでエラー
Catch ex As Exception
Console.WriteLine($"dic1(""PEPE""): {ex.Message}")
End Try
' 自動的にNewされることを期待...
dic1.Add("SHIB", 0.0042) ' <-ここでエラー
Catch ex As Exception
Console.WriteLine($"dic1.Add(""SHIB"", 0.0042): {ex.Message}")
End Try
' Newする必要がある
dic1 = New Dictionary
dic1.Add("DOGE", 60)
Console.WriteLine($"dic1(""DOGE"") = {dic1("DOGE")}")
End Sub
End Module
dic1 Is Nothing = True
dic1("PEPE") : Object reference not set to an instance of an object.
dic1.Add("SHIB", 0.0042) : Object reference not set to an instance of an object.
dic1("DOGE") = 60
As New
による自動的なインスタンス生成がされません。新たなインスタンスが必要なときはNew
する必要があります。
オブジェクトの解放はいつか?
わき道にそれましが、気を取り直して以下、最後のテストです。
これはオブジェクトが解放されるタイミングを確認する実験です。クラスモジュールでClass1
を作り、解放時に呼ばれるTerminate
イベントプロシージャがいつ動作するかを確認します。
Private mName As String
Public Property Let Name(ByRef vl As String)
mName = vl
End Property
Public Property Get Name() As String
Name = mName
End Property
' クラス解放時に呼ばれる
Private Sub Class_Terminate()
Debug.Print mName & "インスタンス解放"
End Sub
また、Class1
を生成するCreateClass1
関数を作り、その中でNothing
を使ってみました。
Private Function CreateClass1() As Class1
Dim newInst As Class1
Set newInst = New Class1
newInst.Name = "new"
' 生成したClass1のインスタンスを返す
Set CreateClass1 = newInst
' Nothingしてみる
Set newInst = Nothing
Debug.Print "CreateClass1関数終了"
End Function
以下のHoge
を実行すると、イミディエイトウインドに関数終了時とクラス解放時にテキストメッセージが出力されます。
' グローバル変数
Public zInst As Class1
Public Sub Hoge()
Dim xInst As Class1: Set xInst = New Class1
xInst.Name = "x"
Dim yInst As Class1: Set yInst = New Class1
yInst.Name = "y"
' グローバル変数への代入
Set zInst = New Class1
zInst.Name = "z"
' CreateClass1関数でClass1を生成
Dim newInst As Class1: Set newInst = CreateClass1
Debug.Print "newInstのName: " & newInst.Name
' xだけNothingしてみる
Set xInst = Nothing
Debug.Print "Hoge関数終了"
End Sub
出力結果は以下のとおりです。
CreateClass1関数終了
newInstのName: new
xインスタンス解放
Hoge関数終了
newインスタンス解放
yインスタンス解放
-
xInst
は、Nothing
を代入した直後にTerminate
が呼ばれます -
yInst
は、Nothing
を代入していませんが、Hoge
終了後にTerminate
が呼ばれます -
zInst
は、Terminate
が呼ばれず、オブジェクトは解放されません -
CreateClass1
関数内のnewInst
にNothing
を代入しても、外側のnewInst
に影響はありません -
Hoge
関数内のnewInst
は、Nothing
を代入していませんが、yInst
と同様にHoge
終了後にTerminate
が呼ばれます
オブジェクト変数がグローバル変数の場合、解放にはNothing
の代入が必須となります。Hoge
関数を実行した後でzInst
のName
プロパティをイミディエイトウインドウで確認してみましょう。
?zInst.Name
z
Name
プロパティに値が残っていることがわかります。
また、Nothing
しなくても、変数が宣言されたプロシージャのスコープから抜けると自動的に解放されることがわかりました。
例外として、一部のオブジェクトにはClose
メソッドなどがあり、プログラマの責任でClose
して解放しなければならない場合もあります。
おわりに
以上、カオスなVBAの世界のほんの一部をまとめてみました。
ChatGPTでプログラムや記事を簡単に書ける昨今ですが、自分の書き方や自分の言葉でまとめたほうが納得いくということを今回つくづく感じました