VBAのコーディング指針(案)【エラー処理】

  • 14
    Like
  • 3
    Comment

■ エラーの捕捉

 VBAでは、「On Error GoTo ステップ名」から始めることで、エラーが発生した場合、指定のステップ名に処理を移す事ができます。

errorCatch.bas

Public Sub runProcess() 
  On Error GoTo ERROR_STEP 'エラー捕捉開始

   'エラーが発生したら、ERROR_STEPに処理が移る。

 Exit Sub 'エラがない場合はここで処理終了。ErrObjectを初期化(Err.Number=0になる)
ERROR_STEP: 'エラー時の処理
 MsgBox Err.Description
End Sub

 小さい処理の場合、エラー捕捉はそれほど必要ないかもしれませんが、処理が多い場合は原則エラー捕捉を入れておきます。そうしないとデバッグに時間がかかります。ただ、上記のコードの状態であれば捕捉してもしなくても大して意味がないと思います。 

 エラー捕捉は、エラーに応じた処理を実行することに加えて、共通ライブラリであればエラー箇所を取得することが必要になります。要するに、他の言語でいう「スタックトレース」が必要になります。

■ スタックトレース

 VBAではスタックトレースを標準機能で使うことは、VBEのツールをつかってみること以外はできません。つまり、コード上でスタックトレースの情報を取得することはできません。
 
 そこで自前でスタックトレースができるように作成します。どうやって実現するのか?
この仕方についてはいろいろやり方はあるのかもしれませんが、ErrObject(実際にはErr)を使うのが最もよいと思います。

ErrObjectは、エラーが発生した際にエラー情報が格納されます。エラーがない状態では、Err.Numberは0でDescriptionなど情報が空文字の状態です。

また、ErrObjectはErr.raiseによってエラーを発生させ、On Error GoTo でエラーをキャッチすることができます。

 このErrObjectをつかってスタックトレースを実現します。

ErrObjectには、Sourceというエラーを発生させたオブジェクト名やIDを格納するプロパティがあります。
 
 ▶ Err.Sourceプロパティ

 ここにエラーの発生した箇所を格納していきます。

stackTrace.bas

'TestModule内にあるプロシージャ
Public Sub runProcess() 
  On Error GoTo ERROR_STEP 'エラー捕捉開始

   'エラーが発生したら、ERROR_STEPに処理が移る。

 Exit Sub 'エラがない場合はここで処理終了。
ERROR_STEP: 'エラー時の処理

  '発生元のErrObject情報を格納する。
 Dim preSource As String, preDescription As String
 preSource = Err.Source
 preDescription = Err.Description
  'helpContextなども引き継ぎたければここで変数に格納する

 Err.Clear 'ErrObjectを初期化

 'ErrObjectに既存のエラー情報とエラー箇所としてのプロシージャ情報を格納し、
 'エラーを再送する
 Err.raise source:="TestModule.runProcess" & vbCrLf & preSource, description:=preDescription

End Sub

 上記で完全なコードではなく、あくまでもエッセンスを表しているのみです。ポイントは、以下です。

  • 発生元のErrObjectの情報を別の変数に格納。
  • Err.Sourceに既存の情報とエラー捕捉したプロシージャ情報を格納。
  • Err.raiseでエラーを意図的に発生させ、エラーを呼び出し元に再送する。

上記のようにして、ErrObjectを呼び出し元に再送していきます。
呼び出しの出発点において、ErrObjectから情報を取得し、エラーを表示したり、ログを出力すればよいと思います。

■ エラー再送プロシージャ

 エラーの発生箇所や既存エラー情報を格納し、エラーを再送するにはそれ専用のプロシージャを作成すれば呼び出しがすっきりします。
以下はその例です。

M_Error.bas

'M_Errorモジュール
Public Sub sendError(preError As ErrObject, moduleName As String, procedureName As String, extraInfo As String) 

 Dim preSource As String, preDescription As String, preHelpContext As Integer, preHelpFile As String
 preSource = preError.Source
 preDescription = preError.Description
 preHelpContext = preError.HelpContext 
 preHelpFile = preError.HelpFile 

 preError.Clear 'ErrObjectを初期化

 'ErrObjectに既存のエラー情報とエラー箇所としてのプロシージャ情報を格納し、
 'エラーを再送する
 Err.raise Source:= moduleName & "." & procedureName  & vbCrLf & preSource, _
           Description:=preDescription & vbCrLf & extraInfo , _
           HelpContext:=preHelpContext, _
           HelpFile:=preHelpFile
End Sub

上記のメソッドを以下のように呼び出します。

callErrorProc.bas

'TestModule内にあるプロシージャ
Public Sub runProcess() 
  On Error GoTo ERROR_STEP 'エラー捕捉開始

   'エラーが発生したら、ERROR_STEPに処理が移る。

 Exit Sub 'エラがない場合はここで処理終了。
ERROR_STEP: 'エラー時の処理
  '発生箇所を格納してエラーを再送
  M_Error.sendError Err, "TestModule", "runProcess", "エラー情報"
End Sub

エラー情報の再送の方法についてはどのようなログにするのか等によって記載方法は変わると思います。
上記はあくまでもサンプルです。(実際には他にも色々処理をいれることがあります。)

■ モジュール名をプライベート定数で定義

 スタックトレースができる様になったらメソッドの位置情報をできるだけ簡易に記載できるようにモジュール名をプライベート定数で定義します。
この定数を前述のメソッドに渡します。プライベートにすることで同名の定数をそのまま使い回しができます。

privateModuleName.bas

'モジュール名をプライベート定数で定義
Private Const P_MODULE_NAME As String = "TestModule"

'TestModule内にあるプロシージャ
Public Sub runProcess() 
  On Error GoTo ERROR_STEP 'エラー捕捉開始

   'エラーが発生したら、ERROR_STEPに処理が移る。

 Exit Sub 'エラがない場合はここで処理終了。
ERROR_STEP: 'エラー時の処理
  '発生箇所を格納してエラーを再送
  '★モジュール名定数を引数に設定
  M_Error.sendError Err, P_MODULE_NAME, "runProcess", "エラー情報"
End Sub

 プライベート定数にしているので、グローバルで定数内の値が干渉することはありません。
このままメソッドをコピーして、プロシージャ名と付加的な情報を変えればよくなります。

■ On Error GoTo ステートメントによるエラー情報の初期化に注意

エラー捕捉は原則した方がよいのですが、「On Error GoTo」ステートメントを実行するとErrObjectの初期化(Err.Clear)が自動的に実行されます。

 ▶ ErrObjectの初期化

ここで気をつけるのが、エラー捕捉しエラーステップ内でプロシージャを呼び出すときです。
呼びだされたプロシージャ内に、「On Erro Goto」があるとそこでErrObjectが初期化されて、エラー情報が消えてしまいます。

privateModuleName.bas

'On Error GoToを呼び出したプロシージャ
Private Sub closeFile(fileId As Long)
  On Error GoTo ERROR_STEP 'ErrObjectを初期化
  Close fileId
  Exit Sub
ERROR_STEP:
  M_Error.sendError Err, P_MODULE_NAME, "closeFile", "エラー情報" 
End Sub

'実行処理
Public Sub runProcess(filePath As String) 
  On Error GoTo FINAL_STEP 'エラー捕捉開始(エラーが発生しようと、正常であろうと共通処理へ移動)

  'ファイル書き込み
  Dim fileId As Long
  fileId = FreeFile()
  Open filePath For Output As #fileId
  Print #fileId, "テスト値"

  'Exit Subがないので、正常の場合もFINAL_STEPへ移動)
FINAL_STEP: '

  'ファイルを閉じるプロシージャを呼び出し(ここに「On Error GoTo」がある)
  closeFile fileId

  'On Error GoToの後なので、ErrObjectが初期化され、以下のステップは実行されない。
  'closeFile内でエラーが発生した場合もここを通過しない。
  If 0 <> Err.Number Then
    M_Error.sendError Err, P_MODULE_NAME, "runProcess", "エラー情報"
  End If

End Sub

上記のコードのように、「On Error GoTo」があるプロシージャをエラーステップ内で呼び出すと、
ErrObjectが初期化されて、エラー情報の捕捉ができなくなります。
ですので、エラーステップ内で呼び出すプロシージャには「On Error GoTo」を入れないか、
呼び出し前にエラー情報を退避しておく方がよいです。

■ エラー情報の退避クラス

上記のようにエラー処理をする際に、どうしてもエラー情報を退避しないといけない場合があります。
その場合、あらかじめErrObjectの情報を格納するクラスを作成しておきます。
 全く同名のプロパティ名をつけて作るのがよいと思います。

privateModuleName.bas

'モジュール名をプライベート定数で定義
Private Const P_MODULE_NAME As String = "C_ErrorInfo"

Public source As String, description As String, helpContext As Integer, helpFile As String

Public Sub setProperties(preError As ErrObject)
  With preError
    Me.source = .Source
    Me.description = .Description
    Me.helpContext = .helpContext
    Me.helpFile = .helpFile
  End With
End Sub

Public Sub raiseError()
 Err.raise Source:= Me.source, _
           Description:=Me.description, _
           HelpContext:=Me.helpContext, _
           HelpFile:=Me.helpFile
End Sub

Publicプロパティではなく、Privateプロパティにして、プロパティメソッドを使って値へのアクセスをしても良いとは思いますが、
それは製造プロジェクトやチームの方針に従えばよいと思います。個人的にはPublicなsetter/getterが存在するものは、
Publicプロパティでよいと思います。無駄な記述なだけのように思いますので。

 ここでErrObjectのインスタンスを作成して使えばという疑問がわきますが、インスタンス自体は作れますが、
値を代入するとエラーが発生します。

 上記のクラスを使ってErrObjectの情報を一時退避して、エラー処理を行えばエラー情報を消さずに利用できます。

■ リフレクション(CallByName、Application.run)時のエラー処理

 VBAでもリフレクションは当然できます。オブジェクトのメソッドならCallByName、プロシージャならApplication.runを使えば文字列で名前を指定して、処理を実行できます。
 
 ここでエラー処理について注意が必要です。上記のリフレクション関連プロシージャで呼び出した処理内でエラーが起きた場合、Err.raiseでエラーを発生させても、呼び出し元には、エラーが伝わりません。発生箇所でエラーダイアログが表示されます。

 この場合、呼び出されるプロシージャ内では、「On Error Next Resume」で、例外をそれ以上発生しないようにします。
その上で、Err.Numberによりエラー発生を判別し、引数か戻り値でエラー情報を呼び出し元に返す必要があります。
 例えば、引数で返すなら、上記で触れたエラー情報退避クラスを使います。

privateModuleName.bas

'M_Testモジュールにあるとする。
Public Sub runCalledProcess(errorInfo As C_ErrorInfo)
 On Error Next Resume

  '何らかの処理

 If 0 <> Err.Number Then
 'エラー情報を引数に渡す
  errorInfo.setProperties Err
 End If

End Sub

Public Sub runProcess()
 On Error GoTo ERROR_STEP

  '名前でプロシージャを呼び出す。
  Dim errorInfo As New C_ErrorInfo
  Application.run "M_Test.runCalledProcess", errorInfo
  If 0 <> errorInfo.Number Then
  'エラー情報は引数のエラー番号で判別し再送
    errorInfo.raiseError
  End If

 Exit Sub
ERROR_STEP:
  M_Error.sendError Err, P_MODULE_NAME, "runProcess", "エラー情報"
End Sub

 上記のコードはあくまでもサンプルです。戻り値など色々と実際のコーディングに合わせて、アレンジができると思います。
ポイントは、Err.raiseを取得するのにワンクッション必要だという点です。

■ エラー番号での状態判別は多用しない

 Err.Numberによってどのようなエラーが発生しているのかが分かります。これを使えば、エラーに対応した処理がかけます。
ただ、最初からErr.Numberありきで処理を分岐することを考えないほうがよいと思います。
 あくまでもErr.Numberはエラーが起きた時に判別を使うものであって、ファイルの有無などは判別関数があるので、それらを使って判別します。

 ただし、VBAをしているとエラー番号を使わないと判別ができないものがあります。配列の動的なサイズ拡張であったり、コレクションクラスに指定オブジェクトがあるのかを判別するなどです。こうした場合は、それ以外選択肢がないので使わざるを得ないです。

 原則としては、エラー以外の関数で状態判別ができるならそちらで事前にチェックをします。Err.Numberはあくまでも本当に例外(開発者が予期しないこと)に限定して利用すべきだと思います。

■ エラー処理を考える大きな視点

 エラー処理をするにあたっては、大きく分けて下記のことを意識しておく必要があると思います。

  • ユーザーの操作エラー
     ユーザーによる復帰・再操作可能な状態、プログラマの想定内。

  • プログラムエラー
     ユーザーによる復帰・再操作不能な状態、バグを始めプログラマの想定外の原因。

  • 外部システム(環境)エラー
     当該プログラムの制御範囲外。プログラマは想定ができるものもあるがいつ起こるのかやその原因の対処はプログラムを通してはできない(しない)。例えば、メモリ確保の失敗、ファイル・システムのエラー、ネットワークエラー、DBサーバのダウンなど。

 ErrObjectは、上記のユーザーの操作エラーの判別などには使うべきではないと思います。あくまでも、想定外のプログラムエラーや外部システムエラーを把握するためのオブジェクトであると思います。

■ On Error Resume Next の使い所

 「On Error Resume Next」を使うと、エラーが発生したときにそこでダイアログがでず、処理が止まりません。
これは、エラーを無視したり、なかったことにするために使うのではなく、そこで処理を止めないために使います。
 前述したリフレクション内でのエラー処理のように、あくまでもエラー情報を汲み取ることを前提に、処理を止めない為に利用します。あとは、Err.Numberを利用しないと判別できない時に使ったりします。
(エラー発生時にVBAのコードをユーザーに見せない為ともいえます。)

 あくまでも、エラー情報を汲み取ることを前提にし、処理を止めない為に使うことを目的にすべきだと思います。

■ ユーザー定義のエラー番号

 リファレンスを読めば分かる通り、ユーザーが定義するエラー番号は以下の様に付けます。

vbObjectError + 512 + ユーザー定義の番号

※ 0〜512はシステムエラー

 ▶ ユーザー定義のエラー番号

■ エラーログ

エラーログはプロシージャを最初に呼び出したところ(実行のスタート地点)で簡易なファイル書き出しプロシージャをつかって実現すればよいと思います。

関連記事

▶ VBAのコーディング指針(案)【変数の名前と宣言】
▶ VBAのコーディング指針(案)【プロシージャ名の名前と呼び出し】
▶ VBAのコーディング指針(案)【GoTo文の使い所】
▶ VBAのコーディング指針(案)【クラスの使いどころ(オブジェクト指向的なVBAの考察)】