C ++での仮想およびテンプレートを使用した機能オブジェクト、関数、およびラムダの埋め込み

この記事では、標準のC ++テクノロジーを使用してさまざまなオブジェクトへの呼び出しを管理するための非常に生産性の高い(コンパイル時に組み込まれる)簡単にスケーラブルなコードを取得できるメカニズムのいくつかを示します。



タスクについて



少し前に、ユーザー(実行時)情報に応じて、プログラムコア内でさまざまなアクションを実行する小さなモジュールを実装する必要がありました。 同時に、主な要件は、コードの最大パフォーマンス(最適化可能性)、サードパーティの依存関係の欠如、および機能を追加する場合の単純なスケーリングでした。



簡単かつ読みやすくするために、コード例では最も複雑なキーメカニズムのみを示します。 O2最適化のためのMicrosoftコンパイラ用のマシンコードの例が提供されています。



最初のステップ



「C」スタイルの問題の解決策は、データを処理するときに値が設定されている関数へのポインターを単に使用することです。 ただし、ポインター自体に加えて、各関数の追加情報を保存する必要がありました。 その結果、最も一般的なソリューションを提供するために、選択は必要なフィールドとメソッドのセットを持つ抽象クラスに基づいていました。



オブジェクトアプローチにより、低レベルの実装から抽象化し、より広い概念を扱うことができます。これにより、コードとデバイス全体の理解が容易になります。



そのようなクラスの簡単な例:



struct MyObj { using FType = int( *)(int, int); virtual int operator() ( int a, int b ) = 0; virtual ~MyObj() = default; };
      
      





ここでは、仮想演算子「 () 」が主なものであり、仮想デストラクタは明らかな理由で必要であり、FTypeは引数の型と戻り値に関してmainメソッドのセマンティクスを定義するだけです。



同様のクラスがあるため、関数へのポインターを使用した操作は、MyObj型のポインターを操作することで置き換えられます。 ポインタはリストまたはテーブルなどに便利に保存でき、残りは適切に初期化するだけです。 主な違いは、オブジェクトが状態を持つことができ、継承メカニズムがそれらに適用できることです。 これにより、外部ライブラリからこのコードにさまざまな既製の機能を追加する機能が大幅に拡張および簡素化されます。



このアプローチには、もう1つの重要な利点があります。直接呼び出し制御が仮想化メカニズムに転送され、コンパイラレベルでエラーや最適化の問題から保護されます。



埋め込み



実際、プログラムを最適に動作させるための最も重要なステップは、インラインコードを書くことです。 基本的に、これには、実行される命令のシーケンスがデータのランタイムに最小限に依存することが必要です。 この場合、コンパイラは、アドレスに移動(呼び出し)したり、不要なコードを捨てたりする代わりに、呼び出しの場所に関数のコードを埋め込むことができます。 同じ基準を使用すると、長いジャンプや頻繁なプロセッサキャッシュの変更を回避してマシンコードを収集できますが、これはまったく別の話です。



残念ながら、この場合、ユーザーデータのアクションの選択には明らかな問題があります。 このプロセスは仮想化のメカニズムに移行されており、次に行うことは、他のすべてが組み込まれていることを確認することです。 これを行うには、サードパーティの機能の継承と呼び出しを適用して、オーバーロードされたメソッド内で転送する必要があります。 この場合、それらを正常に統合および最適化できます。



継承



最初のステップは、抽象クラスの継承を直接処理することです。 最も簡単な方法は、継承中にオペレーターを手動でオーバーロードすることです。 例:



 struct : public MyObj { int operator()( int a, int b ) override { return a + b; }; }addObj; // manually inherited structure MyObj* po = &addObj; int res = (*po)( a, b );
      
      





この場合、仮想メソッドの最適化された呼び出しは、すぐに2つの数値の加算に移行することがわかります。 O2を最適化すると、MSVSは*(レジスタの準備、引数のスタック)を呼び出すためのおよそ次のマシンコードを生成します。



 push dword ptr [b] mov eax,dword ptr [esi] mov ecx,esi push dword ptr [a] call dword ptr [eax]
      
      





そして実際にオーバーロードされたメソッドのためのそのようなコード:



 push ebp mov ebp,esp mov eax,dword ptr [a] add eax,dword ptr [b] pop ebp ret 8
      
      





*最初の部分は、呼び出し自体のセマンティクスのみに依存するため、すべてのケースで完全に同一です。したがって、このコードはさらに欠落します。 この記事では、常にres = (*po)(a, b);



オプションを使用しますres = (*po)(a, b);







場合によっては、最適化がさらに優れている場合があります。たとえば、g ++は整数の折りたたみを2つの命令(lea、ret)に圧縮できます。 この記事では、簡潔にするために、Microsoftコンパイラーで取得した例に限定しますが、Linuxの下でg ++でもコードがテストされたことに注意します。



ファンクター



論理的な継続とは、「サードパーティの機能で実装された複雑なコードを実行する必要がある場合はどうなるか」という質問です。 当然、このコードはMyObj後継者のオーバーロードメソッド内で実行する必要がありますが、ケースごとに独自の(匿名であっても)クラスを手動で作成し、オブジェクトを初期化してアドレスを渡す場合、理解しやすさとスケーラビリティについても覚えておく必要はありません。



幸いなことに、C ++にはこのための優れたテンプレートエンジンがあります。これは、コードのコンパイル時の解決、したがって埋め込みを意味します。 したがって、ファンクターがパラメーターとして受け取る単純なテンプレートを作成し、匿名のMyObj継承クラスを作成して、オーバーロードされたメソッド内で結果のパラメーターを呼び出すことができます。



しかし(もちろん「しかし」)、ラムダや他の動的オブジェクトはどうでしょうか? C ++のラムダは、その実装と動作を考慮して、関数としてではなくオブジェクトとして正確に認識される必要があることに注意してください 。 残念ながら、C ++のラムダ式はテンプレートパラメーターの要件を満たしていません。 彼らは17番目の標準でこの問題を修正したいと考えており、それがなくてもすべてがそれほど悪いわけではありません。



ここでシンプルでとても楽しい解決策が探られました 。 本質的には、タンバリンとさらに踊りながら、関数への引数として動的オブジェクトを正直に転送することにありますが、コンパイラはこのコードを非常にうまく最適化し、必要なものをすべて埋め込むことができます。



結果として、必要な結果を与えるラッパークラスとラッパー関数という小さなペアを作成できます。



 template<class Func> class Wrapping : public MyObj { Func _f; public: Wrapping( Func f ) : _f( f ) {}; int operator()( int a, int b ) override { return _f( a, b ); } }; template<class Func> Wrapping<Func>* Wrap( Func f ) { static Wrapping<Func> W( f ); return &W; }
      
      





ポインターを初期化するには、Wrap関数を呼び出して、目的のオブジェクトを引数として渡すだけです。 さらに、ファンクターの概念の特性により(およびこれはまさにそれでの作業です)、引数は、実行可能なオブジェクトであるか、または異なるタイプであっても、対応する引数の数を持つ関数になります。



呼び出しの例は次のとおりです。



 po = Wrap( []( int a, int b ) {return a + b; } );
      
      





複雑なビューにもかかわらず、オーバーロードされた演算子「 () 」の一連の命令は非常に単純で、実際には手動の継承と埋め込みによって得られるものと同一です。



 push ebp mov ebp,esp mov eax,dword ptr [a] add eax,dword ptr [b] pop ebp ret 8
      
      





ラップが呼び出されると、すべての複雑な条件付き遷移と初期化が発生します。その後、仮想メソッドを呼び出すためのメカニズムのみが残ります。 さらに、静的オブジェクトを使用した作業が行われています。つまり、ヒープとロングジャンプの呼び出しがないことが期待されます。



ほぼすべてのインスタンスを埋め込むことができるのは興味深いです。 例のコード:



 struct AddStruct { int operator()( int a, int b ) { return a + b; } }; ... op = Wrap( AddStruct() );
      
      





オーバーロードオペレーター用の次のマシンコードがあります。



 push ebp mov ebp,esp mov eax,dword ptr [a] add eax,dword ptr [b] pop ebp ret 8
      
      





つまり 手動インストールと同じです。 newを介して作成されたオブジェクトでも、同様のマシンコードを取得できました。 ただし、この例を残しておきます。



機能



上記のコードには、通常の機能に関して重大な問題があります。 このラッパーは、型の関数へのポインターを引数として簡単に受け入れることができます。



 int sub( int a, int b ) { return a + b; }; ... po = Wrap( sub );
      
      





ただし、オーバーロードされたメソッドのマシンコードには、それぞれ遷移を伴う別の呼び出しがあります。



 push ebp mov ebp,esp push dword ptr [b] mov eax,dword ptr [ecx+4] push dword ptr [a] call eax add esp,8 pop ebp ret 8
      
      





つまり、特定の状況(つまり、関数とオブジェクトの性質が異なる)により、関数をこの方法で構築することはできません。



セマンティクスが同じ関数



記事の最初に戻ると、埋め込みのために、テンプレートパラメーターを介して目的のオブジェクト(この場合は関数)を渡すことができることを思い出してください。 そして今、関数ポインタに対してのみ、このアクションが許可されています。 呼び出されたメソッドのセマンティクスを定義する抽象クラスで定義された型を使用すると、そのような関数専用の「ラッパーとラッパー」のペアを簡単にオーバーロードできます。



 template<class Func, Func f> struct FWrapping : public MyObj { int operator ()( int a, int b ) override { return f( a, b ); } }; template<MyObj::FType f> FWrapping<MyObj::FType, f>* Wrap() { static FWrapping<MyObj::FType, f> W; return &W; }
      
      





次の形式の関数のオーバーロードされたラップのラップ:



 int add( int a, int b ) { return a + b; } ... po = Wrap<add>();
      
      





手動継承で取得したものと同一の最適なマシンコードを取得できます。



 push ebp mov ebp,esp mov eax,dword ptr [a] add eax,dword ptr [b] pop ebp ret 8
      
      





優れたセマンティクスを持つ関数



最後の質問は、埋め込みに必要な関数がMyObjで宣言された型と一致しない場合に残ります。 この場合、ラッパー関数の別のオーバーロードを簡単に追加できます。このオーバーロードでは、型は別のテンプレートパラメーターとして渡されます。



 template<class Func, Func f> FWrapping<Func, f>* Wrap() { static FWrapping<Func, f> W; return &W; }
      
      





この関数を呼び出すには、転送される関数のタイプを手動で示す必要がありますが、これは必ずしも便利ではありません。 コードを簡素化するには、 decltype( )



キーワードを使用できます。



 po = Wrap<decltype( add )*, add>();
      
      





decltype



後に「 * 」を付ける必要があることに注意することが重要decltype



。そうしないと、開発環境では、これらの引数を満たすWrap実装の欠如に関するエラーメッセージがdecltype



される場合があります。 これにもかかわらず、ほとんどの場合、プロジェクトは正常にコンパイルされます。 この矛盾は、テンプレートに渡すときに型を決定するための規則と、実際にはdecltype



操作の原理によってdecltype



します。 エラーメッセージを回避するには、 std::decay



などの構造を使用して、正しい型置換を保証します。これは、単純なマクロでラップするのに便利です。



 #define declarate( X ) std::decay< decltype( X ) >::type ... po = Wrap<declarate( add ), add>();
      
      





または、エンティティを作成したくない場合は、コンプライアンスを手動で追跡します。



もちろん、少なくとも型変換が必要なので、そのような関数を埋め込むときのマシンコードは異なります。 たとえば、次のように定義された関数を呼び出す場合:



 float fadd( float a, float b ) { return a + b; } ... op = Wrap<declarate(fadd), fadd>();
      
      





これは逆アセンブラーから出てきます:



 push ebp mov ebp,esp movd xmm1,dword ptr [a] movd xmm0,dword ptr [b] cvtdq2ps xmm1,xmm1 cvtdq2ps xmm0,xmm0 addss xmm1,xmm0 cvttss2si eax,xmm1 pop ebp ret 8
      
      





一緒に機能する



Wrap関数の追加のオーバーロードを受け取って他の関数を埋め込んだ後、ZenにアプローチしてZenを呼び出すために別のオプションを呼び出すことにより、オプションの1つをオーバーライドできます。



 template<class Func, Func f> FWrapping<Func, f>* Wrap() { static FWrapping<Func, f> W; return &W; } template<MyObj::FType f> FWrapping<MyObj::FType, f>* Wrap() { return Wrap<MyObj::FType, f>(); }
      
      





Wrap関数の3つのオーバーロードはすべて同時に存在できることに注意してください。テンプレートパラメータは、関数の引数としてポリモーフィズムに関する同じ規則に従うためです。



すべて一緒に



上記の結果として、50行未満の場合、十分に近い*セマンティクスを持つ実行可能オブジェクトと関数を、必要なプロパティの追加と実行可能コードの最大埋め込みを備えた統合型に自動的に変換できるメカニズムを取得しました。



*この例に十分近いということは、引数の数が一致し、一致または暗黙的な型変換の可能性があることを意味します。



 struct MyObj { using FType = int( *)(int, int); virtual int operator() ( int a, int b ) = 0; virtual ~MyObj() = default; }; template<class Func> class Wrapping : public MyObj { Func _f; public: Wrapping( Func f ) : _f( f ) {}; int operator()( int a, int b ) override { return _f( a, b ); } }; template<class Func, Func f> struct FWrapping : public MyObj { int operator ()( int a, int b ) override { return f( a, b ); } }; template<class Func> Wrapping<Func>* Wrap( Func f ) { static Wrapping<Func> W( f ); return &W; } template<class Func, Func f> FWrapping<Func, f>* Wrap() { static FWrapping<Func, f> W; return &W; } template<MyObj::FType f> FWrapping<MyObj::FType, f>* Wrap() { return Wrap<MyObj::FType, f>(); } #define declarate( X ) std::decay< decltype( X ) >::type
      
      





このメカニズムの潜在的な問題は、異なる数の引数または既約(暗黙)型で関数を「ラップ」する必要があることです。 特定の解決策は、ラップされたラムダ内でそのような関数(ファンクター)を呼び出すことです。 例:



 int volume( const double& a, const double& b, const double& c ) { return a*b*c; }; ... po = Wrap( []( int a, int b )->int { return volume( a, b, 10 ); } );
      
      





コード例はこちらです。 ビルドするには、C ++ 11を使用する必要があります。 埋め込みの違いを明らかにするために-O2最適化。 コードは、不必要な埋め込みを避けるように準備されています。

______________________________



追加





コメントには、私が答えようとするいくつかの重要な質問がありました。



1) std::function



違い:




まず第一に、タスクの本質はコール制御を仮想化メカニズムに転送することでした(そのような理由は記事の範囲外です)。 したがって、 Wrap



関数のすべてのバリアントは、継承されたクラスを生成する方法として正確に解釈する必要があります。 したがって、各呼び出しは「手動」で完了する必要があります-ループ内で関数を使用すると、プログラムが正しく動作しなくなります。 これは、同じstd::function



と比較して欠点です。



2番目の重要な点-この実装は、動的メモリでは機能しません。 これにより、パフォーマンスの面で潜在的な利点が得られます。 さらに、実際に動的メモリを引き続き使用する必要がある場合、 new



演算子を追加して、各関数内の数行を変更するだけで済みます。 ただし、この場合、メモリのクリーニングを制御する必要があります(これは自動的に行われます)。

このアプローチでは、実行可能コードを埋め込むことが可能です( std::function



で起こることと同様に、同時に、仮想呼び出しメカニズムは機能し続けます。



2)状態が異なる同じタイプのオブジェクトの繰り返し呼び出し中にWrap



正しくWrap



ない




気配りのある人々のおかげで、同じタイプのファンクターを処理するときに異なる状態の異なるインスタンスが転送されると、正しくない(明らかではない)コードが動作する可能性を本当に逃しました。 この場合、 Wrapping



クラスの静的オブジェクトは、最初の引数で1回だけ初期化されます。 他のすべての呼び出しは効果がありません。



最初に行うことは、このような状況に対する保護を追加することです。 これを行うには、次のように、フラグを簡単に追加し、再初期化を試みるときに例外をスローできます。

 template<class Func> Wrapping<Func>* Wrap( Func f ) { static int recallFlag{}; if( recallFlag ) throw "Second Wrap of the same type!\n"; recallFlag++; static Wrapping<Func> W( f ); return &W; }
      
      





(スローの非標準オブジェクトをおIびします)



ただし、これは問題の解決策ではなく、事故の場合の警告にすぎません。



シンプルで効果的な解決策は、Wrap関数のテンプレートにパラメーター(デフォルト値)を追加することです。 必要に応じて、このパラメーターを変更すると、別の静的ラッピングインスタンスを使用して、関数の別の実装がそれぞれ呼び出されます。
 template<int i = 0, class Func> Wrapping<Func>* Wrap( Func f ) {...}
      
      







その後、同じ型の引数を呼び出すたびに、パラメーターに新しい値を渡す必要があります。 これを手動で行うのはやや不便です。 いくつかの解決策があります。

-定義済みマクロ__COUNTER__または__LINE__を使用して小さなマクロを追加します。

-上記のマクロに基づいて特定のテンプレートカウンターを収集します。

- 難解なエキゾチックな道を進み、純粋なテンプレートカウンターを収集します。



最初のソリューションは、非常に信頼性が高くシンプルです。 ただし、異なるファイルでラップを使用する場合、__ LINE__マクロは同じ結果をもたらす可能性があり、__ COUNTER__マクロは標準ではありませんが、ほとんどのコンパイラに実装されていることに注意してください。 プログラムの他のモジュールが何らかの形でこのマクロを使用し、それに対する唯一の権利を必要とする場合にも、競合が発生する可能性があります。 一般的に、ソリューションは次のようになります。
 #define OWrap( ... ) \ Wrap<__COUNTER__>( __VA_ARGS__ )
      
      





さらに、関数引数の下で単純なWrap呼び出しのマクロを定義できます。
 #define FWrap( ... ) \ Wrap<declarate( __VA_ARGS__ ), __VA_ARGS__>()
      
      







2番目のオプションは、たとえばここおよびここからのソリューションを使用して実装できます 。 その後、同じ方法でマクロ内の結果を置き換えることができます。



最後に 、しかし私の意見では-最も興味深いオプションはこの記事に触発されています。 実際、これはかなり微妙な実装ですが、完全にC ++ 11標準のフレームワーク内にあります。 その結果、次のような追加のマクロを使用せずに、カウンターをテンプレートに直接置き換えることができます。
 template<int i = next(), class Func> Wrapping<Func>* Wrap( Func f ) {...}
      
      





ここで、 next()



はテンプレートカウンターの実装です。



このようなコードを実稼働環境に投入する前に、3回考えてすべての利用可能な従業員に尋ねる必要があることは注目に値しますが、結果は非常に興味深く有用です。 このメカニズムの詳細な説明と実装については、次の追加記事または別の記事で説明します。



All Articles