t-hom’s diary

主にVBAネタを扱っているブログ…とも言えなくなってきたこの頃。

VBA オブジェクト変数の宣言時にNewすると何がまずいのか

オブジェクト変数の宣言と使用については、以下の2パターンが存在する。

■パターン1 宣言と同時にNewしてしまう方法
Dim C As New Collection

■パターン2 宣言とオブジェクトのSetを分ける方法
Dim C As Collection
Set C = New Collection

私はこれまで大体のケースでパターン1を用いてきた。そのほうがコードが短くまとまってスッキリするからだ。

たかが1行とあなどるなかれ。1つのプロシージャのサイズはスクロールせずに全体が見渡せるくらいの長さが理想的であるが、特にノートPCのように画面が狭い環境では1行といえど貴重なスペースである。

ところがちょうど今日、VBAのお膝元であるMicrosoftのサイトでVB6.0においては変数宣言時にNewするのは避けるべきであるとの記述を発見してしまった。

https://msdn.microsoft.com/ja-jp/library/dd297716.aspx

VBAも実質はVB6.0なのでもちろんこれに該当する。

短く書けなくなるのは残念であるが、MSの公式見解である以上は方針転換を検討せざるを得ない。

そもそも変数宣言でNewした場合の動作について

前述のページには「Visual Basic 6.0 では、このコードのある行ではオブジェクトは生成されずに、それを利用するタイミングに、オブジェクトがすでに作られているかどうかをチェックし、なければ作成されるというものでした。これによる利点と欠点は次の通りです。」とある。

まぁここまでは知っていた。
驚いたのはここからで、「必ずオブジェクトが存在することが保証される (Nothing を設定してオブジェクトが破棄されたとしても、オブジェクト変数を再利用しようとすると、再作成される)」という記述である。

実際にやってみた。

Sub あれれー()
    Dim C As New Collection
    Set C = Nothing
    C.Add "あれ?なんでNothing入れたのにAddできるん?"
    MsgBox C.Item(1)
End Sub

マジだ。

オブジェクトを明示的にNothingで破棄しているにもかかわらず、C.Addができてしまう。

ちなみに、念のため宣言とNewを分けて書いてみたところ、エラーになる。これは予想どおり。

Sub こっちはエラーになる()
    Dim C As Collection
    Set C = New Collection
    Set C = Nothing
    C.Add "このAddは無理"
    MsgBox C.Item(1)
End Sub

さて、この実験により変数宣言時にNewするとオブジェクト変数にNothingを設定しても、そのオブジェクト変数を再利用しようとすると自動でオブジェクトが生成されてしまうことが判明した。

ところで、オブジェクトの利用とはプロパティの設定やメソッドの実行だけを指しているのではない。単にオブジェクト変数を参照するだけで利用したことになってしまうのだ。

以下のコードはオブジェクト変数にNothingを代入しているにも関わらず、Nothingと比較するとFalseになってしまうというもの。

Sub Nothingになってくれない()
    Dim C As New Collection
    
    Set C = Nothing
    Debug.Print C Is Nothing
    
    Set C = Nothing
    Debug.Print C Is Nothing
End Sub

Set C = Nothingの時点ではちゃんとNothingが入っているけど、いざ比較しようとしたらやはりオブジェクト変数の中身を参照する必要があるので、その時点で中身が作られてしまいNothingではなくなってしまうのだ。

つまりCはNothingになるけれど、観測した瞬間Nothingではなくなってしまう。なんだか量子力学にでてくるシュレーディンガーの猫の話と似てる。まぁ、VBAの場合はローカルウインドウを使えばちゃんとNothingになっている瞬間を観測できるけどね。

オーバーヘッドについて

変数宣言するときにNewした場合、オブジェクト変数が参照されるたびにNothingかどうかをチェックするのでオーバーヘッドが発生する。
MSDNには変数宣言時にNewする方法について、「オーバーヘッドが大きいので、使うべきではありません。」と書かれているが、はたしてどれくらい違うものなんだろうか。

実際にやってみた。

Sub 変数宣言とNewを分ける()
    t = Timer
    Dim C As Collection
    Set C = New Collection
    For i = 1 To 10000000
        C.Add i
    Next
    Debug.Print Timer - t
End Sub

Sub 変数宣言と同時にNew()
    t = Timer
    Dim C As New Collection
    For i = 1 To 10000000
        C.Add i
    Next
    Debug.Print Timer - t
End Sub

結果は、大して変わらない。
いちおう変数宣言とNewを分けた書き方の方が早かった。
とはいえ、1000万回ループさせてやっと有意差が0.3~0.4秒なのでオーバーヘッドによる遅延は微々たるものだ。

変数宣言時にNewするとコンストラクターの発動タイミングが変わる。

実はDim C As New Collectionが実行されたタイミングではまだCにはCollectionが入っていない。
実際にCollectionが変数に格納されるのは、一度でもそのオブジェクト変数が利用されたときだ。

まぁ有名な話なので知ってる人は知ってると思うが、ここで今一度実験によってその挙動を明らかにしておきたい。
調査するためには、Collectionでは分かりにくいので自作のクラスにコンストラクターを実装して確認してみよう。

コンストラクターとは、オブジェクトが生成されたタイミングで自動的に発動される特殊なプロシージャである。

クラスモジュールClass1を作成し、次のコードを張り付ける。

Private Sub Class_Initialize()
    Debug.Print "オブジェクトが生まれました。"
End Sub

Sub hoge()
    Debug.Print "hoge"
End Sub

次に標準モジュールのコード

まずはMicrosoftの推奨する方法で行儀よく。

Sub 宣言とNewを分けた場合()
    Dim c As Class1
    Debug.Print "標準コード1"
    Set c = New Class1
    Debug.Print "標準コード2"
    c.hoge
End Sub

結果はこうなった。

標準コード1
オブジェクトが生まれました。
標準コード2
hoge

ちゃんとNew Class1とした瞬間にオブジェクトが生まれている。

では次に変数宣言時にNewした場合。

Sub 宣言時にNewした場合()
    Dim c As New Class1
    Debug.Print "標準コード1"
    c.hoge
End Sub

結果は以下のとおり。

標準コード1
オブジェクトが生まれました。
hoge

ほら、宣言時のNewではオブジェクトが生成されておらず、次の標準コード1が表示されている。
そしてc.hogeと命令を出した瞬間、命令よりも一瞬早くコンストラクターのコードが実行される。

次に、メソッドの実行などを行わずにオブジェクト変数の参照だけするケース。
変数のアドレスを調べるVarPtr関数を使ってみた。
ザ・参照!って感じの関数なのでちょうど良いかと思って。

Sub 宣言時にNewしたのち参照だけした場合()
    Dim c As New Class1
    Debug.Print "標準コード1"
    Debug.Print VarPtr(c)
    Debug.Print "標準コード2"
    c.hoge
End Sub

結果は次のとおり

標準コード1
オブジェクトが生まれました。
 3141520 
標準コード2
hoge

この場合も、初めて参照されたタイミングでコンストラクターが実行されている。

オブジェクト変数の宣言時にNewしたことで発生しうるバグ

まあ、変数宣言時にNewしたからといってそれが原因で起こるバグというのはあまり考えにくいのだけれど、そこはあえてバグが起きるコードを考えてみた。
※バグはエラーとイコールではない。作者の意図しない動作はすべてバグである。

次のようなクラスモジュールを作ってみた。
コンストラクターでSheet1のA1セルにHelloを書き込み、デストラクターでGood Bye!を書き込む。

Private Sub Class_Initialize()
    Sheet1.Range("A1").Value = "Hello"
End Sub

Sub hoge()
    Sheet1.Range("A1").Value = "hoge"
End Sub

Private Sub Class_Terminate()
    Sheet1.Range("A1").Value = "Good Bye!"
End Sub

そして標準モジュール。
まずは宣言とNewを分けた場合。

Sub 宣言とNewを分ける()
    Sheet1.Range("A1").Value = "Start"
    Dim c As Class1
    Set c = New Class1
    Debug.Print Sheet1.Range("A1").Value
    c.hoge
    Debug.Print Sheet1.Range("A1").Value
    Set c = Nothing
    Debug.Print Sheet1.Range("A1").Value
End Sub

結果はこうなった。

Hello
hoge
Good Bye!

最初にA1セルにStartを入れているが、cにNew Class1を代入した時点でコンストラクターによりA1にはHelloが入るため、Debug.Printで最初の出力はHelloとなっている。

次に宣言時にNewした場合。

Sub 宣言時にNewする()
    Sheet1.Range("A1").Value = "Start"
    Dim c As New Class1
    Debug.Print Sheet1.Range("A1").Value
    c.hoge
    Debug.Print Sheet1.Range("A1").Value
    Set c = Nothing
    Debug.Print Sheet1.Range("A1").Value
End Sub

結果はこうなった。

Start
hoge
Good Bye!

変数宣言時にNewしているためここではまだオブジェクトは生成されず、A1の値はStartとでる。
次にhogeが実行される瞬間、コンストラクターによりA1の値がHelloに書き換えられるが、直後にhogeが実行されてA1の値はhogeになってしまう。

折角コンストラクターを作ったのに無意味な動作になってしまったため、これはバグだと言える。

あるいは別のシチュエーションとして、すでにNothingを代入して破棄したオブジェクトを間違って参照してしまったとき、そのコードが動いてしまうことだ。
さらにまずいことに、内部ではオブジェクトが再作成されるため、再度コンストラクターが実行されることだ。
まあこれはプログラマーのミスなのであるが、変数宣言とNewを分けておけば、破棄したはずのオブジェクト変数に再アクセスしてしまった際にちゃんとエラーで知らせてくれる。

まとめ

現実にはコンストラクターでRangeを書き換えるなんて特殊なことはやらないと思うので個人的には変数宣言時にNewを使って問題が発生するようなシチュエーションはめったにない。
ただし、今後も問題が発生するようなコードを書かないとは言い切れないし、Microsoftが推奨していない以上は変数宣言時のNewはやめたほうが良いだろうなと思う。
ということで、今後は変数宣言時のNewは封印しようと思う。

当ブログは、amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイト宣伝プログラムである、 Amazonアソシエイト・プログラムの参加者です。