C# DynamicObjectの基本と細かい部分について

DynamicJsonをver.1.1に更新しました。ver.1.0の公開以降に理解したDynamicObjectについての諸々を反映させてあります。具体的には、IsDefinedやDeleteといったメソッド名を書かずに、それらが呼び出せるようにし、また、foreach時にキャストが不要になりました。DynamicはIntelliSenseが効かないので、メソッド名を書かせたら負け。大まかに分けて「プロパティ」「名前付きメソッド」「名前無しメソッド」「キャスト」の4つをうまいこと振り分けて、不自然ではなく使えるようにすれば、DynamicObjectとして良い設計となるのではないかな、と思っています。

というわけで、今回は微妙にはまったDynamicObjectの挙動の色々について書きますが、その前に1.1の更新事項を。DynamicJson自体については、ver1.0のリリース時の記事を参照ください。

// ただの入れ物クラスなのであまり気にしないで。
public class FooBar
{
    public string foo { get; set; }
    public int bar { get; set; }
}

// こんなJSONがあったとするとする
var json = DynamicJson.Parse(@"{""foo"":""json"", ""bar"":100, ""nest"":{ ""foobar"":true } }");
var arrayJson = DynamicJson.Parse(@"[1,10,200,300]");
var objectJson = DynamicJson.Parse(@"{""foo"":""json"",""bar"":100}");

// .プロパティ名()はIsDefined("プロパティ名")と同じになります
var b1_1 = json.IsDefined("foo"); // true
var b2_1 = json.IsDefined("foooo"); // false
var b1_2 = json.foo(); // true            
var b2_2 = json.foooo(); // false;

// .("プロパティ名")はDelete("プロパティ名")と同じになります
json.Delete("foo");
json("bar");

// キャストはDeserialize<T>()と同じになります
var array1 = arrayJson.Deserialize<int[]>();
var array2 = (int[])arrayJson; // array1と一緒
int[] array3 = arrayJson; // こう書いてもDeserialize呼び出しと同じだったりする

// 配列だけではなく、パブリックプロパティ名で対応を取るマッピングも可能です
var foobar1 = objectJson.Deserialize<FooBar>();
var foobar2 = (FooBar)objectJson;
FooBar foobar3 = objectJson;

// 勿論、配列+オブジェクトでも可。Linqに繋げる時はキャストで囲みましょう(asはダメ)
var objectJsonList = DynamicJson.Parse(@"[{""bar"":50},{""bar"":100}]");
var barSum = ((FooBar[])objectJsonList).Select(fb => fb.bar).Sum(); // 150
var hoge = objectJsonList as FooBar[]; // これはnullになる、asとキャストは挙動が違う

// array状態のDynamicJsonにforeachはdynamicが渡る
// 中の型が分かっている場合は、varではなく型名指定するといいかも
// ちなみに、数字はdynamicのままだと全てdoubleです
foreach (int item in arrayJson)
{
    Console.WriteLine(item); // 1, 10, 200, 300
}

// オブジェクト状態のDynamicJsonへのforeachはKeyValuePair
// .Key、.Valueなのは分かってる、というならdynamicで受けると楽かも
foreach (KeyValuePair<string, object> item in objectJson)
{
    Console.WriteLine(item.Key + ":" + item.Value); // foo:json, bar:100
}

foreachを自然に使えるようにしたのと、IsDefined、Remove、DeserializeをDynamicとして自然に呼び出せるようにした、というのが更新内容になります。IsDefined("name")が.name()で自然なのか?というと、どうなんでしょうねー、という感じですが、しかしDynamicはIntelliSenseが効かないのです!なので、多少ややこしくても、こうして使える方が便利だと思われます。

ところで、キャストとasの関係は、DynamicObjectだとより正確に意識する必要が出てきます。キャストとasは例外が飛ぶかnullになるかの違いしかない、と思っていたりしたのは私なのですが、そんなことはなくて、asはユーザ定義の変換演算子を呼ばないという性質があります。DynamicObjectでもそれが反映されているため、キャストするとTryConvertが呼ばれるのですが、asは純粋にそのクラスの継承関係しか見ません。

DynamicObjectとは

では本題。まずは基本から。型宣言がdynamicの場合に、挙動が変わるオブジェクトの作成方法は、DynamicObjectを継承して、挙動を変えたいメソッド(Tryなんたら)をオーバーライドするだけです。

override->スペースでIntelliSenseに候補が出てきますね、素敵。Try何とかかんとかの第一引数はbinderですが、とりあえずbinder.Nameだけで何とかなります。成否はboolで返し(falseの場合は、呼び出しを解決するため更に連鎖が重なったりする)、trueの場合はresultにセットした値が呼び出し元に返る。といった仕組みになっています。

呼び出しの解決

簡単な例、ということで呼び出し名を返す、というだけの単純なTryInvokeMemberを定義してみます。

// MyDynamic:DynamicObjectのオーバーライド、メソッド呼び出し名を文字列としてそのまま返す、引数がある場合はfalse
public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result)
{
    result = binder.Name;
    return (args.Length > 0) ? false : true;
}

dynamic d = new MyDynamic();
var t1 = d.Hoge(); // Hoge - dynamic(string)
var t2 = d.ToString(); // ToStringにはならない、MyDynamicのToStringが呼ばれる

さて、この場合本来あるメソッドであるToStringやEquals、その他自分で定義したメソッドがあれば、それらを呼んだ場合はどちらが優先されるでしょうか、というと、定義されたメソッドが優先です。なので、TryInvokeMemberにだけ挙動を定義しておくと、どうやっても呼べないメソッドが出てきます。例えばDynamicJsonで言えば、プロパティ名がToStringのJSONに対してToString()で定義されているか確認は出来ません。そのための回避策として、TryInvokeMemberはIsDefinedの簡易記法としています。IsDefined("ToString")ならば問題なく呼べますので。

引き続いて、TryInvokeMemberがfalseの場合も見てみます。

public override bool TryGetMember(GetMemberBinder binder, out object result)
{
    result = new MyDynamic();
    return true;
}

public override bool TryInvoke(InvokeBinder binder, object[] args, out object result)
{
    result = ((string)args[0]).ToUpper();
    return true;
}

dynamic d = new MyDynamic();
var t = d.Hoge("aaa"); // AAA(InvokeMemberが失敗したらGetMemberが呼ばれ、それのTryInvokeが呼ばれる)

d.Hoge("aaa")は、まずTryInvokeMemberが呼ばれます。今回は引数がある場合はfalseとしているので、失敗します。すると、呼び出しの解決のためTryGetMemberが呼ばれます。ここでtrueの場合は、引数を持ってTryInvokeが呼ばれます。TryInvokeはd("aaa")のような、メソッド名なしでの関数呼び出しです。C#にはない記法となるので、TryInvokeMemberからの失敗の連鎖でTryGetMemberでDynamicObject以外を返すと、TryInvokeに失敗という形で例外が出ます。

ややこしいですね!この失敗の連鎖はDynamicJsonではオブジェクトがネストしている際の呼び出しの解決に利用しています。

var json = DynamicJson.Parse(@"{""tes"":10,""nest"":{""a"":0}");

json.nest(); // これはjson.IsDefined("nest")
json.nest("a"); // これはjson.nest.Delete("a")

json.nest("a")は、まずTryInvokeMemberが呼んでいます。これは原則IsDefinedと等しいのですが、引数がある場合はfalseにしています。そのためTryGetMemberが呼ばれて.nestを取得。そして、TryInvoke(これはDeleteに等しい)を呼ぶという流れになっています。真面目にDynamicObjectを使って構造を作る場合、呼び出し解決順序などを意識する必要は、間違いなくあります。ややこしいですけどねー。

DynamicObjectとforeach

DynamicObjectがIEnumerableならば、foreachはそれを呼びます。ていうかforeach可能なものはIEnumerableにするでしょ常識的に考えて。と、言いたいのですが世の中そうもいかなかったりします。

Dynamicの変数はデバッガで見るとDynamic Viewというものが用意されていて、展開すると全てのプロパティ名と値を表示してくれます(これに対応させるにはGetDynamicMemberNamesをオーバーライドする必要がある、DynamicObject作るなら必須!)。大変便利なのですが、これ、クラスがIEnumerableを実装している場合はIEnumerableの結果ビューに置き換わってしまい、Dynamic Viewがなくなってしまいます。

Dynamic Viewがないと非常に不便極まりないので、DynamicJsonではIEnumerableの実装は断念しました。しかし、foreachでそのまま呼べないのは不便だ。どうする?となって思い浮かんだのは、IEnumerableじゃなくてもGetEnumeratorがあればforeachって呼ばれるんだよねー、普通のクラスは。という仕様がC#にはあるので、Dynamicでも行けるかな?と思いきやそんなことはなく、呼ばれませんでした。じゃあ、かわりにforeach時に何が呼ばれていたか、というと、TryConvertが呼ばれます。

public override bool TryConvert(ConvertBinder binder, out object result)
{
    // foreachで呼ばれた時はbinder.TypeがIEnumerable
    if (binder.Type == typeof(IEnumerable))
    {
        result = Enumerable.Range(1, 10); // resultはIEnumerableとIEnumerator、どちらでも可
        return true;
    }

    // 通常のキャストは適当に分岐させるか、キャストの演算子オーバーロードでもどちらでもいい
    // 演算子オーバーロードがある場合は、そちらが優先されます
    result = (binder.Type == typeof(string)) ? "hogehoge" : null;
    return true;
}

dynamic d = new MyDynamic();
foreach (var item in d)
{
    Console.WriteLine(item); // 1,2,3,4,5,6,7,8,9,10
}

var ie = (IEnumerable)d; // これは失敗する、インターフェイスへの明示的型変換は不可!

C#ではインターフェイスの演算子オーバーロードは定義出来ないし、Dynamicでも呼び出しは不可能になっています。が、foreach呼び出し時のみ、TryConvertにTypeがIEnumerableとして渡るようになっているので、そこでtrueを返せば、IEnumerableではないDynamicObjectでもforeachで列挙出来ます。

正直なところ、セコいハックに過ぎないです。本当はデバッガでDynamic Viewと結果ビューが共存できればいいんですよ、ていうか出来るべき。あと、Dynamic Viewは今のところ値がnullのものは表示しないようになっているのですが、これも不便な仕様ですね、改善して欲しいところ。とはいえ、IEnumerableじゃないから不便になってる!ということは無いと思われます。どちらにせよIEnumerableじゃなくてもdynamicはキャストしないと拡張メソッドが呼べない(つまりそのままではLinqが使えない)ため、利用感は犠牲にしていません。

そういえばExpandoObjectはIEnumerableなのにdynamic viewが出るので、何か方法はあるかもしれませんね。

まとめ

dynamicは当初思っていたよりも、遥かに使いがいのある仕組みでした。dynamicはDSL。だと思います。そして、DSLとして便利に使わせるならば、メソッド名で呼ばせるのは厳禁。用意された機構を上手く使ってIntelliSenseレスでも快適に操作出来るようにしなければならない。というか、それで操作出来ないならば普通にC#で組んでIntelliSense効かせた方がずっと良い。

と、まあ、そんなわけでDynamicJsonは400行程度の小さいコードですが、割と色々考えて作ってありますので、是非使ってみてください。お手製ライブラリにありがちなJSON解析の出来が怪しい、といった問題を、解析部を.NET FrameworkのJsonReaderWriterFactoryに丸投げしているため避けられている、というのも大きな利点かと思われます。

ソースコード本体は、ArrayとObjectを一つのクラスに統合しているため、各メソッド行頭でifで分ける、というのが若干怪しいのですが(オブジェクト指向的にはポリモーフィズム、ていうかDynamicならその辺考えずに分けても問題ない、だろうけど400行のコードですからねえ、分割したほうが手間かつ分かりにくくなるだろうしで、まだリファクタリングの出番ではない、と思いますです、これ以上規模が膨れるなら別ですが)、全体的にはDynamicObjectの機能を満遍なく使っているので、参考になるかと思います。あと、JSON書き出し時のLinq to XmlでのXML組み立て部分は若干トリッキーなものの、割とよく出来ているかな? Deserializeはもう少し気合入れて実装し直さないとダメな感じですがががが。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive