C#
Roslyn
C#小品集シリース

C#:Roslynを使って数式を計算するクラスを作成してみた

C#:逆ポーランド記法を利用した数式の計算(6)」では、逆ポーランド記法を利用して、数式の計算を行うコードを示しましたが、こんどはRoslyn使ってコードをコンパイルし、それを実行することで、数式を計算するクラスを作成してみました。

準備

準備として、Microsoft.CodeAnalysis.CSharp をNuGetからインストールします。
.csprojファイルには、以下のような記述が追加されます。

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="2.6.1" />
  </ItemGroup>

ちなみに、.NET Core2.0で動作を確認しています。たぶん、そのまま .NET Framework4.6 とかの環境でも動くと思います。

数式を計算するExpressionクラス

早速、数式を計算するExpressionクラスのコードを示します。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;

...

 public class Expression {

    private const string _sourceCode = @"
        using System;
        namespace Gushwell {{
            public class ExpressionCalculator {{
                public object Calculate() {{
                    return {0};
                }}
            }}
        }}";


    public static object Calculate(string exp) {
        var sourceCode = CreateSourceCode(exp);
        var compilation = CreateCompilation(sourceCode);
        return RunCode(compilation);
    }


    private static object RunCode(CSharpCompilation compilation) {
        using (var ms = new MemoryStream()) {
            EmitResult result = compilation.Emit(ms);                

            if (result.Success) {
                ms.Seek(0, SeekOrigin.Begin);
                var assembly = AssemblyLoadContext.Default.LoadFromStream(ms);
                var instance = assembly.CreateInstance("Gushwell.ExpressionCalculator");
                var type = assembly.GetType("Gushwell.ExpressionCalculator");
                var method = type.GetMember("Calculate").First() as MethodInfo;
                var ans = method.Invoke(instance, null);
                return ans;
            } else {
                IEnumerable<Diagnostic> failures = result.Diagnostics.Where(diagnostic =>
                    diagnostic.IsWarningAsError || diagnostic.Severity == DiagnosticSeverity.Error);
                string message = "";
                foreach (var diagnostic in failures) {
                    message += $"\t{diagnostic.Id}: {diagnostic.GetMessage()}";
                }
                throw new InvalidOperationException(message);
            }
        }
    }

    private static CSharpCompilation CreateCompilation(string sourceCode) {
        var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);

        string assemblyName = Path.GetRandomFileName();
        var references = new MetadataReference[] {
            MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location)
        };
        var compilation = CSharpCompilation.Create(
            assemblyName,
            syntaxTrees: new[] { syntaxTree },
            references: references,
            options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
        return compilation;
    }

    private static string CreateSourceCode(string exp) {
        return string.Format(_sourceCode, exp);
    }
}

簡単にやっていることを書くと、こんな感じ。

  1. C#のクラス定義のテンプレートコード(文字列)を用意
  2. 引数で与えられた式をこのテンプレートコードに埋め込む
  3. このコードをCSharpSyntaxTree.ParseTextで、パースする
  4. 3.でパースした結果を Emit APIで、アセンブリにコンパイルする(メモリストリームに生成)
  5. コンパイルした結果できたアセンブリをロード
  6. リフレクション使って、ロードしたクラスのメソッドを呼び出す

これで、数式を計算させています。

型はobjectにしていますので、必要ならば、呼び出した側で適切な型にキャストする必要があります。boxing発生してしまいますが、特定の型に固定してしまうのもどうなんだろう? ということで、objectとしました。

なお、このコードは、Compiling C# Code Into Memory and Executing It with Roslynを参考にしています。っていうか、ロジックは、ほぼそのまんまです。
これを、数式計算用として再利用しやすいようにクラスとしてきちんとコードにしたのが、このExpressionクラスです。

Expression.Calculateメソッドを呼び出してみる

Expression.Calculateメソッドを呼び出すテストコードを書いてみます。

public class Program {
    public static void Main(string[] args) {
        var x1 = Expression.Calculate("((1 + 2) * (3 + 4)) / 3");
        Console.WriteLine(x1);

        var x2 = Expression.Calculate("(1+2+3+4+5+6+7+8+9+10)/10.0");
        Console.WriteLine(x2);
    }
}

実行結果

7
5.5

と意図通りの結果が出ました。

独り言

ところで、これを作成している途中、

dotnet ExpSample.dll

って、実行すると以下のエラーが出てたんですが、いつのまにか出なくなってました。

CS0103: The name 'Console' does not exist in the current context

なぜ、このエラーがでたのか、どうしてエラーが消えたのか原因がわかりません。

確実にわかっているのは、動的にコンパイルするコードの中(ここでは、_sourceCodeの中)に、Console.WriteLine()呼び出すコードがあると、.NET Coreでは、このエラーが出るということです。

でも、Console.WriteLine()削ってもエラーが出ていたような気がするんだよな。
もしかしたら、それは気のせいかもしれません。今考えると、単にちゃんとリビルドされていなかっただけかな、という気もしています。