Excel VBAをクラスを使って改善しよう: エラーハンドリングを考える
よくあるエラーハンドリングと課題
一般的なエラーハンドリングは以下の3種類があります。
特に1と2が多く、3は正直見かけないかもしれません。
- Booleanの戻り値で判別
- 例外を使った判別
- アウト変数と戻り値を使って判別
長年、実業務でExcel VBAアプリケーションを構築していく中で様々なコードを見てきましたが、上記それぞれに課題があります。
また、最後にクラスモジュールを使用した改善案を提示します。
まずは、それぞれのコード例と課題を整理しましょう。
整理するにあたり、1つの題材を元に話を進めたいと思います。
シート名「プロジェクト」からプロジェクト一覧を取得する。
プロジェクトが1件もない場合は設定ミスと判断し、エラーとする。
エラーが出ない場合は、ヘッダを除く案件リストを配列として取得する。
Booleanの戻り値で判別
これはもう、どこにでもあります。
標準モジュール1つでの実装イメージです。
Option Explicit
Private mProjects As Variant
Public Sub TestMain()
If SetProjects Then
Dim vIndex As Long
For vIndex = LBound(mProjects) To UBound(mProjects)
Debug.Print mProjects(vIndex)
Next vIndex
End If
End Sub
Private Function SetProjects() As Boolean
Dim vArray As Variant
vArray = ThisWorkbook.Worksheets("プロジェクト").Range("A1").CurrentRegion.Value
If UBound(vArray, 1) <= 1 Then
Exit Function
End If
ReDim vProjects(1 To UBound(vArray, 1) - 1) 'ヘッダ除く
Dim vRowIndex As Long
For vRowIndex = LBound(vArray, 1) + 1 To UBound(vArray, 1) 'ヘッダ除く
vProjects(vRowIndex - 1) = vArray(vRowIndex, 1)
Next vRowIndex
mProjects = vProjects
SetProjects = True
End Function
メンバー変数のmProjectsに安全に値を設定できています。
と、言いたいところですが、例えばシートがなかったりしたら失敗します。

これは、ThisWorkbook.Worksheets("プロジェクト") でシート名がないため、プログラマの予期しないエラーとなっています。
Booleanの戻り値だけでは、完全にはエラーを排除出来ない、これが第1の課題です。
第2の課題は、戻り値がBoolean型であり、取得したいデータ(プロジェクトの配列)そのものではないため、直感的でないことです。
これに対処するのが、例外処理です。
例外を使った判別
例外を使ったエラーハンドリングも、一般的で生成AIもよく提案してくれます。
基本的にこのコードで多くの問題は解決できます。
Option Explicit
Public Sub TestMain2()
On Error GoTo Catch
Dim vProjects As Variant
vProjects = GetProjects
Dim vIndex As Long
For vIndex = LBound(vProjects) To UBound(vProjects)
Debug.Print vProjects(vIndex)
Next vIndex
Exit Sub
Catch:
Debug.Print Err.Number & ":" & Err.Source & ":" & Err.Description
End Sub
Private Function GetProjects() As Variant 'Array
Dim vArray As Variant
vArray = ThisWorkbook.Worksheets("プロジェクト").Range("A1").CurrentRegion.Value
If Not IsArray(vArray) Then
Err.Raise 11111, "GetProjects", "配列の取得に失敗しました"
End If
If UBound(vArray, 1) <= 1 Then
Err.Raise 22222, "GetProjects", "シートから取得できません"
End If
ReDim vProjects(1 To UBound(vArray, 1) - 1) 'ヘッダ除く
Dim vRowIndex As Long
For vRowIndex = LBound(vArray, 1) + 1 To UBound(vArray, 1) 'ヘッダ除く
vProjects(vRowIndex - 1) = vArray(vRowIndex, 1)
Next vRowIndex
GetProjects = vProjects
Exit Function
End Function
前段の課題1については、基本どんなエラーが起きてもCatchラベルに飛ぶため、安全です。
課題2についても、戻り値が素直であり、処理が継続していれば必ず値が設定されていることが保証される点もグッドです。
ただし、このコードにも課題はあります。
1つは、デバッグのしにくさです。ここでは1つのプロシージャを扱っていますが、多くのプロシージャが階層的に実行される実コードでは、いきなり呼び出し元のCatchに処理がジャンプします。
何らかの後処理が必要だった場合(例えばファイルクローズ処理など)、処理がジャンプすることで問題となることがあります。
もう1つは、例外をキャッチするか、しないかを意識しながら実装が必要となることです。
プロシージャに例外を投げることを明示的に記述する方法が言語的にサポートされていないため、実装者は例外が投げられるかどうかをコードを読んで確認する必要があります。
今回でいえば、配列の取得に失敗した場合、シートから値が取得できない場合に例外が出力されることを理解しておく必要があります。
コードが長くなればなるほど、様々な例外を管理せねばならず、保守性が低下します。
アウト変数と戻り値を使って判別
この方法はあまり見かけませんが、前2つの課題をカバーできる点で、慣れると使いやすいと思います。
Option Explicit
Public Sub OutTestMain()
Dim vProjects As Variant
If GetProjects(vProjects) Then
Dim vIndex As Long
For vIndex = LBound(vProjects) To UBound(vProjects)
Debug.Print vProjects(vIndex)
Next vIndex
Else
Debug.Print "エラーになりました。"
End If
End Sub
Private Function GetProjects(pOutResult As Variant) As Boolean
On Error GoTo Catch
Dim vArray As Variant
vArray = ThisWorkbook.Worksheets("プロジェクト").Range("A1").CurrentRegion.Value
If Not IsArray(vArray) Then
Debug.Print "配列取得失敗!"
Exit Function
End If
If UBound(vArray, 1) <= 1 Then
Debug.Print "値が設定されていない"
Exit Function
End If
ReDim pOutResult(1 To UBound(vArray, 1) - 1) 'ヘッダ除く
Dim vRowIndex As Long
For vRowIndex = LBound(vArray, 1) + 1 To UBound(vArray, 1) 'ヘッダ除く
pOutResult(vRowIndex - 1) = vArray(vRowIndex, 1)
Next vRowIndex
GetProjects = True
Exit Function
Catch:
Debug.Print "内部エラー発生"
End Function
ちょっとコードが難しく感じるかもしれませんので、ちょっとだけ解説します。
GetProjectsプロシージャは引数にpOutResultをとります。
呼び出し元では特に初期化してはいませんが、GetProjects内でReDimして再宣言をしています。
VBAはByValを引数に付与しない限りByRefで参照渡しとなるため、呼び出し元でも設定した内容は引き継がれます。
処理が成功したかどうかを戻り値で返しています。
呼び出し元で戻り値を判別し、成功しているならば値が正しく取得できたことが保証されます。
失敗しているならば、アウト変数は使ってはいけないという判断ができます。
GetProjectsプロシージャ内で例外処理は閉じているため、呼び出し元は戻り値のみで成功・失敗が判別できます。
これは階層化された呼び出しでもIF文で分岐処理もでき、コードもずっと読みやすい形になります。
もうこれで課題はなさそうですが、1つだけ問題があります。
それはエラー情報が乏しい、ということです。
GetProjectsプロシージャが失敗したことはわかりますが、その理由を把握したい場合があります。例えば、内部エラーの場合は処理を終了したいが、それ以外の入力データによるエラーの場合は処理を継続したい、といったことです。
これについては、アウト変数を追加して、エラー原因を返すアウト変数を準備すれば解決できそうです。
ただし、返したい情報が多くなるたびに引数が増えていったり、本来は結果を返す意味のアウト変数という意味合いが薄れていき、なんでもアウト変数で処理してしまいがちになります。
実行結果もアウト変数として、Functionプロシージャではなく、Subプロシージャにすることだってできます。
そうなると各プロシージャで書き方の統一性がなくなり、可読性がまた下がっていってしまいます。
改善案:結果オブジェクトを導入する
これらを解決するために、戻り値の情報をリッチにしたいと思います。
クラスモジュールにExecuteResultを追加します。
Option Explicit
Private mSuccess As Boolean
Private mCause As E_ERROR_TYPE
Public Property Let Success(pValue As Boolean)
mSuccess = pValue
End Property
Public Property Get Success() As Boolean
Success = mSuccess
End Property
Public Property Let Cause(pValue As E_ERROR_TYPE)
mCause = pValue
End Property
Public Property Get Cause() As E_ERROR_TYPE
Cause = mCause
End Property
Option Explicit
Public Enum E_ERROR_TYPE
NORMAL
NO_ARRAY
NO_DATA
UNEXPECTED
End Enum
Public Sub ObjTestMain()
Dim vProjects As Variant
With GetProjects(vProjects)
If .Success Then
Dim vIndex As Long
For vIndex = LBound(vProjects) To UBound(vProjects)
Debug.Print vProjects(vIndex)
Next vIndex
Else
Select Case .Cause
Case E_ERROR_TYPE.NO_ARRAY
Debug.Print "配列が取得できませんでした。"
Case E_ERROR_TYPE.NO_DATA
Debug.Print "データがありませんでした"
Case E_ERROR_TYPE.UNEXPECTED
Debug.Print "予期せぬエラーが発生しました"
Case Else
Debug.Assert False '列挙体に追加し忘れのためチェック
End Select
End If
End With
End Sub
Private Function GetProjects(pOutResult As Variant) As ExecuteResult
On Error GoTo Catch
Dim vArray As Variant
vArray = ThisWorkbook.Worksheets("プロジェクト").Range("A1").CurrentRegion.Value
If Not IsArray(vArray) Then
Set GetProjects = CreateExecuteResult(False, NO_ARRAY)
Exit Function
End If
If UBound(vArray, 1) <= 1 Then
Set GetProjects = CreateExecuteResult(False, NO_DATA)
Exit Function
End If
ReDim pOutResult(1 To UBound(vArray, 1) - 1) 'ヘッダ除く
Dim vRowIndex As Long
For vRowIndex = LBound(vArray, 1) + 1 To UBound(vArray, 1) 'ヘッダ除く
pOutResult(vRowIndex - 1) = vArray(vRowIndex, 1)
Next vRowIndex
Set GetProjects = CreateExecuteResult(True, NORMAL)
Exit Function
Catch:
Set GetProjects = CreateExecuteResult(False, UNEXPECTED)
End Function
Private Function CreateExecuteResult(pSuccess As Boolean, pCause As E_ERROR_TYPE) As ExecuteResult
Dim vObj As ExecuteResult
Set vObj = New ExecuteResult
vObj.Success = pSuccess
vObj.Cause = pCause
Set CreateExecuteResult = vObj
End Function
戻り値にExecuteResultクラスを使用するようにしています。
ExecuteResultクラスは処理が成功したかどうか、失敗した場合はEnumで指定したエラーコードをE_ERROR_TYPE列挙体のメンバーとして返しています。
ほかの情報が必要になったとしても、ExecuteResultクラスにプロパティを追加していけば、エラー時に必要な情報を増やしていくことができます。
また、この記述方法はちょっと特殊ゆえ、目立ちます。
失敗して副作用が発生するかもしれないプロシージャの呼び出しに注意が向けられます。
通常のIF文と異なるため、前段のアウト変数を使ったロジックより、同じ分岐処理でも違うブロックとして認識しやすく、ロジック全体の制御構造に影響を与えません。
まとめ
今回はエラーハンドリングについて、VBAのクラスを使って、安全で可読性の高い方法を見てきました。
なかなか見慣れないとは思いますが、使っていくとコードが整理されていくのが分かるはずです。
ぜひ挑戦してみてください。

Discussion