数か月前に VBA のバグを発見しましたが、適切な回避策を見つけることができませんでした。このバグは、優れた言語機能を制限するようなものなので、本当に厄介です。
For Each
カスタム コレクション クラスを使用する場合、クラスをループで使用できるように列挙子が必要になることはよくあります。これは、次の行を追加することで実行できます。
Attribute [MethodName].VB_UserMemId = -4 'The reserved DISPID_NEWENUM
次のいずれかの方法で、関数/プロパティ シグネチャ行の直後に挿入します。
- クラス モジュールをエクスポートし、テキスト エディタで内容を編集してからインポートし直す
- 関数シグネチャの上にラバーダックアノテーションを使用してから同期する
'@Enumerator
残念ながら、x64 では、上記の機能を使用すると、間違ったメモリが書き込まれ、場合によってはアプリケーションがクラッシュします (後述)。
バグの再現
CustomCollection
クラス:
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "CustomCollection"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit
Private m_coll As Collection
Private Sub Class_Initialize()
Set m_coll = New Collection
End Sub
Private Sub Class_Terminate()
Set m_coll = Nothing
End Sub
Public Sub Add(v As Variant)
m_coll.Add v
End Sub
Public Function NewEnum() As IEnumVARIANT
Attribute NewEnum.VB_UserMemId = -4
Set NewEnum = m_coll.[_NewEnum]
End Function
標準モジュールのコード:
Option Explicit
Sub Main()
#If Win64 Then
Dim c As New CustomCollection
c.Add 1
c.Add 2
ShowBug c
#Else
MsgBox "This bug does not occur on 32 bits!", vbInformation, "Cancelled"
#End If
End Sub
Sub ShowBug(c As CustomCollection)
Dim ptr0 As LongPtr
Dim ptr1 As LongPtr
Dim ptr2 As LongPtr
Dim ptr3 As LongPtr
Dim ptr4 As LongPtr
Dim ptr5 As LongPtr
Dim ptr6 As LongPtr
Dim ptr7 As LongPtr
Dim ptr8 As LongPtr
Dim ptr9 As LongPtr
'
Dim v As Variant
'
For Each v In c
Next v
Debug.Assert ptr0 = 0
End Sub
メソッドを実行するMain
と、コードはメソッドのAssert
行で停止し、[ローカル]ウィンドウで、ローカル変数の値がどこからともなく変更されたShowBug
ことを確認できます。ここで、 ptr1
は に等しいです。メソッド内でより多くの変数が使用される(オプションのパラメーターを含む) ほど、値 (メモリ アドレス) が書き込まれるメソッド内のより多くの ptr が取得されます。
ObjPtr(c)
NewEnum
ShowBug
言うまでもなく、メソッド内のローカルptr変数を削除するShowBug
と、アプリケーションのクラッシュが確実に発生します。
コードを 1 行ずつステップ実行すると、このバグは発生しません。
バグの詳細
Collection
このバグは、内に格納されている実際のものとは関係ありませんCustomCollection
。NewEnum 関数が呼び出された直後にメモリが書き込まれます。したがって、基本的に次のいずれかを実行しても効果はありません (テスト済み):
Optional
パラメータの追加- 関数内からすべてのコードを削除します(これを示す以下のコードを参照)
IUnknown
の代わりとして宣言するIEnumVariant
Function
として宣言する代わりにProperty Get
- メソッド シグネチャで
Friend
orのようなキーワードを使用するStatic
- DISPID_NEWENUM をGetのLetまたはSetに対応するものに追加するか、前者を非表示にします (つまり、Let/Set をプライベートにします)。
上記のステップ 2 を試してみましょう。次の場合CustomCollection
:
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "CustomCollection"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit
Public Function NewEnum() As IEnumVARIANT
Attribute NewEnum.VB_UserMemId = -4
End Function
テストに使用されるコードは次のように変更されます。
Sub Main()
#If Win64 Then
Dim c As New CustomCollection
ShowBug c
#Else
MsgBox "This bug does not occur on 32 bits!", vbInformation, "Cancelled"
#End If
End Sub
Sub ShowBug(c As CustomCollection)
Dim ptr0 As LongPtr
Dim ptr1 As LongPtr
Dim ptr2 As LongPtr
Dim ptr3 As LongPtr
Dim ptr4 As LongPtr
Dim ptr5 As LongPtr
Dim ptr6 As LongPtr
Dim ptr7 As LongPtr
Dim ptr8 As LongPtr
Dim ptr9 As LongPtr
'
Dim v As Variant
'
On Error Resume Next
For Each v In c
Next v
On Error GoTo 0
Debug.Assert ptr0 = 0
End Sub
実行Main
すると同じバグが発生します。
回避策
バグを回避するために私が見つけた信頼できる方法:
メソッドを呼び出して (基本的には
ShowBug
メソッドから離れて)、戻ってきます。これは、For Each
行が実行される前に発生する必要があります (同じメソッド内のどこにでもある可能性があることを意味する前に、必ずしも正確な行の前にあるとは限りません)。Sin 0 'Or VBA.Int 1 - you get the idea For Each v In c Next v
短所:忘れやすい
Set
ステートメントを行います。ループで使用されるバリアントにある可能性があります (他のオブジェクトが使用されていない場合)。上記のポイント 1 のように、これはFor Each
行が実行される前に発生する必要があります。Set v = Nothing For Each v In c Next v
Set c = c
Or を使用してコレクションをそれ自体に設定し、 cパラメーターByVal
をShowBug
メソッドに渡すことによっても (Set として、IUnknown::AddRef を呼び出します)
短所: 忘れやすいEnumHelper
列挙に使用された唯一のクラスである別のクラスを使用します。VERSION 1.0 CLASS BEGIN MultiUse = -1 'True END Attribute VB_Name = "EnumHelper" Attribute VB_GlobalNameSpace = False Attribute VB_Creatable = False Attribute VB_PredeclaredId = False Attribute VB_Exposed = False Option Explicit Private m_enum As IEnumVARIANT Public Property Set EnumVariant(newEnum_ As IEnumVARIANT) Set m_enum = newEnum_ End Property Public Property Get EnumVariant() As IEnumVARIANT Attribute EnumVariant.VB_UserMemId = -4 Set EnumVariant = m_enum End Property
CustomCollection
次のようになります。VERSION 1.0 CLASS BEGIN MultiUse = -1 'True END Attribute VB_Name = "CustomCollection" Attribute VB_GlobalNameSpace = False Attribute VB_Creatable = False Attribute VB_PredeclaredId = False Attribute VB_Exposed = False Option Explicit Private m_coll As Collection Private Sub Class_Initialize() Set m_coll = New Collection End Sub Private Sub Class_Terminate() Set m_coll = Nothing End Sub Public Sub Add(v As Variant) m_coll.Add v End Sub Public Function NewEnum() As EnumHelper Dim eHelper As New EnumHelper ' Set eHelper.EnumVariant = m_coll.[_NewEnum] Set NewEnum = eHelper End Function
および呼び出しコード:
Option Explicit Sub Main() #If Win64 Then Dim c As New CustomCollection c.Add 1 c.Add 2 ShowBug c #Else MsgBox "This bug does not occur on 32 bits!", vbInformation, "Cancelled" #End If End Sub Sub ShowBug(c As CustomCollection) Dim ptr0 As LongPtr Dim ptr1 As LongPtr Dim ptr2 As LongPtr Dim ptr3 As LongPtr Dim ptr4 As LongPtr Dim ptr5 As LongPtr Dim ptr6 As LongPtr Dim ptr7 As LongPtr Dim ptr8 As LongPtr Dim ptr9 As LongPtr ' Dim v As Variant ' For Each v In c.NewEnum Debug.Print v Next v Debug.Assert ptr0 = 0 End Sub
明らかに、予約済みの DISPID は
CustomCollection
クラスから削除されました。長所:カスタムコレクションを直接ではなく
For Each
、関数に強制します。.NewEnum
これにより、バグによるクラッシュが回避されます。短所:常に余分な
EnumHelper
クラスが必要です。.NewEnum
行に を追加するのを忘れがちFor Each
です (実行時エラーが発生するだけです)。
最後のアプローチ (3) が機能するのは、c.NewEnum
が実行されるとメソッドが終了し、クラス内のShowBug
呼び出しの前に返されるためです。基本的には(1)がバグを回避する方法です。Property Get EnumVariant
EnumHelper
この動作の説明は何ですか? このバグをよりエレガントな方法で回避できますか?
編集
ByVal を渡すことCustomCollection
は常にオプションではありません。を検討してくださいClass1
:
Option Explicit
Private m_collection As CustomCollection
Private Sub Class_Initialize()
Set m_collection = New CustomCollection
End Sub
Private Sub Class_Terminate()
Set m_collection = Nothing
End Sub
Public Sub AddElem(d As Double)
m_collection.Add d
End Sub
Public Function SumElements() As Double
Dim v As Variant
Dim s As Double
For Each v In m_collection
s = s + v
Next v
SumElements = s
End Function
そして今、呼び出しルーチン:
Sub ForceBug()
Dim c As Class1
Set c = New Class1
c.AddElem 2
c.AddElem 5
c.AddElem 7
Debug.Print c.SumElements 'BOOM - Application crashes
End Sub
明らかに、この例は少し強引ですが、「子」オブジェクトのカスタム コレクションを含む「親」オブジェクトを持ち、「親」が「子」の一部またはすべてを含む何らかの操作を実行することは非常に一般的です。
この場合、行Set
の前にステートメントまたはメソッド呼び出しを行うことを忘れがちです。For Each