Roslyn for Scriptingで、あなたのアプリケーションにもC#スクリプトを!!

dotnet_logoいよいよ、Visual Studio 2015リリースが近づいてきました。今回はC#的にはあまり大がかりな拡張がありませんが、内情としてはC#コンパイラのインフラが「Roslyn」に正式対応するという事で、地味に大きな変更となっています。

Roslynは、MSのオープンソース戦略としては早い段階で公開され、それ以来、パブリックな場で将来のC#コンパイラの仕様検討などが行われています。勿論、ソースコードも「オープンソース」として公開されており、自分でいじりたければフォークも可能です。そろそろ概要を掴んでおこうと考えている方向けに、いくつかリンクを張っておきます。

なお、この記事には続編があります:「真・Roslyn for Scripting! あなたのアプリケーションにもC#スクリプトを!!」


ちょっとだけ背景

csc今まで、C#のコンパイラは「csc.exe」で、.NET Frameworkに付属していました。このプログラムはアンマネージコード(多分、C++)で書かれており、メンテナンスが大変そうだなと思います。また、Visual Studioが、ソースコードエディタ内でインテリセンスを表示させるために、csc.exeとは「別に」、独自にソースコードのパースを行っており、言語仕様の変化に合わせて二重にメンテナンスしなければならないという問題もありました。更に、Resharperなどのサードパーティプラグインも、この内部実装にはアクセスできないため、またもや同じようなパーサーを独自に書かなければならず、どう考えても不健全です。

そこで、C#のソースコードを解析し、いわゆる「構文木」をオブジェクト表現可能な形でパースする、ニュートラルな独立したライブラリを実装する事にした… というのがRoslynです。当初は「Compiler as a service」 (InfoQ)なワードだけが取りざたされて、何か大きなサービス的なもののように誤解されたこともありましたが、要するにコンパイラのための(低レベルな)インフラをライブラリ化して、様々な環境から共通利用出来るようにしたものです。

# なお、C#, C#と言ってますが、VB.netもRoslyn化されています。

さて、Roslynのパーサーの説明をしようとすると大変そうなので (;´Д`) この記事では「スクリプティング」に焦点を置きたいと思います。

所で、実はこの分野は、.NETのマルチプラットフォーム展開を行う「Mono」の方が先行していて、MonoのC#コンパイラは C#で書かれているので、既に同じようなインフラが存在する事になります。Monoのインフラを使ったスクリプティングインフラとして、「scriptcs」が存在します。このプロジェクトは、スクリプティングのモジュール化に必要な「スクリプトライブラリ」を作る事が出来たり、スクリプトから参照するライブラリをNuGetから取得(実行時に配置、即使用可能)出来たりと、スクリプト環境としての熟成を図っているようです。また、Roslynがリリースされたことで、scriptcsのインフラを、MonoかRoslynかで選択可能にしようとしています。

Monoプロジェクトは今後、.NET Coreプロジェクトと相互に情報交換して、.NET純正のソースコードのMonoへの取り込みや、Monoの相互運用性(Linux対応など)の.NETへの取り込みを行う事を表明しています。また、C#コンパイラのインフラについては、Roslynベースに統一する方針のようです。


スクリプティング・ホスティング

自分で書いた何らかのアプリケーションに、スクリプティング機能を載せたいと思ったことはありませんか? 例えば、何らかのゲームを作っているとして、このゲームにC#のスクリプティング機能を搭載出来たら、アドベンチャーゲームやロールプレイングゲームのシナリオ進行、シミュレーションゲームの敵AI調整に C# のスクリプトが使えるようになるのです。ゲームの世界ではluaやpython辺りをよく聞きますね。でも、本体をC#で書いているなら、スクリプトもC#で書きたいものです。

別の例として、Officeを挙げます。Officeには「Visual Basic for Application」という、VBチックなスクリプト環境があります。が、VBA、書きたくないです… VBAは徐々にフェードアウトの方向になっていると思いますが、代わりにC#でスクリプトが書けたら、もうちょっとOfficeに前向きになりそうです(Excel向けであれば、商用プラグインが既にあります:FCell)。MicrosoftはOfficeでC#を使えるようにするか? は、まだまだ分かりませんが、Roslynが公開されたことで、その可能性は高くなったと思います。

Roslynのコアは、C#のパーサーライブラリです。そして、極めて依存性が低く設計されています(Portable class library化されています)。が、パースしても構文木が得られるだけで、これが実行可能になるわけではありません。この構文木を食わせて、スクリプトとして実行可能にするインフラが「Roslyn for scripting」です。

流れは以下のようになります。

Roslyn Roslyn for scripting
スクリプト(文字列) → 構文木 → IL変換(コンパイル) → 実行

Roslyn for scriptingは、構文木をコンパイルしてIL命令に変換し、更に実行まで行います。IL変換は、System.Reflection.Emitを使用して動的にILを生成します。Emitは環境によって使用出来ない(例:ストアアプリ環境であるWinRTではEmitは使えない)ため、Roslyn本体には含めなかったのだと推察しています。現在のところ、Roslyn for scriptingは.NET4.5以上の環境でのみ使用可能です。

話が複雑になってきましたか? いやいや、Roslyn for scriptingは、ものすごく簡単に使えます!


Hello! C# scripting!!

roslyn1Roslyn for scriptingを使ってスクリプト環境を作る場合、殆どRoslynを意識する必要はありません。とりあえずHello worldをやってみましょう。.NET 4.5以上のコンソールアプリケーションのプロジェクトを作ってください。次に、NuGetで以下のパッケージを導入します。まだ正式版ではないので、「リリース前のパッケージを含める」を選択して下さい。

# もうRCでGoLiveなので、インターフェイスに大きな変更は無いと思われます。
# 最近、NuGetの調子が悪いようなので、上記パッケージが一覧に表示されない場合は、パッケージマネージャコンソールから “Install-Package” コマンドでインストールすると確実です。

導入出来たら、Mainメソッドに以下のようにRoslyn for scriptingの実装を書きます。

using Microsoft.CodeAnalysis.Scripting.CSharp;

namespace HelloRoslynForScripting
{
	class Program
	{
		static void Main(string[] args)
		{
			// Console.WriteLine("Hellow C# scripting!");
			var script = CSharpScript.Create(
				"Console.WriteLine(\"Hello C# scripting!\");");
			script.Run();
		}
	}
}

roslyn2これだけです! さぁ、実行してみましょう!! 簡単ですね! 簡単過ぎて、応用性が無いのでは? と思うかもしれませんが、大丈夫です。追々分かると思います。

ところで、ソースコードの先頭にコメントを入れておきましたが、興味深い事に気が付きましたか?

Consoleクラスは、本来はSystem名前空間に存在しますが、指定していません。これは、事前に「using System;」相当の定義が組み込まれているからです。
また、このコード自体、名前空間の宣言も、クラス宣言も、メソッド宣言もありません。スクリプト環境でこれらを書くことももちろん出来ますが、スクリプト環境ではこれらを省略可能にしているのです。そのため、いきなり実装本体が来ても、全く問題ありません。


スクリプト環境固有の課題

上記のように、スクリプティングの世界では、単にソースコードをリアルタイムで解析・実行するだけではなく、普通のソースコードとは異なる状況に対処する必要があります。以下にそのような課題を挙げます。

  • 記述の省略を可能にする
  • 逐次処理を可能にする
  • ホスト環境と通信する

記述の省略を可能にする

前述のように、スクリプト環境では色々省略して記述出来た方が便利です。ホストする側で、事前にusingを成立させておくことが出来ます。「System」名前空間は既定で定義されていますが、独自の名前空間も定義しておくことが出来ます。そのためには、事前にScriptOptionsクラスを用意して、オプションとして指定します。

using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.Scripting.CSharp;

namespace HelloRoslynForScripting
{
	class Program
	{
		static void Main(string[] args)
		{
			// using System.Collection.Generic; 相当
			var options = new ScriptOptions().
				WithNamespaces(
					"System.Collections.Generic").
				WithReferences(
					typeof(object).Assembly);

			// var list = new List<string> { "ABC", "DEF" }; System.Console.WriteLine(string.Join(",", list));
			var script1 = CSharpScript.Create(
				"var list = new List<string> { \"ABC\", \"DEF\" }; System.Console.WriteLine(string.Join(\",\", list));",
				options);
			script1.Run();
		}
	}
}

ScriptOptionsや他のクラスもそうですが、Roslynでは「イミュータブルオブジェクト」を全面的に採用しています。イミュータブルオブジェクトとは、文字列(System.String)や日付構造体(System.DateTime)のように、インスタンスの内容が不変である事を保証した実装の事です。一度生成されたインスタンスは、中身が変更されることはありません。

上記の例では「WithNamespaces」メソッドを呼び出していますが、これによって、最初に生成したScriptOptionsクラスのインスタンスを変更するのではなく、新たなScriptOptionsクラスのインスタンスを生成しています。以下がWithNamespacesの定義です。

/// <summary>
/// Creates a new <see cref="T:Microsoft.CodeAnalysis.Scripting.ScriptOptions"/> with the namespaces changed.
/// </summary>
public ScriptOptions WithNamespaces(IEnumerable<string> namespaces)
{
	// (内部実装)
}

このように、WithNamespacesを呼び出すと、名前空間をオプションとして追加した「新しいScriptOptions」を生成し、戻り値として返却します。そのため、単にWithNamespacesを呼び出しただけでは、元のインスタンスには何も変更が加わっていない事になるので注意が必要です。

roslyn3さて、名前空間の定義は「WithNamespaces」メソッドで行いますが、ScriptOptionsの定義を空のインスタンスから始めた(new ScriptOptions())ので、mscorlibへの参照を追加しておく必要があります。それが「WithReferences」メソッドです。

なお、最初の例でオプションを省略しましたが、省略時はmscorlibへの参照と、System名前空間の定義だけが行われた状態になっています。

こうして必要な定義を追加したオプションを生成出来れば、あとは、CSharpScript.Createの引数にオプションを渡すことで、これらの定義が解釈されます。一般的には、「System」「System.Collections.Generic」「System.Linq」「System.Threading.Tasks」などが定義されていた方が、利便性が向上するでしょう。あるいは、これらの定義をホスト側のApp.configや設定ファイルから読み込むようにすれば、更に柔軟性が高くなります。


逐次処理を可能にする

スクリプト環境では、ユーザーがコンソールからインタラクティブにコードを記述する可能性があります。例えば、コマンドプロンプト(cmd.exe)やPowerShellでは、ユーザーがコマンドラインから入力した命令やコードが「Enter」キーの押下で、即実行されます。と言う事は、コンパイル実行の場合は、一度にすべてのコードを評価しなければならないのが、ちょっとづつ、断片的にソースコードが渡される可能性がある事を意味します。この状況を、とりあえず簡単に記述したのが以下の例です。

using Microsoft.CodeAnalysis.Scripting.CSharp;

namespace HelloRoslynForScripting
{
	class Program
	{
		static void Main(string[] args)
		{
			var script1 = CSharpScript.Create(
				"Console.WriteLine(\"Hello C# scripting for first line, and continue...\");");
			script1.Run();

			var script2 = CSharpScript.Create(
				"Console.WriteLine(\"Hello C# scripting for next line!\");");
			script2.Run();
		}
	}
}

単に、CSharpScriptのインスタンスを2回生成して実行しているだけです。これは問題ないでしょう。しかし、スクリプトコードの入力をユーザーがインタラクティブに実行している場合、もっと複雑な問題が発生する可能性があります:

using Microsoft.CodeAnalysis.Scripting.CSharp;

namespace HelloRoslynForScripting
{
	class Program
	{
		static void Main(string[] args)
		{
			var script1 = CSharpScript.Create(
				"var data = 123;");
			script1.Run();

			var script2 = CSharpScript.Create(
				"Console.WriteLine(\"Hello C# scripting with data: {0}\", data);");

			// "(1, 56): error CS0103: The name 'data' does not exist in the current context"
			script2.Run();
		}
	}
}

roslyn4コードは名前空間・クラス・メソッドなどの定義が無くても解釈されるのでしたよね? いきなり変数「data」が定義されていますが、スクリプトとしては問題ありません。問題はscript2です。このスクリプトで、data変数を参照しようとしていますが、コンパイルエラーが発生しています。

これは、script1とscript2が、相互に全く関係のないスクリプトとして実行しているためです。勿論、そのように意図している場合は問題ありませんが、インタラクティブに実行する場合は、このようにコードが分断されてしまう可能性は十分考えられます。

では、どうすれば良いでしょうか? script1で定義されたdata変数をscript2で参照可能にするには、「ScriptState」を使います。

using Microsoft.CodeAnalysis.Scripting.CSharp;

namespace HelloRoslynForScripting
{
	class Program
	{
		static void Main(string[] args)
		{
			var script1 = CSharpScript.Create(
				"var data = 123;");
			var script1State = script1.Run();

			var script2 = CSharpScript.Create(
				"Console.WriteLine(\"Hello C# scripting with data: {0}\", data);");
			script2.Run(script1State);
		}
	}
}

CSharpScript.Runメソッドの戻り値は、ScriptStateです。この中には、スクリプトを実行した結果や、スクリプトとして記憶しておくべき様々な情報が格納されています。これを、次のスクリプト実行時のRunメソッドの引数に渡すと、スクリプトはScriptStateの記憶を前提とした環境で動作します。この中にはdata変数の結果も格納されているので、script2も問題無く実行出来るようになるのです。

さぁ、もう、かなり自分のアプリケーションに組み込める気がしてきましたよね?! 次の問題が片付けば、大抵の環境に組み込んで、すぐに応用が可能ですよ!


ホスト環境と通信する

ここまでで、独立したスクリプトエンジンとして問題なく操作できるようになったはずですが、現実には、スクリプト側のコードから、ホスト環境のオブジェクトを操作したりしたい訳です。でなければ、スクリプト環境を組み込む意味がありませんよね。

前述のCSharpScript.Runメソッドの引数の定義を見て、疑問に思ったかもしれません。

/// <summary>
/// Runs this script.
/// </summary>
/// <param name="globals">An object instance whose members can be accessed by the script as global variables, or a <see cref="T:Microsoft.CodeAnalysis.Scripting.ScriptState"/> instance that was the output from a previously run script.</param>
/// <returns>
/// A <see cref="T:Microsoft.CodeAnalysis.Scripting.ScriptState"/> that represents the state after running the script, including all declared variables and return value.
/// </returns>
public ScriptState Run(object globals = null)
{
	// (内部実装)
}

Runメソッドの引数は「object」です。ここに渡していたのはScriptStateクラスのインスタンスなので、わざわざobjectと定義する意味が分からないかもしれません。実は、この引数にはScriptStateだけではなく、任意のクラスのインスタンスが渡せます。以下に例を示します:

using System;
using System.Linq;

using Microsoft.CodeAnalysis.Scripting.CSharp;

namespace HelloRoslynForScripting
{
	public sealed class HostObject
	{
		public int Value;

		public string CreateValues(int count)
		{
			var r = new Random();
			return string.Join(",", Enumerable.Range(0, count).Select(index => r.Next()));
		}
	}

	class Program
	{
		static void Main(string[] args)
		{
			var hostObject = new HostObject { Value = 123 };

			var script1 = CSharpScript.Create(
				"Console.WriteLine(\"Hello C# scripting with host object: Value={0}, Values=[{1}]\", Value, CreateValues(10));");
			script1.Run(hostObject);
		}
	}
}

ホスト側で用意した「HostObject」クラスのインスタンスを渡し、スクリプト側からアクセスしています。面白い事に、スクリプトからはグローバルな無名の要素としてアクセス出来ます。そのため、「Value」フィールドや「CreateValues」メソッドに、インスタンスやクラスの指定がありません。

roslyn5Runの引数に渡せるインスタンスは、クラスである必要があります(構造体はダメです。勿論、クラスのメンバーに構造体があっても問題ありません)。また、クラスはパブリックでなければなりません。恐らく、スクリプトがコンパイルされた際に、クラスのメタデータにアクセスする必要があるからでしょう。


残る課題

ここまで分かれば、後は応用だけです。スクリプト環境として考えられる他の課題は、scriptcsを見てみると分かるかもしれません。

  • インタラクティブな指令で、アセンブリ参照を追加する
  • インタラクティブな指令で、スクリプトをファイルとしてロードする
  • インタラクティブな指令で、NuGetなどのパッケージシステムをサポートする

つまり、インタラクティブなコンソールがコードの入力となる場合は、帯域外の指令で、ホストがスクリプティングのサポートをする必要があります。scriptcsでは、このような独自コマンドを定義(先頭が”:”で始まるコマンドが入力された場合は、スクリプトコードではなく独自に処理を行う。例えば”:load”でスクリプトライブラリをロードする)して対処しています。

さあ、これでC#スクリプトで何でも出来るでしょう!