VBAでカプセル化のミスがスタック領域の不足を起こした件


あらすじ

VBAのコードを書いているとき、カプセル化のためのコードで、
意図せぬ無限再帰をしてしまい、
エラーメッセージ「スタック領域が不足しています。」を
発生させてしまいました。
いわゆるスタックオーバーフローです。

スタック領域

今回のお題はスタックオーバーフローです。
「スタック領域が不足しています。」と言われたら、
8割の確率で、再帰呼び出しによるスタックオーバーフローを疑っていいと思います。

本来のコード

まず、変数myNamePrivateにして外部からアクセスできなくしています。
これがカプセル化です。
次に、Property GetmyNameを文章に当てはめて取得できるようにしています。
myNameに値を登録する場合にはProperty Letを使えるようにしています。

NameRemember.cls
Private myName As String

Public Property Get GetName() As String
    GetName = "I am " + myName + "."
End Property

Public Property Let SetName(ByVal newName As String)
    myName = newName
End Property

誤ったコード

以下の誤ったコードでは、
Property Letの中で、自分自身に代入しているため、
無限に再帰呼び出しが行われます。

NameRemember.cls
Private myName As String

Public Property Get GetName() As String
    GetName = "I am " + myName + "."
End Property

Public Property Let Name(ByVal newName As String)
    Name = newName '←ここがミス
End Property

なぜ起こしてしまったのか

意図的に、以下のような再帰呼び出しの無限ループを起こすことも可能です。

LoopRecurse.bas
Sub Recursion(i)
    i = i + 1
    Call Recursion(i)
End Sub

ご覧の通り、上記のサンプルコードは関数の無限再帰です。
プロパティで同じことが起きる可能性を警戒していませんでした。

Name、myName、newNameなど、似た名前ばかりなため、
テキトーに書いて直すのを忘れたんですね。
まだまだ経験不足だからかもしれません。

対策

とはいえ、経験不足というのは言い訳です。
解決方法を考えましょう。

根本的な発生対策は思いつきません。
もし知っている、思いつく人がいれば、
コメント欄で伝えて下さるとLGTMします。
というかフォローします。

取り合えずの対策

細かい低いテストコードを書いて早めに気が付く方法があります。
このテストコードには、RubberDuckを使用しています。

test.bas
Option Explicit
Option Private Module

'@TestModule
'@Folder("Tests")

Private Assert As New Rubberduck.AssertClass
Private Fakes As New Rubberduck.FakesProvider

'@ModuleInitialize
Private Sub ModuleInitialize()
    'this method runs once per module.
    Set Assert = New Rubberduck.AssertClass
    Set Fakes = New Rubberduck.FakesProvider
End Sub

'@ModuleCleanup
Private Sub ModuleCleanup()
    'this method runs once per module.
    Set Assert = Nothing
    Set Fakes = Nothing
End Sub

'@TestInitialize
Private Sub TestInitialize()
    'This method runs before every test in the module..
End Sub

'@TestCleanup
Private Sub TestCleanup()
    'this method runs after every test in the module.
End Sub

'@TestMethod("Small")
Private Sub TestSetName()
    On Error GoTo TestFail
    'Arrange:
    Dim HumanName As NameRemember
    'Act:
    HumanName.SetName("GOD")

    'Assert:
    Assert.Succeed

TestExit:
    Exit Sub
TestFail:
    Assert.fail "Test raised an error: #" & Err.Number & " - " & Err.Description
End Sub

'@TestMethod("Small")
Private Sub TestGetName()
    On Error GoTo TestFail
    'Arrange:
    Dim HumanName As NameRemember
    Dim TestName As String
    'Act:
    TestName = "GOD"
    HumanName.SetName(TestName)

    'Assert:
    Assert.isEqual HumanName.GetName, "I am " + TestName + "."

TestExit:
    Exit Sub
TestFail:
    Assert.fail "Test raised an error: #" & Err.Number & " - " & Err.Description
End Sub

動作として、Sub TestSetNameSetNameが最後まで正常に動作するかを確認しています。
また、Sub TestGetNameGetNameが正常な値を返すかを確認しています。

この方法ではミスを見つけることはできますが、
未然に防ぐことはできません。

コードスニペット

vscodeなどには、コードスニペットやEmmetといった、
テンプレートを自動挿入してくれる機能が付いています。

これらを使用すると、クラスモジュール内でPrivate myName As Stringを書いている段階でProperty GetProperty Letの構文が自動挿入されたりします。

以下のサンプルコードのようなレベルが、
1行目を書いた直後に自動挿入されれば、
無限再帰は起こりえないでしょう。

NameRemember.cls
Private my_Name As String

Public Property Get Get_Name() As String
    Get_Name = my_Name
End Property

Public Property Let Set_Name(ByVal input_Value As String)
    my_Name = input_Value
End Property

これらの自動化は、単にキー入力の数を減らすだけではなく、
今回のようなミスを事前に防ぐ効果もあるといえます。
しかし、現状VBE(Visual Basic Editor)にそのような機能はありません。

命名規則

また、SetName、GetNameなど、明らかにそれと分かる
命名規則にして習慣化するとミスが防げるかもしれません。

まとめ

今のところ確実に未然防止する方法はありません。
スタックオーバーフローには気を付けましょう。

Excelsior!