こんにちは。トイロジックでプログラマをしているAです。本記事では動的メモリを使用しないfunctionの作成について解説したいと思います。メモリ確保を避ける場合の対応方法や、標準でも利用されている型消去の基本を学ぶことができます。ゲーム開発の初学習者向けですが、基本的なC++の文法やテンプレートの使い方を理解していることが前提の内容です。
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. サイズ
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:
//...
};
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
は無条件ではなくなります。それでもアロケーションに伴うコストから解放されるのは魅力ですし、注意深く設計すれば利便性が落ちる可能性も減らせます。
開発で動的確保が問題になった場合、まず一般的な観点から無駄がないか検証すべきですが、更に効率を求めたいのであれば対応の価値があるのではないでしょうか。ありがとうございました。