こんにちは。田原です。
今回はパラメータ・パックの展開方法についてもう少し解説します。1つはパラメータ・パックを加工して展開できるお話、もう一つは再帰を使わないで関数パラメータ・パックを展開する方法です。後者を使えるケースでは仕組みを把握すれば再帰よりはメンテナンスしやすいように感じます。(再帰はちょっと難しいですし。)
1.パラメータ・パックの加工について
まずは、前々回の2-1.パラメータ・パックで説明した...の付け方のルールの2.と3.の補足です。
サンプル・ソースも第14回のものを使いますが、解説しやすいよう少し修正しています。
①改行のためprintLogImpl()にstd::cout << "\n";を追加しました。
②パラメータ名をちゃんと付けました。
③時刻を削除しました
#include <iostream>
void printLogImpl()
{
std::cout << "\n";
}
template<typename tFirst, typename... tRest>
void printLogImpl(tFirst first, tRest... rest)
{
std::cout << first;
if (sizeof...(rest)) std::cout << ", ";
printLogImpl(rest...);
}
template<typename... tTypes>
void printLog(tTypes... iParams)
{
printLogImpl(iParams...);
}
int main()
{
printLog(1, 1.2, "three");
}
1, 1.2, three
1-1.テンプレート・パラメータ・パックの加工
ルールは簡単です。テンプレート・パラメータ・パック名に加工を施して、後ろに...を付けるだけです。
定番の加工としては、const&を追加するものです。パラメータをconst参照で受け取れば、一時オブジェクト、変数の両方をコピーせずに受け取れます。
第14回のサンプルはそれを考慮していませんので、実はパラメータをコピーして受け取っています。
int型等の場合はコピーした方が効率が良いので問題ないですが、クラスのインスタンスでサイズが大きいような場合はコピーは頂けません。このようなときのためにconst参照を使います。
テンプレート・パラメータ・パック名の後ろに const& を追加し、その後ろに...を付けています。
#include <iostream>
void printLogImpl()
{
std::cout << "\n";
}
template<typename tFirst, typename... tRest>
void printLogImpl(tFirst const& first, tRest const&... rest)
{
std::cout << first;
if (sizeof...(rest)) std::cout << ", ";
printLogImpl(rest...);
}
template<typename... tTypes>
void printLog(tTypes const&... iParams)
{
printLogImpl(iParams...);
}
int main()
{
printLog(1, 1.2, "three");
}
結果は同じなので見た目では何が変わったか分かりませんね。
そこで、いつものTYPENAMEマクロで型を表示してみます。その際、第11回で説明したように単にTYPENAMEマクロに渡すとtypeid()の効果のため、constや&が消えてしまいますので、ダミーのクラス・テンプレート(Type<tType>)を介します。
#include <iostream>
#include "typename.h"
template<typename tType>
struct Type { };
void printLogImpl()
{
std::cout << "\n";
}
template<typename tFirst, typename... tRest>
void printLogImpl(tFirst const& first, tRest const&... rest)
{
std::cout << first << "[" << TYPENAME(Type<decltype(first)>) << "]";
if (sizeof...(rest)) std::cout << ", ";
printLogImpl(rest...);
}
template<typename... tTypes>
void printLog(tTypes const&... iParams)
{
printLogImpl(iParams...);
}
int main()
{
printLog(1, 1.2, "three");
}
1[Type<int const&>], 1.2[Type<double const&>], three[Type<char const (&) [6]>]
因みにconst&を追加しなかった場合の実行結果は次のようになります。const参照になっていませんのでコピーが発生しています。(とはいえ、int型やポインタ型のコピーは問題ないです。大きなクラスなどを渡した時にコピーコストが気になります。)
1[Type<int>], 1.2[Type<double>], three[Type<char const*>]
前者で、”three”をconst&で受け取ると型がchar const (&) [6]になっていることにちょっと驚くかも知れません。
まず、”three”はchar型の配列です。文字数が5文字でNULL終端なので6文字の配列となります。そして、第13回で解説したように配列の参照は 型(&)[要素数]となります。リテラル文字は変更出来ませんので、”char const”型です。従って、”char const (&)[6]”となります。そして、後者のconst参照で受け取らない場合は、配列は先頭へのポインタで受け取りますので、char const*となります。
文字列自体はコピーしていないのでコピーコストが無視できてしまいますが、もし、std::stringを渡してコピーするとコピーコストが気になります。(ヒープメモリ獲得コストもかかりますし。)
1-2.関数パラメータ・パックの加工
ルールはテンプレート・パラメータ・パックの加工と同じです。関数パラメータ・パック名に加工を施して、後ろに...を付けるだけです。
この加工はびっくり高度な加工ができます。例えば関数呼び出しもできます。
サンプルを関数呼び出しで処理してみましょう。
#include <iostream>
#include "typename.h"
template<typename tType>
struct Type { };
template<typename... tTypes>
void printLogImpl(tTypes const&...)
{
}
template<typename... tTypes>
void printLog(tTypes const&... iParams)
{
printLogImpl(std::cout << iParams << " [" << TYPENAME(Type<decltype(iParams)>) << "]\n"...);
}
int main()
{
printLog(1, 1.2, "three");
}
この結果はWandboxのgccでは次のようになりました。
three [Type<char const (&) [6]>] 1.2 [Type<double const&>] 1 [Type<int const&>]
期待したのとは順序が逆ですが、渡したパラメータ全てに対してprint()関数が呼ばれています。
printLogImpl()はprintLog()と同じ形式なので、いくらでもパラメータを渡せます。そこに、関数パラメータ・パックを加工して渡しています。
その結果、main()関数からのprintLog(1, 1.2, "three");呼び出しは、コンパイラが次のように展開し、全てのパラメータが処理されます。
printLogImpl
(
std::cout << 1 << " [" << TYPENAME(Type<decltype(1)>) << "]\n",
std::cout << 1.2 << " [" << TYPENAME(Type<decltype(1.2)>) << "]\n",
std::cout << "three" << " [" << TYPENAME(Type<decltype("three")>) << "]\n"
);
しかし、逆になってしまうのは何故でしょう?
関数呼び出しのパラメータの評価順序は規定されていません。コンパイラを書いた人が自由に決めることができます。パラメータはスタックに積まれる処理系が多いのですが、その際、後(右)から順に積んでいくと、最初のパラメータがアドレスが一番若いところに積まれるので処理しやすいです。そのため、後から順に積む処理系が多いです。そして、スタックに積む直前に処理すると都合が良いのです。その結果、関数に渡すパラメータが後から評価される処理系が少なくありません。
ところで、よく見ると再帰がなくなってますね。そう、関数パラメータ・パックを展開する際に関数呼出しするように加工すれば、渡された全てのパラメータを処理できるわけです。なんだ再帰しなくても処理できるのですね。
2.再帰を使わないで関数パラメータ・パックを展開する方法
1-2.の関数呼び出しで展開すると処理順序が処理系に依存してしまい、多くの処理系では逆順に処理されてしまいます。できれば、処理系に依存せず頭から順番に処理したいです。
ネットを探しているとありました!!
C++のパラメータパック基礎&パック展開テクニックの記事によるとSwallowと呼ばれるテクニック(イデオム?)を使うとお手軽かつスマートに展開できます。
この記事では、std::initializer_listを使ってますが、私としては配列を初期化する方法をお勧めします。追加のインクルード不要で、必要な部分だけに記述すれば良いのでお手軽ですし、記述量も少ないですから見通しも良いです。
2-1.Swallow(飲み込む)テクニックについて
まずは、あまり知られていない , カンマ演算子です。+ や – などと同じく式の中で使えます。
, で区切って複数の部分式を書くことができ、その結果は最後に書いた部分式の値となります。
int x = (1+2, 0); std::cout << x << "\n";
カンマ演算子の優先順位は非常に低いので多くの場合、()で括らないとコンパイルエラーになったり、意図しない結果になったりしますので、注意が必要です。
0
(上記の式はgccでは「カンマの左側は何の効果もない」の意味の警告がでます。その通りですね。でも、今は気にしないでください。)
さて、上記の 1+2 の部分に式を書けるということは、関数を呼び出したり、std::cout << 123と書いたりできます。後者は文のように見えますが、式です。 std::ostreamのoperator<<()という関数を呼び出します。この関数はstd::ostreamの参照を返却します。
int x = (std::cout << 1+2 << "\n", 0); std::cout << x << "\n";
xを(std::cout << 1+2 << "\n", 0)で初期化します。その時、std::cout << 1+2 << "\n"が実行され、その後カンマの右側の値0がxに初期設定されます。
3 0
(因みに、今度はカンマの左側はそれなりに処理をしているので「何の効果もない」警告はでません。)
次に、配列の初期化は次のように書けますね。
int dummy[]
{
1,
2,
3
};
これの各要素(1, 2, 3)の部分に先程のカンマ演算子をはめ込んでみます。
int dummy[]
{
(std::cout << 10 << "\n", 1),
(std::cout << 20 << "\n", 2),
(std::cout << 30 << "\n", 3)
};
for (auto x : dummy)
{
std::cout << x << "\n";
}
10 20 30 1 2 3
このように実行順序は定義順となります。これは配列の初期化子の評価順ですので標準規格上保証されています。
さて、このdummyは実体に名前を付けているので少し鬱陶しいです。似たような複数の処理を同じ{}ブロック内で実装する場合、異なる名前を付けたり、{}ブロックで改めて囲んだりすることになります。
int[]{1, 2, 3};ができれば良いのですが、これはどうやらできないようです。
しかし、型名に名前をつければ良いみたいです。(型名は使い回しできます。)
using swallow=int[];
swallow
{
(std::cout << 10 << "\n", 0),
(std::cout << 20 << "\n", 0),
(std::cout << 30 << "\n", 0)
};
gccでは警告はでないのですが、clangでは「配列を定義したけど使っていない」旨の警告がでるようです。確かにその通りですね。voidでキャストすることで警告を消せます。C言語スタイルのキャストはあまり使わない方がよいのですが、キャストした結果を使うわけではないのでスッキリ記述するためにCスタイルでキャストしてます。
#include <iostream>
int main()
{
using swallow=int[];
(void)swallow
{
(std::cout << 10 << "\n", 0),
(std::cout << 20 << "\n", 0),
(std::cout << 30 << "\n", 0)
};
}
10 20 30
2-2.Swallowテクニックを使って関数パラメータ・パックを展開する
ここまで見ればだいたい見当が付くかと思いますが、関数パラメータ・パックを上記テクニックにて展開してみます。
#include <iostream>
#include "typename.h"
template<typename tType>
struct Type { };
template<typename... tTypes>
void printLog(tTypes const&... iParams)
{
using swallow = int[];
(void)swallow
{
(std::cout << iParams << " [" << TYPENAME(Type<decltype(iParams)>) << "]\n", 0)...
};
}
int main()
{
printLog(1, 1.2, "three");
}
1 [Type<int const&>] 1.2 [Type<double const&>] three [Type<char const (&) [6]>]
カンマ区切りで1行に収めることもできます。(TYPENAMEがあると見にくいので削除)
#include <iostream>
template<typename... tTypes>
void printLog(tTypes const&... iParams)
{
bool first=true;
using swallow = int[];
(void)swallow
{
(std::cout << (first?"":", ") << iParams, first=false, 0)...
};
std::cout << "\n";
}
int main()
{
printLog(1, 1.2, "three");
}
1, 1.2, three
3.まとめ
今回はパラメータ・パックの加工例と再帰を使わない関数パラメータ・パックの展開方法を解説しました。後者はヘルパー関数テンプレートが不要となり単純化できました。
記事中で参照したQiitaの_Enumhackさん(twitterでは「いなむ先生」と呼ばれているようです)の記事を見るまで知らなかったのですが、これは本当に便利ですね。
そして、この記事はパラメータ・パックについてより網羅的に纏められています。より広く把握されたい方は是非参照されて下さい。きっと知的好奇心を刺激されると思います。
さて、これ以上パラメータ・パックを引っ張るのもどうかと思ったのですが、もう少し解説させて下さい。関数パラメータ・パックは大胆な加工ができてびっくりだったのですが、テンプレート・パラメータ・パックも意外に大胆な加工が可能です。次回、そのあたりを解説してみたいと思います。お楽しみに。

>もし、std::stringを渡してコピーするとコピーコストが気になります。(ヒープメモリ獲得コストもかかりますし。)
これに関してですが、C++の代表的なコンパイラは
SSO(Short String Optimization)という最適化を備えており、
十分に短い文字列のstd::stringへのコピーはアロケートを省く事ができます。
(gccで15バイト, clangで22バイトまで有効という情報あり、詳しくは以下を参照のこと)
https://qiita.com/melpon/items/caf4a2bd74968db7032f
コメント、ありがとうございます。
SSOが効いている時はヒープを獲得しないので効率的とはいえ、ポインタを1つコピーするのに較べるとコピー・コストは大きいですね。
SSOについてのmelponさんの解説、判りやすいですよね。私もそこでSSOのことを把握しました。実は応用講座の第13回の記事でちょっと触れています。
>実は応用講座の第13回の記事でちょっと触れています。
あ、これは大変失礼しました。
Pingback: C++でrangeを用いたPython風の繰り返し処理を実装する