EnvDTE を使ってデバッグ中に LINQ っぽいことがしたい

LINQ = LINQ to Object って意味で読んでくださいね!

Visual Studio はそれはもう素晴らしい IDE な訳で、特にデバッグに関する機能は日々の開発速度を大きく向上してくれています。

そんな VS が持つデバッグ機能にも色々ありますが、今回はイミディエイトウインドウすごい、でももっと頑張れよって応援したけどダメだったのでじゃあ無理矢理なんとかしたるわいという話です。(タイトルはちょっと盛りすぎで実際はそこまで上手くいってません)

イミディエイトウインドウ

一応知らない人のために簡単に説明すると、デバッグ中に任意の式や文を実行できる機能です。

[イミディエイト ウィンドウ]

例えばデバッグ実行したプログラムを適当なところでブレークして、イミディエイトウインドウで変数名を叩いてみるとこうなります。

f:id:kendik:20150430005314p:plain

ちょっと小さくて分かりにくいかもですが、変数の値が表示されているのが分かるでしょうか。

次に、Add メソッドに変数を渡して実行してみます。

f:id:kendik:20150430005341p:plain

Add メソッドの実行結果が取れました。

意味があるかは置いておいて、別に今ブレークしているところ無関係なコードも書けます。

f:id:kendik:20150430005501p:plain

とまあこんな感じに。

変数の値を見たり変えたりくらいなら変数ウォッチ系のウインドウでも出来ますが、メソッド呼び出しをしたりちょっとした計算をしたりというのはデバッグ時に意外と必要になるのでデバッグ対象のコードを弄らずにこういうことが出来るのはありがたい限りです。

イミディエイトウィンドウで出来ないこと

ですが、制限もあります。LINQラムダ式を実行できません*1。ちょっと見てみましょう。

class Program
{
    static void Main(string[] args)
    {
        var list = new List<int> {1, 2, 3, 4, 5};
        foreach (var i in list)
            Console.WriteLine(i);
    }
}

はい、何の意味もないコードですが例なのでスルーしてくださいね。これをデバッグ実行して適当にブレークし、そうですね、、、list から偶数だけ取得したいと思ったとしましょう。普通に書くなら Where 拡張メソッドでしょうか。

f:id:kendik:20150430005527p:plain

式に 'lambda expressions' を含めることはできません。 と怒られてしまいました。無慈悲な。

ちなみに今回の例では List<T> が持ってる Find メソッドでも欲しい結果は得られますが、引数にラムダ式が必要なので結局一緒です (デリゲート?何それ?)。クソが。それと、ラムダ式を使わないでもいい LINQメソッド、例えば Any とかもダメですね。定義が含まれていないと言われます。

そう、皆さんご承知の通りイミディエイトウィンドウは非常に強力な機能なのですが、ラムダ式LINQ が使えないのです。これは痛い。。。なぜ出来ないのか。LINQ どうこうというより、デバッグ中にコレクションを自由に操作したいという欲求は普通にあるはずです。どうにかしたいが何とかならんか、というのがこれからの話です*2

Visual Studio 2015 を使う

ググると結構同じ課題を抱えている人がいるようです。MS もそこらへんさすがに配慮したのか、VS 2015 ではイミディエイトウインドウでラムダ式を実行できるようになります。

blogs.msdn.com

じゃあ VS 2015 を使えば解決だね!やったねたえちゃん!

VS 2013 でなんとかしたい

ってそんな訳あるか。

VS 2015 はまだプレビューです。さすがにプレビューを仕事で使うのは難しいですし、そうは言ってもそろそろ出てくるだろうとは思いますが待ってられません。仮に待てても、発売はい即採用なんてお仕事では中々ね。。。

という訳で、VS 2013 でなんとかします。

利用するのはパッケージマネージャコンソールと EnvDTE (の DTE) です。

EnvDTE というのは聞き慣れないと思いますが(私は知りませんでした)、Visual Studio が用意している COM のラッパーだそうです。VS オートメーションに利用されるそうで、外から VS を操作することが出来ます。VS アドインを作ったことのある方には割と馴染みがあるみたいですね。私は COM を触ったことも VS アドインを作ったこともないので、一応調べましたが詳しく説明できません。とりあえずそういうものを使うんだと思ってくれれば。

EnvDTE を使ってみる

では実際にやっていきましょう。

より具体的には、今回利用するのは EnvDTE の Debugger オブジェクトが持つ GetExpression メソッドです。GetExpression メソッドはイミディエイトウィンドウのように、引数に渡した式を解釈して結果を返してくれます。

Debugger.GetExpression メソッド (EnvDTE)

さっきのサンプルコードの適当なところでブレークして、パッケージマネージャコンソールを開きます。
そして

PM> $dte.Debugger.GetExpression("list[0]")

と打つと以下のような結果が返ってくると思います。

Name         : list[0]
Type         : int
DataMembers  : System.__ComObject
Value        : 1
IsValidValue : True
DTE          : System.__ComObject
Parent       : System.__ComObject
Collection   : 

GetExpression で返ってくるのは __ComObject と呼ばれるオブジェクトで、その Value プロパティが実際に欲しい値になります。

なお、Value プロパティで取得できる値の型は残念ながら文字列なので、今回のように数値型のコレクションで計算などを行いたい場合はキャストが必要です。

なので、例えば list コレクションの一番目と二番目の要素の値が欲しい、これらを足したいという要件があった場合以下のように書きます。

要素の値を後で使いたいなどの理由があり、別の変数に欲しい場合

PM> $item1 = $dte.Debugger.GetExpression("list[0]").Value -as [int]
PM> $item2 = $dte.Debugger.GetExpression("list[1]").Value -as [int]
PM> $item1 + $item2
3

結果だけ欲しい場合

PM> $dte.Debugger.GetExpression("list[0] + list[1]").Value
3

コレクションを操作する

ここまで来ればもう概ね予想は付くかと思いますが、これを応用するとコレクションも自由に操作できるようになります。ただ注意が必要なのが、元の型がコレクションの場合、__ComObjectValue は、コレクションそのものではありません。

PM> $dte.Debugger.GetExpression("list")


Name         : list
Type         : System.Collections.Generic.List<int>
DataMembers  : System.__ComObject
Value        : Count = 5
IsValidValue : True
DTE          : System.__ComObject
Parent       : System.__ComObject
Collection   : 

ご覧の通り Count = 5 という文字列リテラルが入っています。我々が欲しいのはコレクションそのもの、ひいては各要素ですね。これらを取得するためには DataMembers プロパティを使います。

PM> $dte.Debugger.GetExpression("list").DataMembers

Name         : [0]
Type         : int
DataMembers  : System.__ComObject
Value        : 1
IsValidValue : True
DTE          : System.__ComObject
Parent       : System.__ComObject
Collection   : System.__ComObject

Name         : [1]
Type         : int
DataMembers  : System.__ComObject
Value        : 2
IsValidValue : True
DTE          : System.__ComObject
Parent       : System.__ComObject
Collection   : System.__ComObject

<中略>

Name         : 列ビュー
Type         : 
DataMembers  : System.__ComObject
Value        : 
IsValidValue : True
DTE          : System.__ComObject
Parent       : System.__ComObject
Collection   : System.__ComObject

このように、DataMembers プロパティによって各要素にアクセス可能になります。「列ビュー」という謎の要素も取れていてこれが何なのかはちょっと分かりません。とりあえず邪魔です。型変換も必要なので、実際に使うにはこんな感じでしょうか。

PM> $list = $dte.Debugger.GetExpression("list").DataMembers | ?{ -not [string]::IsNullOrEmpty($_.Type) } | % { $_.Value -as [int] }
PM> $list | ?{ $_ % 2 -eq 0 }
2
4

偶数だけのコレクションが取得できました。
これをもって「LINQ が実行できた!」と言うにはあんまりですが (そもそも PowerShell だし)、自由にコレクションを操作できるようになったので、まぁいいんじゃないでしょうか。期待されていた方にはごめんなさい。

値の取得に関しては実際もうちょっとスマートにやれるかもしれませんが 私は PowerShell にあまり詳しくないので誰か教えてくださいあくまで参考として見て頂ければ。

注意事項

GetExpression に渡す式にラムダ式は使えません。実際やってみてもエラーにはなりませんが、何も返ってきません。なんだよそれって思われるかもしれませんが、結局 GetExpression も VS 内で式を評価した結果を返しているだけなのでイミディエイトウィンドウと同じ理由で使えないのかもしれませんね。ちょっと明確な理由は分かりかねます。

取得したい値の型が IEnumerable<T> の場合

今までの例では List<T> 型を見てきましたが、IEnumerable<T> 型の場合も補足しておきます。というか大抵の場合は IEnumerable<T> のはずですしおすし。

結論、評価後の値を取ってくる必要がある、ということです。下のような感じです。

PM> $list = $dte.Debugger.GetExpression("list.ToArray()").DataMembers | % { $_.Value -as [int] }
PM> $list | ?{ $_ % 2 -eq 0 }
2
4

ToArray()ToList() を使えということですね。ToArray() を使うと「列ビュー」とやらが取れてこないのでお勧めです。

続・コレクションを操作する ~ユーザ定義型編~

ここまで見てきたのはプリミティブ型のコレクションでした。でも普通デバッグでコレクションを操作までしたいのってユーザ定義型ですよね。という訳で次にユーザ定義型のコレクションの取得を見ていきます。

ユーザ定義型も今までやってきたのと一緒じゃないの?というと実はちょっと違います。__ComObject の取得までは確かに一緒なんですが、型変換ができません。ちょっと見ていきましょう。以下のようなコードがあるとします。

class Program
{
    static void Main(string[] args)
    {
        var list = Enumerable.Range(1, 5)
            .Select(_ => new MyClass {Id = _});
        foreach (var i in list)
            Console.WriteLine(i);
    }
}
public class MyClass
{
    public int Id { get; set; }
}

今まで通り GetExpressionlist の値を取得します。

PM> $dte.Debugger.GetExpression("list.ToArray()")

Name         : list.ToArray()
Type         : ConsoleApplication11.MyClass[]
DataMembers  : System.__ComObject
Value        : {ConsoleApplication11.MyClass[5]}
IsValidValue : True
DTE          : System.__ComObject
Parent       : System.__ComObject
Collection   : 

まぁ、今まで通り取れてますね。
じゃあ今まで通り型変換していきます。

PM> $dte.Debugger.GetExpression("list").DataMembers | % { $_.Value -as [ConsoleApplication11.MyClass] }
型 [ConsoleApplication11.MyClass] が見つかりません。この型を含むアセンブリが読み込まれていることを確認してください。
<以下略>

怒られた!なんで!?ってそりゃ怒られます。型見つかりません。

じゃあ変換したい型が含まれるアセンブリをロードすればいいんでしょ?ってなると思いますがやめましょう。.NET はアセンブリのアンロードが出来ないので一度読み込むと .dll や .exe にロックをかけて、結果、そのアセンブリを生成するコードのビルドが通らなくなる可能性があります (アセンブリを書き換えようとするから)。

今やっているのはあくまでデバッグなので、対象コードの変更は当然起こり得るという前提に立つべきです。というか、仮にそれを許容してアセンブリをロードしても (多分 MarsharlByRefObject を継承しないと) 型変換出来ません。

ではどうするか?

やりたいことは obj.ID みたいな書き方でインスタンスのプロパティにアクセスすることです。
方法は色々あるかもしれませんが、私は PSCustomObject を使いました。

PM> $myClassList = New-Object 'System.Collections.Generic.List[PSCustomObject]'
PM> $temp = $dte.Debugger.GetExpression("list.ToArray()").DataMembers
PM> $temp | %{ $v = $_.DataMembers | Select Name, Value; $o = [PSCustomObject]@{}; $v | %{ $o | Add-Member $_.Name $_.Value }; $myClassList.Add($o); }
PM> $myClassList | ?{ $_.Id % 2 -eq 0 }
Id                                                                                                   
--                                                                                                   
2                                                                                                    
4   

例によって PowerShell のコードの拙さは見逃して頂くとして、軽くやっていることを説明します。

二行目では、GetExpression で取得したコレクションの DataMembers プロパティからコレクションの各要素を取得しています。

ここで、各要素の DataMembers プロパティからはさらにユーザ定義型の各プロパティの名前と値のペアの配列が取得できます (下記参照)。

PM> $dte.Debugger.GetExpression("list.ToArray()[0]").DataMembers

Name         : Id
Type         : int
DataMembers  : System.__ComObject
Value        : 1
IsValidValue : True
DTE          : System.__ComObject
Parent       : System.__ComObject
Collection   : System.__ComObject

ですので、三行目でこれを使って各要素毎にユーザ定義型と同じプロパティを持った PSCustomObject を作成し、一行目で作った結果オブジェクト (ここでは $myClassList) にその PSCustomObject を追加してます。そして、最終的に得られたユーザ定義型と同じプロパティを持った PSCustomObject のコレクションに任意の処理を行う、という流れです。

まとめ

面倒だから VS 2015 使え

ではなくて (半分本気ですが)、EnvDTE とパッケージマネージャコンソールを使えば、多少手間は掛かるものの、デバッグ中のコードの任意のコレクションに対して自由に PowerShell のコードを実行できるようになります。

結局 LINQ そのものが使えないのは残念ではありますが、PowerShell でも似たようなことが出来ますし、日々のデバッグにいくらか助けにはなるんじゃないでしょうか。副次的効果として PowerShell 力も上がるかもしれません。

とはいえ毎回だらだら PowerShell を書くのも面倒ですね。パッケージマネージャのプロファイル ($profile\NuGet_profile.ps1) に例えば以下のような関数を作成してやるともうちょい実務での利用で現実味が出てくるかと。

function GetDebuggingCollection (
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string] $variableName)
{
    $expression = ($variableName + '.ToArray()')
    $temp = $dte.Debugger.GetExpression($expression).DataMembers

    $result = New-Object 'System.Collections.Generic.List[PSCustomObject]'
    $temp | %{ $v = $_.DataMembers | Select Name, Value; $o = [PSCustomObject]@{}; $v | %{ $o | Add-Member $_.Name $_.Value }; $result.Add($o); }

    return $result
}
PM> $list = GetDebuggingCollection('list')
PM> $list | ?{ $_.Id % 2 -eq 0 }
Id                                                                                                   
--                                                                                                   
2                                                                                                    
4                                                                                                    

最後に

PowerShell でやれるのは分かった。C# で出来ないのか?」という疑問もあるかと思います。ご尤も。

結論、出来ます。

なのでそれについても書きたいんですが、ちょっとノリで書いてたら思いの外長くなってしまったので C# 編はまた後日書きます。

*1:余談ですが、ラムダ式使えないのはイミディエイトウィンドウに限った話でなく、ウォッチウインドウでも使えません。

*2:まぁデバッグ用のコードを直接書いてしまうというのも解だと思いますが、今回はコードに一切手を入れない方法を目指しています