.NETでCOMコンポーネントを使用する際のCOMオブジェクト開放要否を確認した

# 確認してみた理由
COMオブジェクトをどうしても使わざるを得ない状況になって、どのタイミングで何を開放すべきかいまいち判然としなかったので手を動かした。

# 注意点
下記コードではとりあえず準備が楽なExcel(2013)を使った。
概ねどんなものかを確認するためと、使用したいコンポーネントでの確認作業の叩き台として作成したが、別のコンポーネントでもこの通りの結果になるかどうかの保証はしかねる。都度そのコンポーネントで確認するのが望ましい。
ぶっちゃけExcel操作するだけならVBScriptかJScriptで操作するコード書いてMSScriptControlで実行する方がお気楽なので、Excelを使った意味は準備が楽だったという点以外には本当にない。

# 結果の概要
- インスタンスを作ったら当然開放する。
- 代入やキャストで別変数に格納した場合は全部同じCOMオブジェクト参照なので、いずれかの変数を開放すれば良い。不安なら全部開放してもそれはそれで問題はない。
- 引数で渡されたCOMオブジェクトの開放は呼び出し側でよしなにする。
- 戻り値として返すCOMオブジェクトは呼び出し側（戻り値を受け取った側）でよしなにする。
- COMオブジェクトのプロパティやメソッドで取得した物は要開放。同じプロパティやメソッド（引数）で取得した場合でも、都度別のCOMオブジェクト参照として扱われるようなので各々開放する必要がある。

# 確認に使ったコード
## 修正
<dl>
<dt>2018/02/09 20:55</dt>
<dd>
コンパイラにキャストが消されていたので消されないように修正。<br/>
結果としては特に変わらなかったので、コードのみ修正して結果の概要は修正箇所なし。<br/>
ついでに再帰呼び出し等でメソッドから戻ってきたモノをEqualsで正しく判断できるかのテストを追加。問題なく判定可能だった。
</dd>
</dl>
## コード
```VB.net:ComReleaseTester.vb
Imports System.Runtime.InteropServices
Imports Excel = Microsoft.Office.Interop.Excel

Class ComReleaseTester
    ''' <summary>
    ''' COMの破棄テストを実行する。
    ''' </summary>
    ''' <param name="leak">意図的に開放漏れを行う場合はTrue</param>
    Public Shared Sub Execute(Optional ByVal leak As Boolean = False)
        Dim oExcel As Excel.Application = Nothing
        Dim oBooks As Excel.Workbooks = Nothing
        Dim oBook As Excel.Workbook = Nothing
        Dim oSheet As Excel.Worksheet = Nothing
        Dim oSheetObj As Object = Nothing
        Dim oRange1 As Excel.Range = Nothing
        Dim oRange2 As Excel.Range = Nothing
        Dim oRange3 As Excel.Range = Nothing
        Dim oRange4 As Excel.Range = Nothing
        Dim oRange5 As Excel.Range = Nothing
        Dim oRange6 As Excel.Range = Nothing
        Dim oRange7 As Excel.Range = Nothing
        Dim oRange8 As Excel.Range = Nothing
        Dim oRangeObj As Object = Nothing
        Dim oInterior1 As Excel.Interior = Nothing
        Dim oInterior2 As Excel.Interior = Nothing

        Dim startTime As DateTime = DateTime.Now

        Try
            Try
                oExcel = New Excel.Application
                oExcel.DisplayAlerts = False

                oBooks = oExcel.Workbooks
                oBook = oBooks.Add()


                PrintSection("ダイレクトキャストすると開放できないという話を見たのでテスト")
                ' Workbook.ActiveSheetがObject型のようなのでObject型でそのまま取得し、ダイレクトキャスト
                oSheetObj = oBook.ActiveSheet
                oSheet = DirectCast(oSheetObj, Excel.Worksheet)
                Try
                    Console.WriteLine("oSheet.Name=" & oSheet.Name)
                Catch ex As Exception
                    PrintError("DirectCastしたCOMオブジェクトにアクセス(oSheet.Name)", ex)
                End Try
                Try
                    Console.WriteLine("Marshal.ReleaseComObject(oSheet)=" & Marshal.ReleaseComObject(oSheet))
                Catch ex As Exception
                    PrintError("DirectCastしたCOMオブジェクトを開放", ex)
                End Try
                Try
                    Console.WriteLine("oSheet.Address=" & oSheet.Name)
                Catch ex As Exception
                    PrintError("開放済みのDirectCastしたCOMオブジェクトにアクセス(oSheet.Name)", ex)
                End Try

                Try
                    Console.WriteLine("oSheetObj.Name=" & oSheetObj.Name)
                Catch ex As Exception
                    PrintError("キャスト元のオブジェクトも開放済みなので" &
                               "プロパティにアクセスするとエラーになる(oSheetObj.Name)", ex)
                End Try

                FinalReleaseComObjects(oSheetObj, oSheet)

                ' これ以降もシートを使うので再取得しておく
                oSheet = oBook.ActiveSheet




                PrintSection("代入やキャストで参照カウントの増加やオブジェクトの生成は行われず、" &
                             "すべて同じCOMオブジェクトとして扱われる。")
                ' COMオブジェクト取得（参照カウント+1）
                oRange1 = oSheet.Range("B5")

                ' 代入では参照カウントは増えない
                oRange2 = oRange1

                '' キャストしたインスタンスを代入しても参照カウントは増えない
                oRangeObj = oRange1
                oRange3 = DirectCast(oRangeObj, Excel.Range)
                oRange4 = CType(oRangeObj, Excel.Range)


                ' すべて同じCOMオブジェクトなので、いずれかが開放されると他も使えなくなる
                ' （COMオブジェクトではあるが使用できず、ReleaseComObjectをしても戻り値が-1）
                Console.WriteLine("oRange2 count=" & Marshal.ReleaseComObject(oRange2))

                ' oRange2で開放済みなのでoRange1は使えない
                Try
                    Console.WriteLine("Marshal.IsComObject(oRange1)=" & Marshal.IsComObject(oRange1))
                    Console.WriteLine("oRange1.Address=" & oRange1.Address)
                Catch ex As InvalidComObjectException
                    PrintError("開放済みのCOMオブジェクトにアクセス(oRange1.Address)", ex)
                End Try

                ' oRange2,3,Objも開放済みになっている（戻り値が-1）
                Console.WriteLine("Marshal.ReleaseComObject(oRange1)=" & Marshal.ReleaseComObject(oRange1))
                Console.WriteLine("Marshal.ReleaseComObject(oRange2)=" & Marshal.ReleaseComObject(oRange2))
                Console.WriteLine("Marshal.ReleaseComObject(oRange3)=" & Marshal.ReleaseComObject(oRange3))
                Console.WriteLine("Marshal.ReleaseComObject(oRangeObj)=" & Marshal.ReleaseComObject(oRangeObj))

                ' oRange7は定義しただけなので当然COMオブジェクトの参照はなく、開放する必要も特にない。
                ' というかNullなので開放しようとすると落ちる。
                ' 開放済みのCOMオブジェクトを開放しようとしても特にエラーにはならないが、
                ' Nullの場合や非COMオブジェクトの場合は落ちるので、
                ' 解放時に最低限NullチェックとCOMオブジェクトかどうかのチェックは必要。
                Try
                    Console.WriteLine("Marshal.ReleaseComObject(oRange7)=" & Marshal.ReleaseComObject(oRange7))
                Catch ex As NullReferenceException
                    PrintError("Nullのオブジェクトを開放しようとした(oRange7)", ex)
                End Try
                Try
                    Dim tmp As String = "TEMP"
                    Console.WriteLine("Marshal.ReleaseComObject(tmp)=" & Marshal.ReleaseComObject(tmp))
                Catch ex As ArgumentException
                    PrintError("非COMオブジェクトを開放しようとした(tmp)", ex)
                End Try




                PrintSection("メソッドの引数として渡しても参照カウントの増加やインスタンスの生成は行われないので、" &
                             "メソッド内で開放されると呼び出し元でも使えなくなる。")
                ' メソッドの引数として渡したCOMオブジェクトがメソッド内で開放されると呼び出し元でも使えなくなる
                oRange1 = oSheet.Range("B5")
                Console.WriteLine("oRange1.Address=" & oRange1.Address)
                ReleaseArg(oRange1)
                Try
                    Console.WriteLine("oRange1.Address=" & oRange1.Address)
                Catch ex As InvalidComObjectException
                    PrintError("開放済みのCOMオブジェクトにアクセス(oRange1.Address)", ex)
                End Try





                PrintSection("メソッドの戻り値として受け渡しても参照カウントの増加やインスタンスの生成は行われないので、" &
                             "戻り値がメソッド内で開放されると呼び出し側でも使えなくなる。")
                ' メソッドへの引数で渡したり戻り値として受け取ったりしても参照カウントは増えない
                Try
                    ' 呼び出したメソッド中、Finally等で戻り値が開放されていると、
                    ' 開放されたCOMオブジェクトが戻ってくるので使用するとエラーになる
                    oRange5 = GetRange(oSheet, "B5", True)
                    Console.WriteLine("oRange5.Address=" & oRange5.Address)
                Catch ex As InvalidComObjectException
                    PrintError("開放済みのCOMオブジェクトにアクセス(oRange5.Address)", ex)
                End Try

                ' 戻り値が開放されていなければ当然使える
                oRange5 = GetRange(oSheet, "B5", False)
                Console.WriteLine("oRange5.Address=" & oRange5.Address)




                PrintSection("再帰呼び出し等で同じインスタンスがそのまま戻ってきた場合も同じCOMオブジェクトとして扱われるので、" &
                             "Equals等で判定して開放の要否を判断する。")
                ' 同インスタンスがそのまま戻ってくる場合
                Try
                    oRange8 = Recursive(oRange5, 1, False)

                    ' Equalsで比較すると同じと判定される
                    Console.WriteLine("Marshal.Equals(oRange5, oRange8)=" & Marshal.Equals(oRange5, oRange8))

                    ' 同じモノなのでoRange8を開放するとoRange5も開放されたことになる。
                    Console.WriteLine("Marshal.ReleaseComObject(oRange8)=" & Marshal.ReleaseComObject(oRange8))
                    Console.WriteLine("oRange5.Address=" & oRange5.Address)
                Catch ex As Exception
                    PrintError("開放済みのCOMオブジェクトにアクセス(oRange5.Address)", ex)
                Finally
                    FinalReleaseComObjects(oRange5, oRange8)
                End Try

                ' 別インスタンスが戻ってくる場合
                Try
                    oRange5 = oSheet.Range("B5")

                    ' 別のCOMオブジェクトが戻ってきた場合は当然別物として扱われる
                    oRange8 = Recursive(oRange5, 1, True)

                    ' Equalsで比較すると一致しないと判定される
                    Console.WriteLine("Marshal.Equals(oRange5, oRange8)=" & Marshal.Equals(oRange5, oRange8))

                    ' 別のモノなのでoRange8を開放してもoRange5には影響しない
                    Console.WriteLine("Marshal.ReleaseComObject(oRange8)=" & Marshal.ReleaseComObject(oRange8))
                    Console.WriteLine("oRange5.Address=" & oRange5.Address)
                Catch ex As Exception
                    ' ここで例外が発生することはないはず。
                    PrintError("何故かoRange8の解放後にoRange5のアクセスもエラーになった", ex)
                Finally
                    FinalReleaseComObjects(oRange5, oRange8)
                End Try




                PrintSection("メソッドの引数が同じ場合や同じプロパティでCOMオブジェクトを取得しても、" &
                             "別のCOMオブジェクトとして扱われる。")
                ' 同じ引数でCOMオブジェクト（この場合はB5セルのRange）を取得しても別物として参照を扱われる
                ' この取得でoRange5の参照カウントが増えたりはしない
                oRange6 = oSheet.Range("B5")
                Console.WriteLine("Marshal.Equals(oRange5, oRange6)=" & Marshal.Equals(oRange5, oRange6))

                ' プロパティでもCOMオブジェクトを返すなら開放の必要がある
                oInterior1 = oRange6.Interior
                ' 同じプロパティでアクセスしても、都度別のCOMオブジェクトとして扱われる
                oInterior2 = oRange6.Interior

                Console.WriteLine("Marshal.Equals(oInterior1, oInterior2)=" & Marshal.Equals(oInterior1, oInterior2))

                Console.WriteLine("oInterior1 ColorIndex=" & oInterior1.ColorIndex)
                Console.WriteLine("Marshal.ReleaseComObject(oInterior1)=" & Marshal.ReleaseComObject(oInterior1))

                ' 別のCOMオブジェクトとして扱われているのでoInterior1が開放されたとしても影響を受けない
                Console.WriteLine("oInterior2.ColorIndex=" & oInterior2.ColorIndex)

                Try
                    ' oInterior1は開放済みなのでアクセスするとエラーになる
                    Console.WriteLine("oInterior1.ColorIndex=" & oInterior1.ColorIndex)
                Catch ex As InvalidComObjectException
                    PrintError("開放済みのCOMオブジェクトにアクセス(oInterior1.ColorIndex)", ex)
                Finally
                    FinalReleaseComObjects(oInterior1, oInterior2)
                End Try




                PrintSection("COMオブジェクトのプロパティを取得した際に取得元のオブジェクトのみ開放しても、" &
                             "取得したプロパティのCOMオブジェクトへのアクセスは特に問題はない。" &
                             "……が、できればやらない方が良い気はする。")
                Try
                    ' RangeからInteriorプロパティを取得後にRangeの方だけ開放してみる
                    oInterior1 = oRange6.Interior
                    Console.WriteLine("Marshal.ReleaseComObject(oRange6)=" & Marshal.ReleaseComObject(oRange6))

                    ' 影響はないようだがGCが走った時とかにどうなるか謎なので、
                    ' 問題ないとは思うが変なハマりをしたくなければやらない方が無難であろう。
                    Console.WriteLine("oInterior1.ColorIndex=" & oInterior1.ColorIndex)
                Catch ex As Exception
                    PrintError("開放済みのCOMオブジェクトにアクセス(oInterior1.ColorIndex)", ex)
                Finally
                    FinalReleaseComObjects(oInterior1)
                End Try



                ' オプションが指定されたら意図的に開放漏れする
                If (leak) Then
                    Dim leakRange As Excel.Range = Nothing
                    leakRange = oSheet.Range("A1")
                End If

            Catch ex As Exception
                MessageBox.Show(ex.Message)

            Finally
                If (oBook IsNot Nothing) Then oBook.Close(False)
                If (oExcel IsNot Nothing) Then oExcel.Quit()
            End Try
        Catch ex As Exception

        Finally
            FinalReleaseComObjects(oInterior1, oInterior2,
                                oRange1, oRange2, oRange3, oRange4, oRange5, oRange6, oRange7, oRange8, oRangeObj,
                                oSheetObj, oSheet, oBook, oBooks, oExcel)
        End Try

        Threading.Thread.Sleep(2000)


        ' Excelプロセスが終了したか確認する。
        PrintSection("Excelプロセスが終了したか確認する。")
        Dim ps As System.Diagnostics.Process() = System.Diagnostics.Process.GetProcessesByName("EXCEL")
        Dim pcnt As Integer = 0
        For Each p As System.Diagnostics.Process In ps
            Try
                If (Not p.HasExited AndAlso p.StartTime >= startTime) Then
                    ' 処理実行後に起動したプロセスのみカウント
                    pcnt += 1
                End If
            Catch ex As Exception
            End Try
        Next
        Console.WriteLine(IIf(pcnt > 0,
            "[WARN] Excelプロセスが残っているかも（count=" & pcnt & "）",
            "[INFO] 終了しました（多分Excelプロセスは残っていません）"))
    End Sub

    Private Shared Function GetRange(ByVal sheet As Microsoft.Office.Interop.Excel.Worksheet, ByVal addr As String, ByVal doRelease As Boolean) As Microsoft.Office.Interop.Excel.Range
        ' 引数で渡されても参照カウントは増えていないので開放の必要がない
        ' 同じ参照なので開放すると呼び出し元の変数も使えなくなってしまうので、それを意図していないなら開放してはいけない
        Dim r As Microsoft.Office.Interop.Excel.Range = sheet.Range(addr)

        Try
            ' 戻り値として使う変数も開放の必要は無い
            ' 戻り値で渡しても参照は増えないので、戻り値をFinallyとかで開放すると使えないCOMオブジェクトが返されてしまう。
            ' 呼び出し側で戻り値を受け取らないのであればここで開放しなければいけないが、
            ' 受け取る気がないのにCOMオブジェクトを返すメソッドを呼び出すのは使い方がおかしい。
            Return r
        Finally
            If (doRelease) Then
                Console.WriteLine("Marshal.ReleaseComObject(MethodResult)=" & Marshal.ReleaseComObject(r))
            End If
        End Try
    End Function

    Private Shared Sub ReleaseArg(ByVal r As Excel.Range)
        ' 引数を開放すると呼び出し元でも使えなくなるので基本的に開放しない
        Console.WriteLine("Marshal.ReleaseComObject(MethodArg)=" & Marshal.ReleaseComObject(r))
    End Sub

    Private Shared Function Recursive(ByVal r As Excel.Range, ByVal i As Integer, ByVal isRetrunNextCol As Boolean) As Excel.Range
        If (i < 10) Then
            If (i = 1) Then Console.Write("Recursive ")
            Console.Write(".")
            Return Recursive(r, i + 1, isRetrunNextCol)
        Else
            Console.WriteLine("")
            If (isRetrunNextCol) Then
                Return r.Offset(0, 1)
            Else
                Return r
            End If
        End If
    End Function

    Private Shared Sub PrintSection(ByVal comment As String)
        Console.WriteLine(New String("="c, 24) & vbCrLf & comment & vbCrLf & New String("-"c, 4))
    End Sub

    Private Shared Sub PrintError(ByVal message As String, ByRef ex As Exception)
        Console.WriteLine("[ERROR] {0} : {1}" & vbCrLf & "{2}" & vbCrLf & "{3}",
                        message, ex.GetType().FullName, vbTab & ex.Message, ex.StackTrace)
    End Sub

    Private Shared Sub FinalReleaseComObjects(ParamArray objects As Object())
        For Each o As Object In objects
            Try
                If (o IsNot Nothing _
                AndAlso Marshal.IsComObject(o)) Then
                    Marshal.FinalReleaseComObject(o)
                End If
            Catch ex As Exception
            End Try
        Next o
    End Sub
End Class
```