
【C++】静的ポリモーフィズムによる環境依存コードの抽象化
こんにちは、トイロジックでグラフィックス関連を担当しているJKです。この記事では、開発プラットフォームの違いによる環境依存コードの抽象化する手法の1つをご紹介します。まずはポリモーフィズムについて少しみていきましょう。
std::functionと動的メモリ
std::functionとは、関数や関数のように呼び出せるオブジェクト、またラムダ式やクラスのメンバ変数、メンバ関数などを保持し、関数と同じ記法で呼び出しができるクラスです。
//関数
auto func(int value) -> float;
//関数オブジェクト
struct functor{
auto operator()(int value) const -> float;
};
//ラムダ式
auto const lambda = [](int value){
return static_cast<float>(value);
};
auto test() -> void{
std::function<auto (int) -> float> f;
f = func;
f = functor{};
f = lambda;
f(100);
}
それぞれ全く異なる型ですが問題なく代入できています。シグネチャ(この場合int型を引数に取りfloat型を返却)が同等で関数呼び出しが可能な型であれば保存できるため、voidポインタを用いたコールバックの安全な代替としたり、タスクとしてプールし任意のタイミングで実行したりと幅広い用途を持つ便利な機能です。
ところでstd::functionは動的にメモリを使用します(*)。残念ながらメモリの確保はコストのかかる処理です。割り当て自体はごく僅かでも頻繁な呼び出しはスレッドの並列性を妨げ、仮想メモリのない環境では断片化の原因ともなります。原理上ランタイムエラーから逃れることはできません。動的メモリはなくてはならないものですが、ゲーム開発者は常にメモリの懸念から解放されたいと望んでいるに違いありません。それも利便性を失わずに。
という訳で今回はstd::functionを題材にメモリアロケーションのない実装を目指します。完全な代替には多くのコードが必要となるためコア設計のみとさせて頂きます。ご了承下さい。言語はC++17を想定していますが、C++11以降であれば再現可能のはずです。
* 多くの場合、十分小さなオブジェクトへは静的なバッファを利用する最適化(SBO)が行われています。ただし実装依存です。
任意の型を保持するために
まず任意の型を保持することについて考えます。試しにクラスをテンプレートにして目的の型を受け取ってみましょう。
template<typename T>
class function{
public:
function(T f) : f_{std::move(f)}{}
auto operator()(int value) -> float{
return f_(value);
}
private:
T f_;
};
struct type{
auto operator()(int) -> float;
};
auto test() -> void{
function<type> f{type{}};
f(100);
}
無事保存できました。しかしこれではクラステンプレートのパラメータに保持したい型Tが現れてしまうため、インスタンス化されたfunctionクラスも全く別の型となってしまいます。他の型を入れ直すことはできませんし、配列やリストにすることもできません。
要件を満たすためにはクラステンプレートパラメータから型情報を除去する必要があります。この手法は型消去(Type Erasure)と呼ばれています。
class function{
public:
template<typename T>
function(T f) :
p_{new T{std::move(f)}},
call_{&function::do_call_<T>},
delete_{&function::do_delete_<T>}{}
~function(){
delete_(p_);
}
auto operator()(int value) -> float{
return call_(p_, value);
}
//未対応
function(function const &) = delete;
auto operator=(function const &) -> function & = delete;
private:
void *p_{};
auto (*call_)(void *, int) -> float{};
auto (*delete_)(void *) noexcept -> void{};
template<typename F>
static auto do_call_(void *p, int value) -> float{
return (*static_cast<F *>(p))(value);
}
template<typename F>
static auto do_delete_(void *p) noexcept -> void{
delete static_cast<F *>(p);
}
};
struct type{
auto operator()(int) -> float;
};
auto test() -> void{
function f{type{}};
f(100);
}
コピー構築したオブジェクトをvoidポインタの形で保持しています。これによって型Tがクラステンプレートパラメータからなくなりました。Tはコンストラクタのテンプレートパラメータにのみ登場し、呼び出しと解放を行う静的メンバ関数のパラメータとなった後消えています。これらの関数は型ごとにインスタンス化されますが、シグネチャは全く同一のため同じ関数ポインタで参照可能です。これでfunction自体は保持する型とは無関係になり、問題が解消されました。
継承を利用したType Erasure
先の手法では機能を追加していくとメンバ変数がどんどん増えてしまいます。処理内容は型ごとに等しいため、複数の関数ポインタをまとめた静的なテーブルは作れそうです。そうすればどんなに処理が増えてもテーブルのポインタを一つ保持するだけで済みます。ただ今回は紙幅の関係もあるのでもう少し簡単な方法を取ってみます。
class function{
public:
template<typename T>
function(T f) :
p_{new derived_<T>{std::move(f)}}{}
~function(){
delete p_;
}
auto operator()(int value) -> float{
return p_->call(value);
}
//未対応
function(function const &) = delete;
auto operator=(function const &) -> function & = delete;
private:
struct base_{
virtual ~base_(){}
virtual auto call(int) -> float = 0;
};
template<typename T>
struct derived_ : base_{
T f;
derived_(T f) : f{std::move(f)}{}
auto call(int value) -> float override{
return f(value);
}
};
base_ *p_{};
};
だいぶすっきりしましたが本質的には同じです。先程言及したテーブルの役目は仮想関数テーブルが負っています。多態的な型を基底クラスから解放する場合、デストラクタを仮想にする必要がありますが、voidポインタを使用した例でそれに相当するのがdo_delete_関数で元の型Tにキャストしているコードです。voidポインタは型情報が消去されているため本来のデストラクタを呼び出せません。
行ったことをまとめると以下になります。
オブジェクトの型情報を消去して保存する(voidポインタ、継承)
実行時に型情報を復帰して利用する(キャスト、多態)
動的なメモリ確保の除外
さて今までの例ではnewとdeleteを使用しています。次はこれをコードから除去しましょう。ここでnewが行っていることは具体的に何なのでしょうか。今回の目的に絞ると以下の2点になります。
任意のサイズのオブジェクトを作成=ストレージサイズの自動化
任意のアライメントのオブジェクトを作成=アライメント調整の自動化
順に代替方法を検討していきます。
1. サイズ
検討と言っておきながら選択肢はほぼありません。固定長になります。ただしユーザが妥当なサイズを決められるようテンプレートパラメータを用います。Sizeもsizeof(T)もコンパイル時定数のため、構築にストレージが十分かコンパイル時に検証することが可能です。
template<std::size_t Size>
class function{
public:
//格納できないサイズの型を弾く
template<
typename T,
std::enable_if_t<(sizeof(T) <= Size)> * = nullptr>
function(T f);
private:
//...
};
生成処理自体はアライメントとまとめて記載します。Sizeはクラステンプレートパラメータのため値が異なると別の型になることに注意して下さい。問題になることは多くないと思いますが、これは動的メモリを排した場合の明確な制限です。
2. アライメント
ある型にはその型を配置するために必要なアライメント(メモリの切りの良い位置)があり、それが守られない場合の動作は未定義です。クラッシュすればまだ良いのですが、無言で速度を犠牲にする環境もあるため注意が必要です。アライメントの対応へは以下が考えられます。
保持する型ごとに調整する
その環境で最大のアライメントを持つ型を定義し、そのアライメントを利用する
クラステンプレートで指定する
型ごとに調整する場合、ストレージのサイズが「型Tのアライメント – 1(ストレージのアライメント)」分小さくなるため、保持したい型のサイズと同じ値をSizeに指定しても格納できない場合が出てきます。コンパイル時の検出は可能ですが、ユーザからするとやや不自然に感じられるかも知れません。他の2つはストレージを予め十分なアライメントで定義しておく方法です。簡単のため本稿ではこちらの方法を取っています。
//アライメントを値で指定するほか、型を直接書くことも可能(可変長引数も可)
alignas(max_align_t) char storage_[Size];
std::aligned_storageでも同等のことが可能ですがC++23で非奨励化されたので今のうちにやめておきましょう。またゲーム開発ではstd::max_align_tは最大のアライメントを持つ型としては不十分な場合があります。しばしば利用されるSIMD型は一般にこの型よりも大きなアライメントを要求するためです(*)。
* オーバーアライメント。標準でもC++17以前は未対応で、グローバルのnewを含めstd::max_align_tのアライメント(多くの場合8バイト)までしか対応していません。そのためSIMD型やそれを含む型を標準ライブラリと共に使用した場合、不正なアライメントとなることがありました。newに関してはオーバーロードで対処出来ますが、最適化の一環で静的なバッファを利用していたり、複数のオブジェクトをまとめて確保していたりすると回避できませんでした(std::functionやstd::make/allocate_sharedなど)。
という訳でサイズとアライメントをパラメータ化した例はこちらです。ヒープの代わりにクラスのメンバとしてストレージを定義し、その上にplacement newでオブジェクトを構築しています。領域自体は解放できないためdeleteからデストラクタのみを呼び出すstd::destroy_atへと置き換わっていることに注意して下さい。
template<std::size_t Size, std::size_t Align>
class function{
public:
template<
typename T,
std::enable_if_t<(sizeof(T) <= Size)> * = nullptr,
std::enable_if_t<Align % alignof(T) == 0u> * = nullptr>
function(T f) :
p_{::new(storage_) derived_<T>{std::move(f)}}{}
~function(){
std::destroy_at(p_);
}
auto operator()(int value) -> float{
return p_->call(value);
}
//未対応
function(function const &) = delete;
auto operator=(function const &) -> function & = delete;
private:
struct base_{
virtual ~base_(){}
virtual auto call(int) -> float = 0;
};
template<typename T>
struct derived_ : base_{
T f;
derived_(T f) : f{std::move(f)}{}
auto call(int value) -> float override{
return f(value);
}
};
alignas(Align) char storage_[Size];
base_ *p_{};
};
最後に
実用的なものにするためにはシグネチャをパラメータ化する必要がありますし、コピーや再代入など考慮すべき事項はたくさんありますが、同様の手法で実現可能です。そしてそれらが済めば晴れてstd::functionの置き換えが可能になります!
//using task = std::function<auto (int) -> float>;
using task = function<auto (int) -> float, 32u, alignof(max_align_t)>;
一般にある機能から動的メモリを取り除く場合、都度必要な分だけ確保されていたストレージが固定長となるため、純粋なメモリ使用量は増える可能性があることに留意して下さい。またムーブ操作をポインタではなく実体に対して行わなければならなくなるため、ムーブコンストラクタやムーブ代入演算子のnoexceptは無条件ではなくなります。それでもアロケーションに伴うコストから解放されるのは魅力ですし、注意深く設計すれば利便性が落ちる可能性も減らせます。
開発で動的確保が問題になった場合、まず一般的な観点から無駄がないか検証すべきですが、更に効率を求めたいのであれば対応の価値があるのではないでしょうか。ありがとうございました。
※本記事はトイロジックのゲーム開発技術ブログ「トイログ」からの転載です。他にも記事を読みたい方はぜひトイログをチェックしてみてください!
トイロジックでは現在、一緒に働くプログラマーを募集しています。
不明点などもお気軽にお問い合わせください。ご応募お待ちしております!