この記事は C# Advent Calendar 2012 の18日目の記事です。
C# はとても良い言語ですが、しばしば他の言語のあれ(プログラマブルにメソッドを追加する機能、コンパイル時にコード走らせる機能、構造的部分型、パターンマッチ、Immutableで辞書のキーになれる型を簡単に作れる仕組み、返り値の型でのメソッドのディスパッチ、本物のマクロ、等々)が欲しいなぁと思うことはよくあると思います。今日はそういうものの中でも言語の枠組みの中でなんとかできそうなダイナミックスコープについて、実現する方法を考えてみます。
ごく稀に「この処理の実行中だけ、この変数の値を一時的に別の値に変更したい」ということがあります。たとえば、HttpContext.Current.Userを参照するMVC4というライブラリがいて、Aさんでログイン中なんだけどBさんのつもりで処理をして欲しいときなんかが、まさにそうです。コードで書くとこんな感じです。
1 var originalUser = HttpContext.Current.User;
2 HttpContext.Current.User = tempUser;
3
4 // tempUser さんがログインしてるつもりでとても重要な処理を実施する。
5 SomeImportantProcess();
6
7 HttpContext.Current.User = originalUser;
この一見動きそうなコードですが重要な問題があります。それはSomeImportantProcess()の中で例外が投げられた場合、HttpContext.Current.UserはtempUserのままになってしまうという点です。これを修正してみます。
1 var originalUser = HttpContext.Current.User;
2 HttpContext.Current.User = tempUser;
3
4 try
5 {
6 // tempUser さんがログインしてるつもりでとても重要な処理を実施する。
7 SomeImportantProcess();
8 }
9 finally
10 {
11 HttpContext.Current.User = originalUser;
12 }
怠惰なプログラマの皆様におかれましてはもうやってらんない状況です。originalUserという変数が新たに出現するのもダサいですし、finallyを明示的に呼び出すなんて、よく訓練されたC#プログラマには我慢できません。
この手の「変数を一時的に別の値に置き換える」というのは広義のダイナミックスコープと言えます。であれば、ダイナミックスコープであると直接表現したいところです。理想的にはこんな感じです。
1 dynamicScope (HttpContext.Current.User = tempUser)
2 {
3 // tempUser さんがログインしてるつもりでとても重要な処理を実施する。
4 SomeImportantProcess();
5 }
残念ながらC#にはマクロが無いので、変わりにusing構文で妥協しましょう。丁度C#の世界にはTransactionScopeというものもありますし、そんな感じにできないものでしょうか。
1 using (DynamicScope(HttpContext.Current.User = tempUser))
2 {
3 // tempUser さんがログインしてるつもりでとても重要な処理を実施する。
4 SomeImportantProcess();
5 }
こんな感じですが、もうちょっとだけC#での実装に合わせて妥協します。
1 using (DynamicScope.Create(() => HttpContext.Current.User, tempUser))
2 {
3 // tempUser さんがログインしてるつもりでとても重要な処理を実施する。
4 SomeImportantProcess();
5 }
第一引数で変数を指定し、第二引数で一時的に上書きたい値を指定します。ギリギリ許せますね。
実装してみます。
1 public static class DynamicScope
2 {
3 public static IDisposable Create<U>(Expression<Func<U>> expression, U value)
4 {
5 return new DynamicScope<U>(expression, value);
6 }
7 }
8
9 /// <summary>
10 /// ダイナミックスコープをDisposableとして実現するクラス
11 /// </summary>
12 class DynamicScopeImpl<TValue> : IDisposable
13 {
14 Action ResetAction;
15
16 public DynamicScopeImpl(Expression<Func<TValue>> expression, TValue value)
17 {
18 var rhs = Expression.Parameter(typeof(TValue), "value");
19 var body = expression.Body;
20 var assign = Expression.Assign(body, rhs);
21 var actionExp = Expression.Lambda<Action<TValue>>(assign, rhs);
22
23 var action = actionExp.Compile();
24 var original = expression.Compile()();
25
26 action(value);
27
28 ResetAction = () => action(original);
29 }
30
31 public void Dispose()
32 {
33 if (ResetAction != null)
34 {
35 ResetAction();
36 ResetAction = null;
37 }
38 }
39 }
第一引数はrefでも良さそうに見えますが、refはクロージャで持ち回すことができなかったので、Expressionになっています。もしかしたらILをどうこうしたりExpressionを頑張ればrefでもいけるかもしれません。識者の登場が待たれます。
荒削りではありますがこれである程度安心してグローバル変数を上書きできるようになりました! エラー処理は読者への宿題とします!
マルチスレッド対応は……スレッドコンテキストスイッチをフックできればなんとかなりそうですが、そんなことできるのかしら?識者の方教えてください。
テストも書いてみます。
1 [TestMethod]
2 public void DynamicScopeTest_Object()
3 {
4 var x = Utility.CreateDefault<DefaultTestClass>();
5
6 Assert.AreEqual("hoge", x.Name);
7
8 using (Utility.DynamicScope(() => x.Name, "name"))
9 {
10 Assert.AreEqual("name", x.Name);
11 }
12
13 Assert.AreEqual("hoge", x.Name);
14 }
15
16 static int GlobalVariable = 5;
17
18 [TestMethod]
19 public void DynamicScopeTest_Global()
20 {
21 Assert.AreEqual(5, GlobalVariable);
22
23 using (Utility.DynamicScope(() => GlobalVariable, 6))
24 {
25 Assert.AreEqual(6, GlobalVariable);
26 }
27
28 Assert.AreEqual(5, GlobalVariable);
29 }
30
31 [TestMethod]
32 public void DynamicScopeTest_Local()
33 {
34 var now = DateTime.Now;
35 var localVar = now;
36
37 using (Utility.DynamicScope(() => localVar, now.AddMinutes(1)))
38 {
39 Assert.AreEqual(now.AddMinutes(1), localVar);
40 }
41
42 Assert.AreEqual(now, localVar);
43 }
44
45 [TestMethod]
46 public void DynamicScopeTest_MultipleDispose()
47 {
48 var now = DateTime.Now;
49 var localVar = now;
50
51 using (var scope = Utility.DynamicScope(() => localVar, now.AddMinutes(1)))
52 {
53 Assert.AreEqual(now.AddMinutes(1), localVar);
54 scope.Dispose();
55 Assert.AreEqual(now, localVar);
56 localVar = now.AddSeconds(5);
57 }
58
59 Assert.AreEqual(now.AddSeconds(5), localVar);
60 }
このテストを書いてみてわかることは、Expressionは無名関数と同様に環境を保持しているように見えるということです。どうなっているんでしょうか?謎です。