4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

C#Advent Calendar 2017

Day 13

DynamicObjectを使う場合の注意点

Last updated at Posted at 2017-12-12

この記事はC# Advent Calendar 2017の13日目の記事デスよっと。

前口上

動的なマッピングなんかするときに威力を発揮するDynamicObjectですが、だいたいが動的に解決されるので挙動にわりかし癖がある。
なので、今回は割と頻用するであろう、TryGetMemberTryConvertを主に検証していこうかなと思いますのでお付き合い頂ければ幸い。

DynamicObjectとは何なの?

動的なアクセスやらの解決をしたい際、一般的なバインドでは無く、DynamicMetaObjectBinder経由で一風変わったコールサイトの構築をしてくれたりする。
利用する際は、DynamicObjectから継承して必要なメソッドをオーバーライドすることでカスタムロジックを噛ませることが可能になったりする。

TryGetMemberで出来ること

例えば、SomeDynamic.HogeMogeという感じでアクセスしたい。ただ、HogeMogeはバックエンドの状態でFooBarなど任意の名前になるよみたいなとき、TryGetMemberメソッドをオーバーライドすることで実行時に適宜ディスパッチすることが出来る。

サンプルとして、以下のようなDynamicObjectを継承したクラスをこさえる


class TryGetMemberSample : DynamicObject
{
	public override bool TryGetMember(GetMemberBinder binder, out object result)
	{
		if (binder.Name == "HogePiyo")
		{
			result = 42;
				
		}
		else if (binder.Name == "FooBar")
		{
			result = 114514;
		}
		else
		{
			result = 0;
		}

		return true;
	}
}

binder.Nameに、実行時どのような識別子で呼ばれたかが入っているので、今回は決め打ちで返す値を変えているけど、実際には内部辞書とのKeyあたりと突合して有無を調べるみたいなのが定石かなと。

また、binder.ReturnTypeが有るので、一見代入先の型を取得可能と思えるけど、ここは常にobjectになってるので、当てにならないし出来ない。
実際、代入する型に合わせてカスタムロジックを仕込みたいのなら、後述するTryConvertを使う必要がある。

さて、少しサンプルを書き換えて、以下のようにした。


class TryGetMemberSample : DynamicObject
{
    public int Hoge => 10;
    public string Piyo=>"hello";


    public override bool TryGetMember(GetMemberBinder binder, out object result)
    {
        if (binder.Name == "HogePiyo")
        {
            result = 42;

        }
        else if (binder.Name == "FooBar")
        {
            result = 114514;
        }
        else
        {
            result = 0;
        }

        return true;
    }
}

この場合、


private static void Main(string[] args)
{
	var d = (dynamic) new TryGetMemberSample();

	int i = d.Hoge;
	i = d.HogePiyo;

    i=d.Piyo;
}

このような呼び出しを行ったとき、
最初のd.Hogeの呼び出しは、TryGetMemberを経由せず、直接Hogeプロパティにアクセスするので注意が必要となる。

Hogeの場合は、型が一致してるので、問題ないけど、Piyoの場合、stringになってるのでコレはどうなるかというと、型の不一致から、TryGetMemberが呼ばれるわけでは無く、RuntimeBinderExceptionが飛んでくるので、この辺は注意が必要。

また、TryGetMemberが実行時に解決できない場合は、falseを返すことで、RuntimeBinderExceptionを発生させることが出来る。

個人的には、TryMemberで名前経由で値をディスパッチするクラスと、TryConvertで型をディスパッチするクラスを別にして、二段構えにする都割と良い感じかなと。

TryConvertで出来ること

さて、先のTryGetMemberでは、メンバ名を元にしたディスパッチが可能であることをみてきた。

ここでは、型を元に実行時に変換処理のカスタムロジックを書く方法をみていこう。同様に、以下のようなクラスを作った



class TryConvertSample : DynamicObject
{
    public override bool TryConvert(ConvertBinder binder, out object result)
    {
        if (binder.Type == typeof(int))
        {
            result = 42;
            return true;
        }

        if (binder.Type == typeof(double))
        {
            result = 42.195;
            return true;
        }

        if (binder.Type == typeof(string))
        {
            result = "hello world";
            return true;
        }

        result = default;
        return false;
    }
}

binder.Typeには、変換先の型が入っているので、こいつを元にして任意の変換を噛ませることが可能となる。
また、binder.Explicitには明示的に呼ばれたのならtrueそれ以外はfalseになるので、明示的なのか暗黙なのかで挙動を変化させることも可能。

TryConvertは非常に強力なカスタムロジックを仕込める反面、結構注意しないと色々とハマる部分も有るので、以下その辺のまとめ。

implicit/explicit operatorとの兼ね合い

先のTryGetValuと同様、明示的に書かれた方が優先される。
但し、explicit operatorが存在する場合は、implicitで読んだ場合は、TryConvertへ、explicitで読んだときはexplicit operatorへとディスパッチされ、逆にimplicit operatorが存在する場合は、explicitに呼んだ場合はTryConvertへ、implicitに呼んだときはimplicit operatorへとディスパッチされるので、この辺注意のほど。

T?の取り扱い

変換演算子を使った場合は、intなら、int?へも適用可能となりますが、先のサンプルのような場合は、厳密に型で一致させてるので、変換が出来なくなります。従って、Nullableにも対応させたいなら、それ相応の条件式を用意する必要がある。

変換先がinterfaceの場合

変換先がinterfaceの場合は、結構癖があるというか、明示的に呼ぶのか、暗黙で呼ぶのかでTryConvertメソッドが呼ばれるか否か変化することになる。
この点に関しては、先日ここら辺に書いているので、参考にしてもらえれば幸い。

TryGetMemberとTryConvertは単一のクラスでは実現不可能

このように、TryGetMemberではどのような型として評価されるのか判断できず、TryConvertによって初めて評価されると言うことがわかった。
なので、jsonやRDBMSへの動的マッピングにDynamicObjectを利用する際は、TryGetMemberを担当するクラスと、TryConvertを担当するクラスを分けるとすっきり表現できる。

最後のまとめとして、その簡単なサンプルを提示して、検証していこう。


using System;
using System.Collections.Generic;
using System.Dynamic;

namespace DynamicSample
{
	internal delegate bool CustomConverter(object convertFrom, out object convertTo, out Type convertToType);

	internal class DynamicValue : DynamicObject
	{
		private readonly CustomConverter _converter;
		private readonly object _value;

		public DynamicValue(object value, CustomConverter converter)
		{
			_value = value;
			_converter = converter;
		}

		public override bool TryConvert(ConvertBinder binder, out object result)
		{
			if (_converter(_value, out var to, out var type))
				if (binder.Type.IsAssignableFrom(type))
				{
					result = to;
					return true;
				}

			result = default;
			return false;
		}


		public static implicit operator int(DynamicValue value)
		{
			return (int) value._value;
		}

		public static implicit operator double(DynamicValue value)
		{
			return (double) value._value;
		}

		public static implicit operator string(DynamicValue value)
		{
			return (string) value._value;
		}
	}

	internal class MemberResolver : DynamicObject
	{
		private readonly IDictionary<string, object> _data;

		public MemberResolver(IDictionary<string, object> data)
		{
			_data = data;
		}

		public override bool TryGetMember(GetMemberBinder binder, out object result)
		{
			bool timeConverter(object from, out object to, out Type type)
			{
				if (from is long l)
				{
					to = new DateTime(l);
					type = typeof(DateTime);

					return true;
				}
				to = default;
				type = default;
				return false;
			}


			if (_data.TryGetValue(binder.Name, out result))
			{
				if (binder.Name.Contains("Time") && result is long)
					result = new DynamicValue(result, timeConverter);
				else
					result = new DynamicValue(result, null);


				return true;
			}

			return false;
		}
	}


	internal class Program
	{
		private static void Main(string[] args)
		{
			var data = new Dictionary<string, object>
			{
				["SomeTime"] = DateTime.Now.Ticks,
				["Hoge"] = 114514,
				["Bar"] = "hello world"
			};


			dynamic resolver = new MemberResolver(data);

			DateTime time = resolver.SomeTime;
			Console.WriteLine(time);

			int num = resolver.Hoge;
			Console.WriteLine(num);

			Console.WriteLine((string) resolver.Bar);
		}
	}
}

TryConvertですべて解決しようとすると、メソッドのサイズが肥大化するのと、パフォーマンス的にもあんまり良くないから、想定される変換は変換演算子を利用して、別途カスタムコンバータが存在するときのみTryConvertで解決するような形を取れば、悪くないかなと言うことでまとめてみた。

まとめ

dynamicは文字通り、実行時までバインドを遅延させることから、柔軟性がある反面、予想外の挙動を取ることが多く一般的なC#のコンテキストとは又別の注意がそれなりに必要だと思う。

同様に、DynamicObjectから派生したクラスは高度な柔軟性を持ち合わせている反面、特にTryConvertの挙動は一般的なC#のコンテキストが優先されることから思った通りの挙動をしてくれない危険性が常に存在することになるかなって。

今回の検証でそこら辺の解決の一助になってくれればと思いまとめてみました。

それでは早いですが、メリークリスマス!

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?