読者です 読者をやめる 読者になる 読者になる

アサーションのメッセージ用文字列をいい感じに組み立てる

C#

アサーションに引っかかったときに出すメッセージの構築って面倒ですよね。
Javaアサーションが文法に組み込まれているくせに、アサーションの評価に使った式を表示してくれもしないという・・・
でも C# は文法にさえ組み込まれていないのでもっとアレ・・・
いやいや、C# には式木というものがあるのですよ。
これを使ってメッセージ用の文字列をいい感じに組み立てるアサーション用のメソッドを作ってみました。まだ途中だけど。


以下コードが微妙に長いので注意。

使い方

使い方は Debug.Assert とはちょっと違っていて、

Contract.Assert(() => a != null);

のようにラムダ式を渡します。
で、このアサーションに引っかかると、

契約「a != null」に違反しました。

のようなメッセージになります。


ちなみに、式木を使っているのでインクリメントとかデクリメントとかあと色々と使えません。
まぁ、アサーションの条件部分に副作用入れるとかもうなんか色々とアレなのでそこは全然いいのですが。


あ、あと面倒なので実装していない機能もあります。
インスタンス生成とか、メソッド呼び出しとか、そこら辺は実装してません。
まぁ、コード読んでもらえば大体分かるんじゃないかと。

本体

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq.Expressions;

namespace ExpressionTreeAssert
{
    public static class Contract
    {
        [Conditional("DEBUG")]
        public static void Assert(Expression<Func<bool>> cond, string detail)
        {
            if (cond.Compile()())
                return;

            Debug.Assert(false, CreateMessage(cond), detail);
        }

        [Conditional("DEBUG")]
        public static void Assert(Expression<Func<bool>> cond)
        {
            if (cond.Compile()())
                return;

            Debug.Assert(false, CreateMessage(cond));
        }

        internal static string CreateMessage(Expression<Func<bool>> expr)
        {
            return string.Format("契約「{0}」に違反しました。", Expand(expr.Body, 0));
        }

        private sealed class Holder
        {
            internal string Operator;
            internal int Priority;
            internal Func<Expression, string, int, string> Expander;
            internal string Expand(Expression expr, int minPriority)
            {
                return Expander(expr, Operator, minPriority);
            }
        }

        static readonly Dictionary<ExpressionType, Holder> ops = new Dictionary<ExpressionType, Holder>()
        {
            { ExpressionType.Equal,             new Holder() { Priority = 94, Operator = "==", Expander = ExpandBinOp } },
            { ExpressionType.NotEqual,          new Holder() { Priority = 94, Operator = "!=", Expander = ExpandBinOp } },
            { ExpressionType.LessThanOrEqual,   new Holder() { Priority = 95, Operator = "<=", Expander = ExpandBinOp } },
            { ExpressionType.GreaterThanOrEqual,new Holder() { Priority = 95, Operator = ">=", Expander = ExpandBinOp } },
            { ExpressionType.LessThan,          new Holder() { Priority = 95, Operator = "<", Expander = ExpandBinOp } },
            { ExpressionType.GreaterThan,       new Holder() { Priority = 95, Operator = ">", Expander = ExpandBinOp } },
            { ExpressionType.TypeAs,            new Holder() { Priority = 95, Operator = "as", Expander = ExpandCastOp } },
            { ExpressionType.TypeIs,            new Holder() { Priority = 95, Operator = "is", Expander = ExpandTypeBinOp } },

            { ExpressionType.Add,               new Holder() { Priority = 97, Operator = "+", Expander = ExpandBinOp } },
            { ExpressionType.Subtract,          new Holder() { Priority = 97, Operator = "-", Expander = ExpandBinOp } },
            { ExpressionType.Multiply,          new Holder() { Priority = 98, Operator = "*", Expander = ExpandBinOp } },
            { ExpressionType.Divide,            new Holder() { Priority = 98, Operator = "/", Expander = ExpandBinOp } },
            { ExpressionType.Modulo,            new Holder() { Priority = 98, Operator = "%", Expander = ExpandBinOp } },
            { ExpressionType.AddChecked,        new Holder() { Priority = 97, Operator = "+", Expander = ExpandBinOp } },
            { ExpressionType.SubtractChecked,   new Holder() { Priority = 97, Operator = "-", Expander = ExpandBinOp } },
            { ExpressionType.MultiplyChecked,   new Holder() { Priority = 98, Operator = "*", Expander = ExpandBinOp } },
            
            { ExpressionType.And,               new Holder() { Priority = 93, Operator = "&", Expander = ExpandBinOp } },
            { ExpressionType.ExclusiveOr,       new Holder() { Priority = 92, Operator = "^", Expander = ExpandBinOp } },
            { ExpressionType.Or,                new Holder() { Priority = 91, Operator = "|", Expander = ExpandBinOp } },
            { ExpressionType.AndAlso,           new Holder() { Priority = 90, Operator = "&&", Expander = ExpandBinOp } },
            { ExpressionType.OrElse,            new Holder() { Priority = 89, Operator = "||", Expander = ExpandBinOp } },
            { ExpressionType.Conditional,       new Holder() { Priority = 87, Operator = "?:", Expander = ExpandCondOp } },
            { ExpressionType.Coalesce,          new Holder() { Priority = 86, Operator = "??", Expander = ExpandBinOp } },
            
            { ExpressionType.LeftShift,         new Holder() { Priority = 96, Operator = "<<", Expander = ExpandBinOp } },
            { ExpressionType.RightShift,        new Holder() { Priority = 96, Operator = ">>", Expander = ExpandBinOp } },

            { ExpressionType.Not,               new Holder() { Priority = 99, Operator = "!", Expander = ExpandUnOp } },
            { ExpressionType.UnaryPlus,         new Holder() { Priority = 99, Operator = "+", Expander = ExpandUnOp } },
            { ExpressionType.Negate,            new Holder() { Priority = 99, Operator = "-", Expander = ExpandUnOp } },
            { ExpressionType.NegateChecked,     new Holder() { Priority = 99, Operator = "-", Expander = ExpandUnOp } },
            { ExpressionType.Convert,           new Holder() { Priority = 99, Operator = "(T)", Expander = ExpandCastOp } },
            { ExpressionType.ConvertChecked,    new Holder() { Priority = 99, Operator = "(T)", Expander = ExpandCastOp } },

            { ExpressionType.ArrayLength,       new Holder() { Priority = 100, Operator = ".Length", Expander = ExpandUnOpReverse } },
            { ExpressionType.ArrayIndex,        new Holder() { Priority = 100, Operator = "[]", Expander = ExpandArrayAccess } },
        };

        static string ExpandUnOp(Expression expr, string op, int minPriority)
        {
            return ExpandOp<UnaryExpression>(
                expr,
                minPriority,
                (unExpr, p) => op + Expand(unExpr.Operand, p));
        }

        static string ExpandUnOpReverse(Expression expr, string op, int minPriority)
        {
            return ExpandOp<UnaryExpression>(
                expr,
                minPriority,
                (unExpr, p) => Expand(unExpr.Operand, p) + op);
        }

        static string ExpandBinOp(Expression expr, string op, int minPriority)
        {
            return ExpandOp<BinaryExpression>(
                expr,
                minPriority,
                (binExpr, p) => string.Format("{0} {1} {2}", Expand(binExpr.Left, p), op, Expand(binExpr.Right, p)));
        }

        static string ExpandTypeBinOp(Expression expr, string op, int minPriority)
        {
            return ExpandOp<TypeBinaryExpression>(
                expr,
                minPriority,
                (binExpr, p) => string.Format("{0} {1} {2}", Expand(binExpr.Expression, p), op, binExpr.TypeOperand.Name));
        }

        static string ExpandCastOp(Expression expr, string op, int minPriority)
        {
            return ExpandOp<UnaryExpression>(
                expr,
                minPriority,
                (unExpr, p) => string.Format(op == "(T)" ? "({1}){0}"
                                                         : "{0} as {1}", Expand(unExpr.Operand, p), unExpr.Type.Name));
        }

        static string ExpandCondOp(Expression expr, string op, int minPriority)
        {
            return ExpandOp<ConditionalExpression>(
                expr,
                minPriority,
                (condExpr, p) => string.Format("{0} ? {1} : {2}", Expand(condExpr.Test, p), Expand(condExpr.IfTrue, p), Expand(condExpr.IfFalse, p)));
        }

        static string ExpandArrayAccess(Expression expr, string op, int minPriority)
        {
            return ExpandOp<BinaryExpression>(
                expr,
                minPriority,
                (binExpr, p) => string.Format("{0}[{1}]", Expand(binExpr.Left, p), Expand(binExpr.Right, p)));
        }

        static string ExpandOp<T>(Expression expr, int minPriority, Func<T, int, string> body) where T : Expression
        {
            var opHolder = ops[expr.NodeType];
            if (opHolder.Priority < minPriority)
            {
                minPriority = opHolder.Priority;
                return string.Format("({0})", body((T)expr, minPriority));
            }
            else
            {
                minPriority = opHolder.Priority;
                return body((T)expr, minPriority);
            }
        }

        static string Expand(Expression expr, int minPriority)
        {
            if (ops.ContainsKey(expr.NodeType))
            {
                var op = ops[expr.NodeType];
                return op.Expand(expr, minPriority);
            }
            switch (expr.NodeType)
            {
                case ExpressionType.Constant:
                    return (((ConstantExpression)expr).Value ?? "null").ToString();
                case ExpressionType.MemberAccess:
                    if (((ConstantExpression)((MemberExpression)expr).Expression).Value.GetType().FullName.Contains("+<>c__DisplayClass"))
                        return ((MemberExpression)expr).Member.Name;
                    return string.Format("{0}.{1}", Expand(((MemberExpression)expr).Expression, minPriority), ((MemberExpression)expr).Member.Name);
                default:
                    throw new ArgumentException(expr.NodeType.ToString() + " is not supported.", "expr");
            }
        }
    }
}

・・・Expand メソッドがとても中途半端ですね。しかも汚い。
まぁいいや。

テスト

NUnit の TestCaseSource 属性のうち、Type オブジェクトを引数に取る方を使ってみました。
なにげにこれ便利だな。

using System;
using System.Collections;
using System.Linq.Expressions;
using NUnit.Framework;

namespace ExpressionTreeAssert
{
    [TestFixture]
    class ContractScenario
    {
        [TestCaseSource(typeof(ContractScenario), "TestCases")]
        public string CreateMessage(Expression<Func<bool>> input)
        {
            return Contract.CreateMessage(input);
        }

        public static IEnumerable TestCases
        {
            get
            {
                yield return TestCaseData(() => true).Returns("契約「True」に違反しました。");
                yield return TestCaseData(() => false).Returns("契約「False」に違反しました。");

                // コンパイル時定数は最適化されて展開されるっぽい
                yield return TestCaseData(() => 1 == 2).SetName("CreateMessage(() => 1 == 2)")
                                                       .Returns("契約「False」に違反しました。");

                // 変数を使った単純なパターン
                var a = 1;
                var b = 2;
                yield return TestCaseData(() => a == b).SetName("CreateMessage(() => a == b)")
                                                       .Returns("契約「a == b」に違反しました。");
                yield return TestCaseData(() => b == a + a).SetName("CreateMessage(() => b == a + a)")
                                                           .Returns("契約「b == a + a」に違反しました。");
                yield return TestCaseData(() => b == 2 * a).SetName("CreateMessage(() => b == 2 * a)")
                                                           .Returns("契約「b == 2 * a」に違反しました。");

                // ちょっと複雑なパターン
                yield return TestCaseData(() => b == a + a * 2).SetName("CreateMessage(() => b == a + a * 2)")
                                                               .Returns("契約「b == a + a * 2」に違反しました。");
                yield return TestCaseData(() => b == (a + a) * 2).SetName("CreateMessage(() => b == (a + a) * 2)")
                                                                 .Returns("契約「b == (a + a) * 2」に違反しました。");

                // 条件演算子
                yield return TestCaseData(() => a == b ? true : false).SetName("CreateMessage(() => a == b ? true : false)")
                                                                      .Returns("契約「a == b ? True : False」に違反しました。");

                object obj = a;
                // キャスト
                yield return TestCaseData(() => obj is int).SetName("CreateMessage(() => obj is int)")
                                                           .Returns("契約「obj is Int32」に違反しました。");
                yield return TestCaseData(() => obj as string == null).SetName("CreateMessage(() => obj as string == null)")
                                                                      .Returns("契約「obj as String == null」に違反しました。");
                yield return TestCaseData(() => (bool)obj).SetName("CreateMessage(() => (bool)obj)")
                                                          .Returns("契約「(Boolean)obj」に違反しました。");

                // 単項式
                yield return TestCaseData(() => !(a == b)).SetName("CreateMessage(() => !(a == b))")
                                                          .Returns("契約「!(a == b)」に違反しました。");

                // 配列
                var nums = new[] { 1 };
                yield return TestCaseData(() => nums.Length == 1).SetName("CreateMessage(() => nums.Length == 1)")
                                                                 .Returns("契約「nums.Length == 1」に違反しました。");
                yield return TestCaseData(() => nums[0] == 1).SetName("CreateMessage(() => nums[0] == 1)")
                                                             .Returns("契約「nums[0] == 1」に違反しました。");
            }
        }

        static TestCaseData TestCaseData(Expression<Func<bool>> expr)
        {
            return new TestCaseData(expr);
        }
    }
}