これは ドリコム Advent Calendar 2019の19日目です。
18日目は shirai_yasuha さんによる、 ちょい時短で幸福度がすごくあがる話 です。
今日は自身の携わっているプラットフォームの話…ではなく、
個人的に思い入れのあるC++という言語についてお話していきたいと思います。
Concept,Module,Coroutineと大がかりな変更が入る中、個人的に一番期待していた機能であるReflection(とMetaClass)は、残念ながら導入見送りとなってしまいました。
どんなに早くても2023年まではC++でReflectionを利用出来ないと考えると、そこはかとなく辛い気持ちになってしまったので、自力でReflectionに近いものを実現できないかと情報を探し始めました。
これを使うことで
といったことが可能になります。
今回の目標は
とします。
※Unity-ECSに関しては今回扱いません
ただし、GameObject/Componentの全てを実装すると途方もない時間がかかってしまうため、
今回はGameObjectを代表する以下の3つの関数をC++で実装します。
まずはリフレクション無しの実装を考えていきましょう
関数内でコンストラクタを呼び出してGameObjectと関連付けるだけです。
このやり方ではクラス追加のたびに毎回手動で追記する必要があります。
後は、Componentを初期化する際にこのIdを持たせてやることで
しかし、この手法ではGetComponentの仕様である、
継承先クラスも取得出来るという挙動を実現することができませんし、Get関数は呼び出される順序に依存して返すIDが変わってしまうため、事故が起きることは容易に想像出来ます。
dynamic_castを使用する事で継承先クラスであるかどうかを判別することは可能ですが、ある程度実行速度が犠牲になってしまいます(後述)。
今回であれば、Component型から派生した型全てを列挙することが出来れば解決します。
例えば、Unityで記述できるC#という言語ではリフレクションが利用できるので、以下のようにサクッと実装できてしまいます。
辺りが考えられるでしょう。
include順序の制御等が難しく、簡単に扱えるような代物にならないだろうと考え不採用としました。
これはどうにもならなかった時の最後の手段として取っておきます。
namespaceや型の別名定義、テンプレート特殊化にマクロも解釈する必要があり、
実質C++コンパイラを自作するのと変わりないので、これも不採用としました。(諸説あり)ので、当然型情報も保持しているはずです。
調べれば何かしらリフレクションに使えるものがあるかもしれません。
ひとまずこの方針で進めていきましょう。
MSVCでC++の型情報を拾えないか探したところ、VisualStudio拡張機能で使えるC++のリフレクションが可能なAPIを見つけることが出来ました。
この環境で実装していきます。
今回のコードは一通りGithubに上がっています。
今回の実装は下図の赤線のルートを辿りました。
ざっくりとした処理の流れは下図の通りです。
これは、上述したUniqueTypeIdクラスのような実装で実現できますが、今回はコンパイラの最適化を期待してtemplate特殊化を利用して実装していきます。
具体的には、テンプレート引数を一つ受け取る空の型を用意して、その型にテンプレート特殊化で値を生やします。
GetComponent<T>はコンパイルされる時点でT型のID(もしくはその変数)を知っている必要があるため、型IDはヘッダに展開します。
せっかくヘッダに展開するのでconstexpr(コンパイル時定数)化もしておきます。
今回は
後で見返したら型名必要ありませんでした。
を用意し、型マネージャに登録します。
そして、型名か型IDから型情報にアクセスできるようにしておきます。
余談ですが、今回の実装はRelease時の最適化を効かせた状態であれば、
dynamic_castに比べて約5倍速く動作します。
今回は実装していませんが、クラスだけでなく変数もリフレクションすることで、シリアライズ/デシリアライズを行う関数の自動生成も出来そうです。
マクロで同じことを実現しようとすると、最低でも各クラスに1つ以上のマクロが必要になるはずなので、それなりのリスクを軽減することが出来ました。
更にVisualStudio for Macとの互換も無いため、実質Windows専用のリフレクションとなります。
また定義の再生成により、静的リフレクションを使用する全てのソースが再コンパイル対象となってしまいます。
これは序盤に解説したUniqueTypeIdを使用する手法を応用して、UniqueTypeIdを動的リフレクションの型IDと一致するように初期化させることで回避できます。
解析するC++コード量が多くなればなるほど、リフレクションによるコード生成の時間が長くなっていくので、中規模以上のプロジェクトで運用していく場合はnamespaceやディレクトリを使って解析対象となるC++コードを予め絞っておく必要がありそうです。
頑張れば頑張った分だけ実行速度や型安全性やコンパイル時間で応えてくれるので程よくやりがいのある面白い言語です。
今この記事を読んでいる皆さんに、C++の面白さがほんの少しでも伝われば幸いです。
明日は じぬ さんの記事です。
ドリコムでは一緒に働くメンバーを募集しています!
募集一覧はコチラを御覧ください!
18日目は shirai_yasuha さんによる、 ちょい時短で幸福度がすごくあがる話 です。
はじめに
こんにちは、enzaのフロントエンドエンジニアのEglissです。今日は自身の携わっているプラットフォームの話…ではなく、
個人的に思い入れのあるC++という言語についてお話していきたいと思います。
事の始まり
2019年も終わりが近づいてきたという事でC++20の仕様がそろそろ出揃った頃ではないのかと情報を漁っていました。Concept,Module,Coroutineと大がかりな変更が入る中、個人的に一番期待していた機能であるReflection(とMetaClass)は、残念ながら導入見送りとなってしまいました。
どんなに早くても2023年まではC++でReflectionを利用出来ないと考えると、そこはかとなく辛い気持ちになってしまったので、自力でReflectionに近いものを実現できないかと情報を探し始めました。
Reflectionとは
Reflection(以下リフレクション)とはプログラムが自分自身の情報を取得する機能の事です。これを使うことで
- クラスの持っているメンバ変数の名前と型一覧を列挙する
- クラスの持っているメンバ関数を関数名から呼び出す
- クラスのコンストラクタを型名から呼び出す
といったことが可能になります。
やりたいこと
ただリフレクションするだけでは面白くないので、目標を考えました。今回の目標は
- UnityのGameObject/Componentを再現する
とします。
※Unity-ECSに関しては今回扱いません
ただし、GameObject/Componentの全てを実装すると途方もない時間がかかってしまうため、
今回はGameObjectを代表する以下の3つの関数をC++で実装します。
- AddComponent<T>
- AddComponent(const string& typeName)
- GetComponent<T>
まずはリフレクション無しの実装を考えていきましょう
AddComponent<T>
型引数ありのAddComponentの実装はとても簡単です。関数内でコンストラクタを呼び出してGameObjectと関連付けるだけです。
T* inline GameObject::AddComponent() { auto component = T(); // GameObjectと関連付ける this->_InternalAddComponent(component); return component; }
AddComponent(const string& typeName)
動きだけ考えれば、文字列をキーにコンストラクタを呼ぶMapを用意するといった形になりそうですが、このペアを動的に生成する手法が思い浮かびませんでした。このやり方ではクラス追加のたびに毎回手動で追記する必要があります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
using namespace std;
using namespace Egliss::ComponentSystem;
unordered_map<string,function<void*()>> ConstructMap;
void Initialize()
{
ConstructMap[Egliss::ComponentSystem::Transform] = []() { return new Transform(); };
// 全ての型分初期化する
// …
}
inline Component* GameObject::AddComponent(const std::string& typeName)
{
// 対応するコンストラクタが無い
const auto itr = ConstructMap.find(typeName);
if(itr == ConstructMap.end())
return nullptr;
// インスタンス生成
Component* component = static_cast<Component*>(itr->second());
// GameObjectと関連付け
this->_InternalAddComponent(component);
return component;
}
|
GetComponent<T>
staticとtemplateをうまく使うことで実装出来そうです。class UniqueTypeId { template<class T> static int Get() { static int internalId = typeId++; return internalId; } static inline int typeId = 0; };このようなクラスを定義することで、型ごとに一意のIdを返すことが出来るようになります。
後は、Componentを初期化する際にこのIdを持たせてやることで
const auto typeId = UniqueTypeId::Get<Transform>(); if(component->TypeId() == typeId) { return component; }このように実装することが出来ます。
しかし、この手法ではGetComponentの仕様である、
継承先クラスも取得出来るという挙動を実現することができませんし、Get関数は呼び出される順序に依存して返すIDが変わってしまうため、事故が起きることは容易に想像出来ます。
dynamic_castを使用する事で継承先クラスであるかどうかを判別することは可能ですが、ある程度実行速度が犠牲になってしまいます(後述)。
何が問題か
ズバリ、型を列挙することです。今回であれば、Component型から派生した型全てを列挙することが出来れば解決します。
例えば、Unityで記述できるC#という言語ではリフレクションが利用できるので、以下のようにサクッと実装できてしまいます。
// C# で実装する場合 { List<Component> components = new List<Component>(); Dictionary<string, Func<Component>> ConstructMap = new Dictionary<string, Func<Component>>(); //Componentの所属するアセンブリを取得 var subClasses = Assembly.GetAssembly(typeof(Component)) // アセンブリに所属する型一覧を取得 .GetTypes() // 派生した型を取得 .Where(m => m.IsSubclassOf(typeof(Component))); // AddComponent(string typeName) var component = Type // 指定した型の .GetType(T) // デフォルトコンストラクタを .GetConstructor(Type.EmptyTypes) // 引数無しで呼び出す .Invoke(null); // GetComponent<T> return components // Tから派生した型であれば残す .Where(m=>m.GetType().IsSubclassOf(typeof(T))) // 見つかったオブジェクト群の先頭 もしくはnullを返す .FirstOrDefault(); }
リフレクションの実現
とにかく型を列挙する方法を考えてみましょう。- マクロを組んでどうにかする
- 自力でC++のコードをパースする
- コンパイラの力を借りる
辺りが考えられるでしょう。
1.マクロを組んでどうにかする
マクロの書き忘れによって簡単に動かなくなってしまうのと、include順序の制御等が難しく、簡単に扱えるような代物にならないだろうと考え不採用としました。
2.自力でC++のコードをパースする
コーディング規約がある程度固まっていれば可能かもしれませんが、namespaceや型の別名定義、テンプレート特殊化にマクロも解釈する必要があり、
実質C++コンパイラを自作するのと変わりないので、これも不採用としました。
3.コンパイラの力を借りる(採用)
C++コンパイラはC++をコンパイル出来る調べれば何かしらリフレクションに使えるものがあるかもしれません。
ひとまずこの方針で進めていきましょう。
コンパイラ
C++のコンパイラと言えば、clang/gcc/MSVCと色々ありますが、私はVisualStudioが好きなのでMSVCを採用します。MSVCでC++の型情報を拾えないか探したところ、VisualStudio拡張機能で使えるC++のリフレクションが可能なAPIを見つけることが出来ました。
リフレクション実装
お待たせしました ここからが実装になります。環境
- Windows10
- VisualStudio2019
- C# 7.0(拡張機能実装用)
- C++17(ランタイム実装用)
この環境で実装していきます。
今回のコードは一通りGithubに上がっています。
拡張機能の実装
リファレンスを漁る
コンパイラ の項で挙げた、VCCodeModelにどのようにアクセスするのかを追っていく過程で良い感じの図を見つけることが出来ました。今回の実装は下図の赤線のルートを辿りました。
処理の流れ
全ての実装を書くと中々の量になってしまうので、詳しくはGithub をご確認ください。ざっくりとした処理の流れは下図の通りです。
静的リフレクション用ヘッダ
GetComponentを実現する際は、型からIDを取得する必要があります。これは、上述したUniqueTypeIdクラスのような実装で実現できますが、今回はコンパイラの最適化を期待してtemplate特殊化を利用して実装していきます。
具体的には、テンプレート引数を一つ受け取る空の型を用意して、その型にテンプレート特殊化で値を生やします。
GetComponent<T>はコンパイルされる時点でT型のID(もしくはその変数)を知っている必要があるため、型IDはヘッダに展開します。
せっかくヘッダに展開するのでconstexpr(コンパイル時定数)化もしておきます。
今回は
- 自身の型ID
- 型名
#pragma once // The file generated by CppReflection.ClassExpoter. namespace Egliss::ComponentSystem { class Component; } // ... namespace Egliss::Reflection { template<> class StaticTypeDescription<Egliss::ComponentSystem::Component> { public: static constexpr int Id = 0; static constexpr std::string_view Name = "Egliss::ComponentSystem::Component"; }; // ... }constexprなstd::stringは作成することが出来ないので、C++17から導入されたstd::string_viewを使用しています。
動的リフレクション用ソース
GetComponent及びAddComponentを実現するため、- 型名
- 抽象クラスかどうか
- 基底クラスまでの型ID一覧
- コンストラクタ
を用意し、型マネージャに登録します。
そして、型名か型IDから型情報にアクセスできるようにしておきます。
// The file generated by CppReflection.ClassExpoter. #include "../ComponentSystem/ComponentSystem.hpp" using namespace Egliss::ComponentSystem; using namespace Egliss::Reflection; using namespace std::string_literals; // 型情報 std::vector<DynamicTypeDescription> DynamicTypeManager::_indexedTypes; // 型名 -> 型情報のインデクス変換 std::unordered_map<std::string, int> DynamicTypeManager::_typesindices; // 動的アクセスのための型情報を初期化する void DynamicTypeManager::Initialize() { _indexedTypes.emplace_back("Egliss::ComponentSysten::Transform", false,std::vector<int>({1 ,0}),[](){return new Transform();}); _typesindices.insert(std::make_pair("Egliss::ComponentSystem::Transform"s,1)); // ... }これで、拡張機能の実装が完了しました。
GameObject実装
AddComponent(const std::string& typeName)
先ほど作成した型マネージャから名前で検索して、型があってコンストラクタが呼び出せる(isAbstractでない)ならインスタンスを返します。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
inline Component* GameObject::AddComponentByName(const std::string& typeName)
{
const auto description = Reflection::DynamicTypeManager::FindByTypeName(typeName);
// 型が無い
if (description == nullptr)
return nullptr;
// new不可能
if (description->isAbstract)
return nullptr;
const auto component = static_cast<Component*>(description->constructor());
// GameObjectと関連付け
this->_InternalAddComponent(component, description->Id());
return component;
}
|
GetComponent<T>
受け取った型からIDを取得し、各コンポーネントの親ID一覧にそのIDが含まれるかで判別します。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
template<class T, class U>
static inline T* _InternalGetComponentFrom(const U& container)
{
// 検索する型のIDを拾う
const int inputTypeId = Reflection::StaticTypeDescription<T>::Id;
// コンテナに存在するコンポーネントを列挙
for (const auto component : container)
{
const auto typeID = component->TypeId();
// 型情報を取得
const auto& description = Reflection::DynamicTypeManager::IndexOf(typeID);
// 基底クラスまでの型ID一覧を辿って対象のIDが存在するか
if (description.HasTypeRelation(inputTypeId))
return component->As<T>();
}
return nullptr;
}
|
dynamic_castに比べて約5倍速く動作します。
完成
動作を確かめてみて問題無ければ完成です。auto obj = GameObjectManager::Create(); auto t = obj->GetComponent<Component>(); // Transformが取れる auto d = obj->AddComponentByName("Egliss::Game::Dummy"); // AddComponent<Dummy>()と同じ無事にC++でAddComponent、GetComponentを実装することが出来ました!
良かったところ
外部ファイルからコンポーネントを生成できる
型名を経由したAddComponentのお陰で、外部ファイルにインスタンスの生成を逃がせるようになりました。 これによってソース内にシーン構造をベタ書きしなくても良くなります。今回は実装していませんが、クラスだけでなく変数もリフレクションすることで、シリアライズ/デシリアライズを行う関数の自動生成も出来そうです。
実行速度が早い
前述したとおりdynamic_castよりも早くなり、気兼ねなく扱える機構になりました。扱いやすい
Unityの機構に慣れていると、GetComponent/AddComponentを直感的に扱うことが出来るため、使う分には学習コストが安く済みます。コンポーネント追加のミスが無くなった
型情報に関係するコードが自動生成されるため、関連付ける値のミスや書き忘れといった問題が起きなくなりました。マクロで同じことを実現しようとすると、最低でも各クラスに1つ以上のマクロが必要になるはずなので、それなりのリスクを軽減することが出来ました。
悪かった所
VisualStudioに依存する
当たり前と言えば当たり前ですが、今回の実装はVisualStudioにベッタリ依存します。更にVisualStudio for Macとの互換も無いため、実質Windows専用のリフレクションとなります。
毎回定義を生成し直す必要がある
現行の実装ではビルド時に自動で実行されないため、リフレクション定義の生成を忘れると惨事が発生します。また定義の再生成により、静的リフレクションを使用する全てのソースが再コンパイル対象となってしまいます。
これは序盤に解説したUniqueTypeIdを使用する手法を応用して、UniqueTypeIdを動的リフレクションの型IDと一致するように初期化させることで回避できます。
定義の生成中はVisualStudioが停止する
この間は文字通り一切の操作を受け付けなくなってしまいます。解析するC++コード量が多くなればなるほど、リフレクションによるコード生成の時間が長くなっていくので、中規模以上のプロジェクトで運用していく場合はnamespaceやディレクトリを使って解析対象となるC++コードを予め絞っておく必要がありそうです。
最後に
C++ははっきり言って手間が掛かる言語ではありますが、頑張れば頑張った分だけ実行速度や型安全性
今この記事を読んでいる皆さんに、C++の面白さがほんの少しでも伝われば幸いです。
明日は じぬ さんの記事です。
ドリコムでは一緒に働くメンバーを募集しています!
募集一覧はコチラを御覧ください!