ものがたり(旧)

atsushieno.hatenablog.com に続く

System.Xaml.dllについて(その2)

前回からさっぱり書く気が起きず放置していたのだけど、適当に続きを書いてみようと思う。今回はXamlObjectReaderについて。

XamlObjectReaderのノード出現フロー

XamlObjectReaderは任意のobjectをXAMLノードとして読むためのXamlReaderの実装だ。

XAMLノードは、XAMLオブジェクトとそのメンバーとその値から成る、という話を前回書いたけど、これをXMLのXmlReaderと同じようにノードをRead()で次々に返していくものだと理解すればいい。XmlReaderにおけるNodeTypeに相当するXamlNodeTypeというものがXamlReaderにもある。基本的には、1つのXAMLノードは、StartObject -> StartMember -> StartObject .. EndObject あるいは Value -> EndMember -> 次のStartMember ... -> EndObject という流れで読み取られる。Valueノードはプリミティブな(メンバーを持たない)型の値である場合に返される。オブジェクトの型はStartObjectの時にXamlReaderのTypeプロパティとして、あるいはメンバーの型であればStartMemberの時にXamlMemberのTypeプロパティとして、知ることが出来る。なお、後述するMarkupExtensionのXamlLanguage.PositionalParametersについては、この流れが当てはまらないので注意が必要だ。

ルートあるいはコレクションの要素(これを便宜上トップレベルと呼ぼう)としてプリミティブな型が出現した場合は、StartObjectの型として知ることが出来る。この時、値を含むXamlMemberとしてはXamlLanguage.InitializationというXamlDirectiveが出現するのみで、これは特定の型をもたない。トップレベル以外の場合はStartMemberで型を知ることが出来る。

GetObject

基本的な流れとは異なる特異な例として、ReadOnlyなメンバーの値となるXAMLオブジェクトの場合は、StartObjectの代わりにGetObjectというノードが流れてくることがある。これはメンバーの値となるオブジェクトの開始を表すもので、オブジェクトの「生成」を指示するものではなく「取得」を指示することになり、最後にはEndObjectで終わることになる。XamlObjectReaderにおいては、StartObjectもGetObjectもメンバーの値をリフレクションで取得することになるが、例えばXamlObjectWriterにおいてはこれらの処理は明確に異なり、StartObjectにおいては新しいインスタンスが生成され、GetObjectにおいては現在生成中のインスタンスからメンバーの値を取得する。GetObjectは、例えば型がコレクションであるメンバーについて用いられる(getterがあってsetterが無い場合が多いだろう)。

GetObjectが使用されるパターンがもうひとつある。それはXAMLノードツリーで既に出現したオブジェクトを「参照」する場合だ。あるノードxの子孫がそのプロパティ値としてxへの参照をもっていた場合、これをもしXamlObjectReaderが単純にStartObject..EndObjectで処理しようとすると、永久ループに入ってしまう。そうならないよう、XAMLツリーで一度出現したオブジェクトは、二度書かれることは無い。代わりに、二度目に出現した時は、XamlLanguage.Reference(x:Reference)という特殊な型のオブジェクトとなる。これは他のメンバーを名前で参照する。この「名前」は、その型の中でSystem.Windows.Markup.RuntimeNamePropertyAttributeを設定されたプロパティが存在すればその値が、無ければXamlLanguage.Name(x:Name)というXamlDirectiveの値として一意に自動生成された文字列が、それぞれ使用される。自動生成の場合は問題にならないが、もし名前文字列が一意な値を返さなかった場合はエラーだ。

出現しうるメンバー

XAMLオブジェクトのメンバーは、通常はその型のプロパティに対応するXamlMemberの集合ということになり、そのXamlTypeでGetAllMembers()で返される。それらのメンバーのうち、値がnullでないものが、StartMember .. EndMemberの一連のノードとなって返され、それがメンバーの数だけ続く。

しかし、このパターンに沿わないものがいくつかある:

  • その型がコンストラクタ引数を要求する場合。デフォルトコンストラクタが無く、ConstructorArgumentAttributeが適用されたプロパティがあれば、XamlLanguage.ArgumentsというXamlDirectiveを返し、その内容として適合するプロパティの値をコレクションとして順次返す(コレクションのノードの返し方についてもここで説明する)。
  • (前述の)プリミティブ型。これはXamlLanguage.Initializationを返す。その値としてプリミティブ値が続く。
  • コレクション型。XamlTypeのIsCollectionあるいはIsDictionaryがtrueであれば、そのオブジェクトでは、(他のメンバーも返すが)コレクションの内容をXamlLanguage.ItemsというXamlDirectiveの内容として、順次StartObject..EndObjectの流れで返していく。IsDictionaryがtrueである場合はもう一段複雑で、コレクションの要素であるKeyValuePair(genericである場合)あるいはDictionaryEntry(non-genericである場合)の、それぞれ値の部分をStartObject..EndObjectとして返すが、その内容として特別なメンバーXamlLanguage.KeyというXamlDirectiveを返すことがある(その内容はキー部分となる)。*1
  • (後述する)IXmlSerializableオブジェクトから転じてXDataとなったオブジェクトの場合、全てのメンバーではなくXData.Textのみが返される。
  • (後述する)MarkupExtensionから派生したクラスのインスタンスの場合、XamlLanguage.PositionalParametersというXamlDirectiveが返されることがある。この条件は明らかではないが、TypeExtension, StaticExtension, コンストラクタ引数が全てプリミティブ型である型が該当するのではないかとわたしは推測している。そして、重要なことだが、このメンバーの内容は、StartMember -> Value -> EndMember の基本的な流れに従わず、それぞれの引数に対応する文字列値がValueノードとして連続して返される(!)。つまり、StartMember -> Value -> Value -> Value ... -> EndMember のようになる。もし文字列値にならないような引数をもつコンストラクタのみが存在する場合、XamlObjectReaderはこれを拒絶しないが、XamlXmlWriterなどはそのようなノードの出現をエラーとする。

ちなみにメンバーの出現順は、コンストラクタ引数など先に出現しなければならないものが先行し、以降はアルファベット順で出現し、XamlLanguage.Itemsは(わたしの知る限り)最後に出現する。XamlLanguage.Keyなどは普通にアルファベット順で出現したりするので、実装する側(この場合わたし)としてはハマりどころだ。

メンバーの値

XAMLオブジェクトのメンバーの値は、通常はそのプロパティの値そのものとなる。しかし、そうならない場合がたまに存在する。

  • ArrayはArrayExtension(x:Array)オブジェクトとなる。
  • TypeはTypeExtension(x:Type)オブジェクトとなる。
  • コレクションの内容などでnullがトップレベルで出現する場合はNullExtensionとなる。(前述の通り、通常のメンバーの値がnullである場合はそもそもStartMember..EndMemberの流れで出現しない。)
  • IXmlSerializableを実装する型のオブジェクトの場合、XData(x:XData)のオブジェクトとなり、そのTextプロパティに、IXmlSerializable.WriteXml()によって文字列にシリアライズされたXMLが含まれることになる。
  • (前述の)参照オブジェクトとしてのReference(x:Reference)は、プリミティブでない任意のオブジェクトに代わる参照として出現しうる。

他にもメンバー値が特別な型になるものが存在するかもしれないが、わたしは把握していない。

ちなみに、これに関連する話題として、XamlObjectReaderのInstanceプロパティについて触れておこうと思う。これは通常は現在のプロパティの値(あるいはルートオブジェクトならそのもの)を返すのだけど、上記のArrayExtensionやTypeExtensionのようなオブジェクトが返されることはなく、元のArrayやTypeが返される*2。ただしさらにややこしいことに、どうやらTypeがルートオブジェクトとして渡された場合には、Instanceプロパティの値はTypeではなくTypeExtensionになるようだ。ArrayはArrayExtensionにならないので、Typeだけ特別扱いしているように見える(バグなんじゃないかとも思う)。

追記: IXmlSerializable/XDataの時はどうなるのだろうかと疑問だったが、実験してみたところ、この場合はXDataでもIXmlSerializableでもなくnullが返るようだ(!)

NamespaceDeclarations

さて、最後に説明するのはやや順番がおかしいのだけど、XamlObjectReaderは、StartObjectを返す前に、一連のNamespaceDeclarationをノードとして返す(XamlNodeTypeにはNamespaceDeclarationも存在する)。このNamespaceDeclaration群は、XAMLオブジェクトのオブジェクトグラフから、使用されるXamlTypeおよびXamlMemberをあらかじめ巡回しておいて、使用されるNamespace (PreferredXamlNamespace)を収集して、それぞれに一意のPrefixを割り当てたものだ。このため、実はオブジェクトグラフは、NamespaceDeclarationの収集と実際のRead()の処理で、(少なくとも)2回は巡回されている(!)。何でこんな動作になっているのかは必ずしも明確ではないが、XamlXmlWriterはあらかじめNamespaceDeclarationが宣言されていないNamespaceがXamlTypeあるいはXamlMemberで出現したらエラーとなるので、その関係かもしれない(StartObjectやStartMemberの前にチェックするより2回巡回した方が早いのかもしれない)。

最後に

以上でXamlObjectReaderの動作の説明は終わりだ。XamlReader, XamlWriterを実装するとき、最初に実装すべきクラスだったのだけど(XamlObjectReaderでオブジェクトを読み、XamlXmlWriterでXMLに出力し、XamlXmlReaderでそのXMLを読み、XamlObjectWriterでデシリアライズしていた)、その経験から言えばこのクラスが一番ややこしいものだった。

本当はこれにattached propertiesなどが加わってさらにややこしいことになるのかもしれないけど、WPFの無いわたしにはそこまで実験する術がないので、今回はここまで。

*1:未確認だがSystem.Windows.Markup.DictionaryKeyPropertyAttributeをもつメンバーがあれば、これは出現しないと考えられる。

*2:書いていて気づいたけどXDataの時はどうなるのか把握していない