🦒

Excel VBAをクラスを使って改善しよう: エラーハンドリングを考える

に公開

よくあるエラーハンドリングと課題

一般的なエラーハンドリングは以下の3種類があります。
特に1と2が多く、3は正直見かけないかもしれません。

  1. Booleanの戻り値で判別
  2. 例外を使った判別
  3. アウト変数と戻り値を使って判別

長年、実業務で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を追加します。

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