はじめに
プログラミング言語C#
最近ではWindowsプラットフォームだけでなく、活躍の場が多くなってきました。Xamarin(monoプロジェクト)により、AndroidやiOSなどのモバイルプラットフォーム向けにも、またLinuxやMac向けにもC#で開発できるようになりました。
そしてゲーム分野でのC#の利用も多くなってきました。前述のmonoを用いているUnityゲームエンジンでは、PC、ゲーム専用機、そして各種モバイルプラットフォーム向けに、C#を使ってゲーム開発をすることができます。そんなUnityでのC#利用ですが実は大きな問題を抱えています。
Unityでは、iOSの実機での実行時、Ahead Of Time(AOT)コンパイル関連の問題により、いくつかの条件を満たした場合他のプラットフォームでは発生しない例外が発生してしまうことがあります。
どのような条件かは@neue_ccさんが、こちらの投稿にまとめられています。またこの投稿で触れられていますが、C#の最も強力な言語要素の一つであるLINQでも、Unity+iOSだと例外が発生してしまうことがあります。
さきほどの投稿を見て、「あぁこういう条件があるならば、LINQのこのメソッドも例外が発生するのでは?」と思い、LINQがどのメソッドで、どういう条件で例外が発生するのか、調べてみました。結構死にました。(「Unity+iOSでエラーになるLINQのまとめ」にまとめてあります。)
Unityゲームエンジンでの開発はされていない方、想像してください。
List<int> indexList = GetIndexList ();
int lastIndex = indexList.Last ();
上記のコード、PC上の開発環境(Unityエディタ)やAndroidでは正しく動作するのですが、iOSの実機だと例外が発生してしまうのです。(indexListが空じゃ無くても)
例外が発生するのは流石に厳しい、かといってC#なのにLINQなしではつらすぎる。そこで、UnityでもLINQが使えるLINQ互換ライブラリを、オープンソースで作りたいと思いました。
そんな思いから、iOSでも安心して使える(ことが目標の)自作LINQ互換ライブラリ、「UniLinq」を作りました。
本投稿ではそのUniLinqを紹介します。
monoとUnityとLINQ
@neue_ccさんもこちらの投稿で言及されていますが、monoとUnityとLINQについてこちらでも説明させていただきます。
UnityもXamarinもmonoを使っています。XamarinではiOSでも(Xamarin.iOS)普通にLINQが使えるようです。なぜでしょうか。
実はmonoの本家も昔はiOSなどのAOTコンパイル環境で、LINQの一部メソッドなどで例外が発生していたようです。mono本家の方はそれを修正しているみたいです。Xamarin.iOSなどでは修正された新しいmonoを利用されているため、LINQが正常に使えるようです。
ところでUnityはかなり古いmonoをベースに独自の修正を加えています。問題なのは、UnityはAOTコンパイル関連の問題が修正がされる前のmonoをベースにしており、mono本家が加えているAOTコンパイル関連の修正が適用されていないことです。そのため、新しいmonoを使っているXamarinではLINQなどを使っても大丈夫だけれど、古いmonoを使っているUnityは例外が発生することがあるようです。
monoのコードをコピペしてちょこちょこっと!これでいけるか?
さて、mono本家のコードはこちらのmono/monoから手に入ります。(また、UnityのmonoはこちらUnity-Technologies/monoから手に入ります)そしてmonoのクラスライブラリについては、MITライセンスで利用することができます。
「AOTコンパイル関連のエラーが修正されたmono/monoのLINQ関連のクラスとかインターフェースとかのソースコードをコピペして来て、名前空間を変えれば、LINQ互換ライブラリができるんじゃないか?」
これだけではダメでした。
- LINQ to Objects関連のコードをコピペ
- 名前空間をSystem.LinqからUniLinqに変更
- 『.NET Framework4以上でないとダメな』のような#ifdefを適切に
- 「FULL_AOT」という#ifdefがあった場合、そちらのコードを利用
としただけでは、いくつかのメソッドはまだ例外が発生してしまいました。
どうやらmonoでは、クラスライブラリのコードの変更・修正だけでなく、コンパイラの改善も通して、AOTコンパイル関連の問題を解決していたようです。そのため新しいmonoのLINQのコードは、新しいのmonoコンパイラでコンパイルしiOSで実行した場合エラーにならないようですが、Unityに内蔵されているmonoコンパイラでコンパイルしiOSで実行した場合AOTコンパイル関連の例外が発生してしまうようです。
つまり、新しいmono/monoのLINQ関連のコードをコピペしてきて、ちょこっと修正しただけでは不十分で、更なるなる修正が必要なようです。
元のmonoから何を変更したのか
- LINQ to Objects関連のコードをコピペ
- 名前空間をSystem.LinqからUniLinqに変更
- 『.NET Framework4以上でないとダメな』のような#ifdefを適切に
- 「FULL_AOT」という#ifdefがあった場合、そちらのコードを利用
に加えていくつか修正を行いました。
分かりやすいのは、LINQ to Objectsの実装であるSystem.Linq.Enumerable
クラス内の次のようなジェネリクス+ラムダ式を使っているFunction<T>.Identity
のコードを使わないようにしたことです。(具体的にはこちらのコミット)
これは、AOTコンパイルエラーになる「ジェネリクス+ラムダ式+値型」パターンに該当し、これを利用しているToDictionary
やToLookup
が特定条件を満たすとAOTコンパイルに関連する例外が発生していました。また、ToLookup
を使っているJoin
、GroupJoin
もこれが原因でAOTコンパイル関連の例外が発生することがありました。
元の実装では、これを使い適切にコードの重複を除いていたのですがAOTコンパイル関連の例外発生を防ぐためにはしかたありません。コードの重複ができてしまいましたが、AOTコンパイルエラーを防ぐことの方が大切です。
また、「FULL_AOT」という#ifdefのコードを採用したため、System.Linq.Enumerable
クラス内からPredicateOf<T>.Always
とい使っているコードも排除されました。(具体的にはこちらのコミット)。これもエラーの原因になる「ジェネリクス+ラムダ式+値型」パターンになりえます。FirstOrDefault
やLast
などで使われていました。
一番厄介だったのが、OrderBy
、OrderByDescending
、ThenBy
、ThenByDescending
です。
具体的にどのような修正を行ったかは実際のコミットを見てください。
またEmpty
も修正しました。Empty
はmono本家ではジェネリクな内部クラスを使って効率化を測った改善がされていました。しかし、Unity内のコンパイラではその修正により、正しくAOTコンパイルできないようになっていたためです。
どうテストしたのか「NUnitLiteForUnity」
作成したライブラリがiOSの実機で正しく動作するかテストしないといけません。Unity開発環境(Unityエディタ)上で、テストするだけでは当然だめです。
Unityには、NUnitを用いたUnity Test ToolsというUnity社製のテストツールがあります。ただコレは今のところ、Unityエディタ上ではテストできるのですが、iOS上やAndroid上でのテストには対応していません。
そこで独自でテストするための仕組みを作る必要がありました。NUnitLiteをベースに、iOS上やAndorid上でアセンブリ内の全てのテストを取得・実行するライブラリを作成しました。
NUnitLiteForUnityと言います。コレ単体で公開しています。(githubページはこちら)
AOTコンパイル関連のエラーが発生しないか調べる必要がある方、よかったら使ってみてください。iOSのUnityのProライセンスを持っていなくても使えるように、またeventでAOTコンパイル関連の例外発生を防ぐように、NUnitLiteのコードにも少しだけ手を加えています。
余談ですが、最初はNUnitLiteでなくて、NUnitを使って作ろうとしていました。Unity開発環境(Unityエディタ)上で、アセンブリ内の全てのテスト取得・実行ができて、いざiOS上で実行しようと思ったら、NUnit自身のコードが原因でAOTコンパイル関連の例外が発生してしまい...NUnitではだめでしたが、NUnitLiteでテストできたため、今回はNUnitLiteを採用しました。
競合との比較
さて、Unity+iOSでLINQを例外発生なしで使えることを目標としているライブラリは実は他にもあります。
Unityのアセットストア(Unity独自のライブラリや3Dモデルやサウンドを販売できるストア)にある、LINQ to iOSというライブラリがそうです。
しかし残念ながらこのアセットも完全ではありません。
たとえば次のようなコードは、LINQ to iOSを使ってもiOSでは例外が発生します。
public void TestPersonOrdering ()
{
Person taroSuzuki = new Person {
FirstName = "Taro",
LastName = "Suzuki"
};
Person jiroSuzuki = new Person {
FirstName = "Jiro",
LastName = "Suzuki"
};
Person taroSato = new Person {
FirstName = "Taro",
LastName = "Sato"
};
List<Person> personList = new List<Person> { taroSuzuki, jiroSuzuki, taroSato };
foreach (Person person in personList.OrderBy (p => p.LastName).ThenBy (p => p.FirstName)){
Debug.Log (person.FirstName + " " + person.LastName);
}
}
public class Person
{
public string LastName { get; set; }
public string FirstName { get; set; }
}
今回作った「UniLinq」では上記のテストコードでは例外は発生しません。
ついでにUnityのLINQの良くない点も直った
AOTコンパイル関連以外にも古いmono、つまりUnityのLINQ関連の実装には不具合がありました。UniLinqでは、新しいmono/monoのLINQ関連のコードを利用することで、この不具合が改善されています。
引数名の修正
引数名が間違っている物があります。これはmono/monoではこちらのコミットで修正されています。
Countメソッドのある引数の引数名が、本来predicate
であるのにselector
になっていました。
int count = numList.Count(predicate: num => num > 90);
名前付き引数を使った呼び出しが上記のようにできるはずですが、古いmonoのつまりUnityのLINQのAPIだと、コンパイルエラーになってしまいます。
他にもLongCountとSelectManyの引数名が違うようです。
実装間違いの修正
一例を上げると古いmono(つまりUnityがそうなっている)の実装だと、Exceptが重複した値を返すことがありました。(このコミットで修正されている)
IEnumerable<int> result = new []{0, 0, 1}.Except(new []{1});
正しいExceptの仕様だと、本来resultは要素は0
が1個のみのはずです。ですが、古いmonoのLINQ(UnityのLINQ)だと、0
の重複が排除されていません。
また、ReverseやJoinやGroupJoinなどのメソッドにも修正が入っているようです。
UniLinqの今後
こうしてUnity+iOSでも安心して使えることが目標
の自作LINQ互換ライブラリ、UniLinqをなんとか公開する準備ができました。
まだベータです。まだまだテストがたりません。AOT関連のテストで必要最低限しかできていません。今後テストを追加していこうと思います。(公開しているプロジェクトには含んでいませんが、monoのLINQのテストは一応全て通りました。)
もしよかったら、「LINQでこのメソッドは落ちた」など情報を頂けると嬉しいです。特に、この「Unity+iOSでエラーになるLINQのまとめ」に載っていない例外発生情報だと大変助かります。
また、UniLinqを使ったのにAOTコンパイル関連のエラーが発生したなどありましたら、申し訳ありませんが教えていただけると嬉しいです。
「C#的にここはこうした方がいい」などのご意見、お待ちしています。
いつか「Unity+iOSでも安心して使えることが目標」から「目標」が取れて、「UniLinqは、Unity+iOSでも安心して使えるLINQ互換ライブラリ」となれるよう頑張ります。
Unityコミュニティの方が一人でも多くLINQを使うように、そしてこのUniLinqがそのきっかけになれば嬉しいです。
UniLinq、よろしくお願いします!
関連投稿
Unityで今はまだLINQを使っていない方向けに、書きました!