C++からC#をスクリプトとして実行

この記事は 「プロ生ちゃん Advent Calender 2016」 6日目の記事です。

はじめに

ゲーム開発で C++ のコードからスクリプト言語を利用するとき Lua や AngelScript といった選択肢がありますが、そーいえば C# 自身が cs ファイルのコンパイル機能を持ってたなーと思い、テスト的に作ってみて COM を作ったりだとか面倒だった構成をどこまでシンプルにできるか挑戦してみました。

ここでは最適化の過程は省略して、今回得られた手順について説明していきます。

1. CLI でスタティックライブラリを作る

cs ファイルのコンパイル機能は C# の機能のため C++ からは直接呼び出せません。そこで、CLI で作ったライブラリを中継させます。CLI をスタティックライブラリにするのは正規の使い方から外れている気がしますが、動けばよしという方針でやっていきます。

とりあえず空の CLR プロジェクト (.Net Framework 4) を作ります。
プロジェクト名は LibCli としておきました。

プロジェクトのプロパティを開いて、
構成プロパティ >> 全般 >> 構成の種類
を アプリケーション (.exe) から スタティックライブラリ(.lib) に変えます。

cs スクリプトのコンパイル&実行を行うコードを書きます。

// LibCli.h - LibCli
#pragma once

// cs スクリプトのコンパイル&実行
_declspec(dllexport) void LibCliMain ();
// LibCli.cpp - LibCli
#include "LibCli.h"
#include "IScript.h"

using namespace System;
using namespace System::Reflection;
using namespace System::CodeDom::Compiler;
using namespace System::Collections::Generic;
using namespace System::Windows::Forms;
using namespace Microsoft::CSharp;
using namespace LibCli;

// 以下の3つを参照に追加するべし
// Microsoft.CSharp
// System
// System.Windows.Forms

void LibCliMain ()
{
	// cs スクリプトのコンパイルパラメータ
	CompilerParameters^ params = gcnew CompilerParameters();
	params->GenerateInMemory = true;
	params->IncludeDebugInformation = false;
	params->ReferencedAssemblies->Add(Assembly::GetEntryAssembly()->Location);
	params->ReferencedAssemblies->Add("System.Windows.Forms.dll");

	// .Net Framework のバージョン
	Dictionary<String^, String^>^ providerOptions = gcnew Dictionary<String^, String^>();
	providerOptions->Add("CompilerVersion", "v4.0");

	// cs ファイル名
	array<String^>^ sources = { System::IO::File::ReadAllText("TestScript.cs") };

	// コンパイル!
	CSharpCodeProvider^ codeProvider = gcnew CSharpCodeProvider();
	CompilerResults^ compilerResults = codeProvider->CompileAssemblyFromSource(params, sources);

	// エラーがあれば出しとく
	for each (CompilerError^ error in compilerResults->Errors)
	{
		System::Windows::Forms::MessageBox::Show(error->ErrorText);
	}

	// コンパイルしたアセンブリを取得
	Type^ type = compilerResults->CompiledAssembly->GetType("Test.TestScript");
	IScript^ testScript = (IScript^)Activator::CreateInstance(type);

	// 実行!
	testScript->Execute();
}
// IScript.h - LibCli
#pragma once

namespace LibCli
{
	/// <summary>
	/// スクリプトのインターフェース。
	/// </summary>
	public interface class IScript
	{
	public:
		/// <summary>
		/// 何かしらの処理
		/// </summary>
		virtual bool Execute ();
	};
}

ヘッダに CLI 形式のコードが紛れていると C++ の方でエラーになってしまうので cpp とヘッダはきちんと分離しておきます。
IScript は cs スクリプトで実装するインターフェースです。
cs スクリプトのファイル名は “TestScript.cs” 、クラス名は “Test.TestScript” と決め打ちにしています。

あと、この3つのコードを追加しただけではビルドが失敗します。コメントに書いた
Microsoft.CSharp
System
System.Windows.Forms
をプロジェクトの参照に追加してからビルドします。

ここまでやってビルドが通らないときは .Net Framework が 4 意外になっている疑いがあります。

2. C++ で exe を作る

C++ で exe を作って、CLI のライブラリを組み込みます。
ソリューションに Win32 コンソールアプリケーションを新規追加します。
アプリケーション設定は、コンソールアプリケーションと、空のプロジェクトにチェック。Security Development Lifecycle のチェックは外した状態にします。
プロジェクト名は CppMain にしました。

CLI で作ったスタティックライブラリをリンクするようにします。
追加した CppMain のプロジェクト設定を開いて
構成プロパティ >> リンカー >> 入力 >> 追加の依存ファイル
に ..\$(Configuration)\LibCli.lib を追加します。
$(Configuration) はマクロで、ビルド構成に合わせて Debug や Release に置換されます。

LibCli.lib は LibCli をビルドした後に生成されるので、LibCli をビルドした後に CppMain をビルドするようにします。
CppMain のプロジェクトを右クリックして
ビルド依存関係 >> プロジェクト依存関係
を選択し、LibCli にチェックを入れます。

プロジェクトの設定は以上で、残りは CLI で書いた関数の呼び出しコードを書くだけです。

// main.cpp - CppMain
#include "..\LibCli\LibCli.h"

#include <cstdlib>

int main (void)
{
	LibCliMain();

	system("Pause");

	return 0;
}

CppMain をスタートアッププロジェクトに設定して、ビルドして、実行して、例外が出れば成功です。

3. cs スクリプトを書く

さきほどの例外は cs スクリプトを書いてなかったせいです。cs スクリプトさえ書いてやれば問題なく動きます。

// TestScript.cs
using System.Windows.Forms;
using LibCli;

namespace Test
{
	public class TestScript : IScript
	{
		public bool Execute ()
		{
			MessageBox.Show("プロ生ちゃんマジ天使!");

			return true;
		}
	}
}

これを CppMain プロジェクトのフォルダ、main.cpp と同じ所に置いて実行すると cs スクリプトに書いたメッセージボックスが出るようになります。

ここまで作業を行ったソリューションファイルです -> CsScriptTest_0.zip

4. cs スクリプトから C++ の関数を呼び出す

今までの説明で cs スクリプトのビルド&実行が最低限動くようになりましたが、cs スクリプトから C++ の関数の呼び出しができないと実用性が薄れるのでそこも試しにやってみます。Lua や AngelScript でいうバインディングってやつですね。

関数を共有するためには CppMain と LibCli から参照する新しいプロジェクトを追加するのが正攻法ですが、今回は最小限に抑えるため LibCli に共有インターフェースを追加してしまいます。

// SharedInterface.h - LibCli
#pragma once

class SharedInterface
{
public:
	virtual int SomeFunction (int a, int b) = 0;
};
// SharedWrapper.cs - LibCli
#pragma once

#include "SharedInterface.h"

namespace LibCli
{
	public ref class SharedWrapper
	{
	public:
		SharedWrapper (SharedInterface* pSharedData)
			: m_pSharedData(pSharedData)
		{
		};

		int SomeFunction (int a, int b)
		{
			return m_pSharedData->SomeFunction(a, b);
		};

	private:
		SharedInterface* m_pSharedData;
	};
}

SharedInterface は CppMain と LibCli で共有するインターフェース。SharedWrapper は C# から SharedInterface を使うためのラッパークラスです。

SharedInterface を利用するように LibCli を書き換えます。

// IScript.h - LibCli
#pragma once

#include "SharedWrapper.h"  // <= 追加

namespace LibCli
{
	/// <summary>
	/// スクリプトのインターフェース。
	/// </summary>
	public interface class IScript
	{
	public:
		/// <summary>
		/// 何かしらの処理
		/// </summary>
		virtual bool Execute (SharedWrapper^ sharedData);  // <= 修正
	};
}
// LibCli.h - LibCli
#pragma once

#include "SharedInterface.h"  // <= 追加

// cs スクリプトのコンパイル&実行
_declspec(dllexport) void LibCliMain (SharedInterface* pSharedData);  // <= 修正
// LibCli.cpp - LibCli
#include "LibCli.h"
#include "IScript.h"
#include "SharedWrapper.h"

using namespace System;
using namespace System::Reflection;
using namespace System::CodeDom::Compiler;
using namespace System::Collections::Generic;
using namespace System::Windows::Forms;
using namespace Microsoft::CSharp;
using namespace LibCli;

// 以下の3つを参照に追加するべし
// Microsoft.CSharp
// System
// System.Windows.Forms

void LibCliMain (SharedInterface* pSharedData)  // <= 修正
{
	// cs スクリプトのコンパイルパラメータ
	CompilerParameters^ params = gcnew CompilerParameters();
	params->GenerateInMemory = true;
	params->IncludeDebugInformation = false;
	params->ReferencedAssemblies->Add(Assembly::GetEntryAssembly()->Location);
	params->ReferencedAssemblies->Add("System.Windows.Forms.dll");

	// .Net Framework のバージョン
	Dictionary<String^, String^>^ providerOptions = gcnew Dictionary<String^, String^>();
	providerOptions->Add("CompilerVersion", "v4.0");

	// cs ファイル名
	array<String^>^ sources = { System::IO::File::ReadAllText("TestScript.cs") };

	// コンパイル!
	CSharpCodeProvider^ codeProvider = gcnew CSharpCodeProvider();
	CompilerResults^ compilerResults = codeProvider->CompileAssemblyFromSource(params, sources);

	// エラーがあれば出しとく
	for each (CompilerError^ error in compilerResults->Errors)
	{
		System::Windows::Forms::MessageBox::Show(error->ErrorText);
	}

	// コンパイルしたアセンブリを取得
	Type^ type = compilerResults->CompiledAssembly->GetType("Test.TestScript");
	IScript^ testScript = (IScript^)Activator::CreateInstance(type);

	SharedWrapper^ sharedWrapper = gcnew SharedWrapper(pSharedData);  // <= 追加

	// 実行!
	testScript->Execute(sharedWrapper);  // <= 修正
}

CppMain の方には SharedInterface の実装を追加します。

// SharedData.h - CppMain
#pragma once

#include "..\LibCli\SharedInterface.h"

class SharedData : public SharedInterface
{
public:
	int SomeFunction (int a, int b) override
	{
		return a + b;
	}
};

LibCliMain() の呼び出し時に SharedData を渡すようにします。

// main.cpp - CppMain
#include "..\LibCli\LibCli.h"
#include "SharedData.h"  // <= 追加

#include <cstdlib>

int main (void)
{
	SharedData shareData;  // <= 追加

	LibCliMain(&shareData);  // <= 修正

	system("Pause");

	return 0;
}

最後に、cs スクリプトを書き換えて完了です。

// TestScript.cs
using System.Windows.Forms;
using LibCli;

namespace Test
{
	public class TestScript : IScript
	{
		public bool Execute (SharedWrapper sharedData)  // <= 修正
		{
			MessageBox.Show("プロ生ちゃんマジ天丼!");

			MessageBox.Show(string.Format("SomeFunction(111, 901): {0}", sharedData.SomeFunction(111, 901)));  // <= 追加

			return true;
		}
	}
}

どこかのタイミングで dll を配置しないといけなくなるかと思っていましたが、終わりまで exe 1個で済んでしまいました。

あと、説明なしにさらっと回避処理を紛れ込ませていましたが、普通に cs ファイルのコンパイル・実行コードを書いた場合は TestScript.cs から LibCli への参照が登録されておらず LibCli.IScript や LibCli.SharedWrapper を参照できません。
でも LibCli はスタティックライブラリになってるしどうすっぺという感じですが、LibCli.cpp で

params->ReferencedAssemblies->Add(Assembly::GetEntryAssembly()->Location);

とやっている部分がミソです。実は exe も dll 的なふるまいをするので、ここで exe を dll として登録しています。

最終的にできあがったソリューションファイルです -> CsScriptTest_1.zip

おわりに

この方法は、設計的にはきれいにまとまりましたが、CLI でスタティックライブラリを使う場合にいろいろと問題が出てしまいました。

まず、.Net Framework 4 と指定していた所ですが、どうやら新しい .Net Framework ではスタティックライブラリが作れないようです。Debug ビルドはできても Release ビルド時に mscorlib.dll が見つからないとエラーになったりします。また無理にリンクして動かしたとしても、アプリケーション実行時にマネージド領域が初期化されないとか何かでC#のメソッドが正しく動きませんでした。

さらに、ソースコードの編集でインテリセンスが効かなくなるという問題もありました。これは結構きついです。

CLI でスタティックライブラリを作れるとこういう面白いことができるなーと思ったのですが、MS さん的にはサポートしていかない感じなんですかね。DLL にすれば解決する話でもあるのですが、やっぱりスタティックリンクがいいなー

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

*