array 型を引数に使うと都合がよい場合がありそうだったので確認してみた。 (Visual Studio2019 を使用。)
やりたいこと
以下のようなコードを目にしました。(mapbox/earcut.hpp より引用。 コード短縮のため一部修正。)
using Point = std::array<double, 2>;
std::vector<std::vector<Point>> polygon;
polygon.push_back({{100, 0}, {100, 100}, {0, 100}, {0, 0}});
2D多角形(穴付き)のデータを {} を用いて簡潔に書けています。
array 型って「vector などのコンテナ型と共通のインタフェースを用意する」「値型のようにコピー可能にする」という目的で用意されたと理解していました。 ですが固定長配列的なオブジェクト(端的には座標値クラスのオブジェクト)の初期化を簡潔に記述する目的でも使えそうだなと思いつきました。
そこでどのような仕様により実現されるのか、実際どう書くことになるか、確認してみました。
C++03 時代の座標値クラス
c++11 より前だったら大体こんな感じだったよね、ということで。
ざっくり以下のような目的で座標値クラスを作るとします。
- 値型として容易にコピーできるようにする。
- 演算子をオーバーロードする。
作成されるインタフェースは大体こんな感じになったのではないでしょうか。
class Vector3
{
public:
Vector3(double x, double y, double z);
Vector3& operator += (const Vector3& rhs);
private:
double m_coord[3];
};
Vector3 v0(0, 1, 2);
v0 += Vector3(1, 2, 3);
これを N 次元に拡張すると以下のような感じになるでしょうか。
template<int N>
class VectorN
{
public:
VectorN();
VectorN(const double coord[N]);
double& operator[] (int i) { return m_coord[i]; }
VectorN& operator += (const VectorN& rhs);
private:
double m_coord[N];
};
VectorN<3> v0;
v0[0] = 0; v0[1] = 1; v0[2] = 2;
double a1[3] = {1, 2, 3};
v0 += VectorN<3>(a1);
この定義だと初期化をスマートに(≒インラインで)書くことができません。いったん配列に詰めるのは手間ですし、長さの間違いが生じそうなのが気持ち悪いです。
どうしても実現するなら C の可変長引数という選択肢もありますが、少なくともこの場面では使いたいとは思えません。
ということで、N次元汎用版の座標値クラスは初期化のさせ方が難しいなあとこれまで思っていました。
座標値クラスなんて2次元版と3次元版があればいいだろうって? 俺は N 次元が欲しいんじゃ!
C++11 時代の座標値クラス
ということで C++11 時代にバージョンアップします。(こう書くと今さら過ぎて格好悪い...)
Vector3 の方は C++11 でコンパイルするだけで、 以下のように使えるようになります。
Vector3 v0{0, 1, 2};
v0 += {1, 2, 3};
ユニバーサル初期化と、implicit な複数引数コンストラクタの呼び出しが可能になったことによるものと思います。
VectorN の方ですが、以下のように定義したらどうかというのが今回の記事の本題です。
namespace cpp11 {
template<int N>
class VectorN
{
public:
VectorN(const std::array<double,N>&);
VectorN& operator += (const VectorN& rhs);
void Set(const VectorN& rhs);
private:
double m_coord[N];
};
}
VectorN<3> v0{ {0, 1, 2} };
v0 += {{1, 2, 3}};
v0.Set({{1, 2, 3}});
初見で期待したよりイマイチです...
- 2重の {} が格好悪い。 (array は引数だから2重になるのが正しいのも分かるのだが。)
- 初期化のところの2重の {} の外側は () でも可です。
- operator += のところは {} である必要があります。
- 指定した要素数が足りない場合は未指定要素に 0 が入る。 (後述)
- 要素数が多すぎる場合はコンパイルエラーになります。
まあ VectorN<3>({0, 1, 2})
みたいな書き方が可能になるだけでも良いとは言えます。
後はこう使うか。
VectorN<3> v0 = std::array<double,3>{0, 1, 2};
v0 += std::array<double,3>{1, 2, 3};
これでもインラインで要素を書けるし、不自然に見える2重 {} もなくなります。 型名がちょっと長いですが、個人的にはこれで妥協してもよいと思えます。 (でも vector<VectorN<3>>
を構築しようとなったら許容しがたいかも。)
2024/1/28 訂正
この記事作成時に以下のコードがコンパイルが通ると記述していましたが、私の見間違いでした。誤ったコードは修正し、文章は削除しました。
v0.Set({1, 2, 3});
動作の詳細
一言で言えば std::array クラステンプレートがそのように作られているからインラインで要素を書けるのですが、その中身が気になる人に向けた内容です。
std::array
cpprefjpの初期化のページに以下の記述があります。これが仕様的な肝です。
arrayクラスはpublicな配列メンバ変数を持ち、非トリビアルなコンストラクタを提供しない。そのため、arrayは集成体の要件を満たす。これにより、arrayクラスは組み込み配列と同様の初期化構文を使用して初期化を行うことができる。
具体的には Visual Studio での array の定義を見て確認します。
template <class _Ty, size_t _Size>
class array { // fixed size array of values
public:
using value_type = _Ty;
(中略)
_NODISCARD _CONSTEXPR17 const _Ty* data() const noexcept {
return _Elems;
}
_Ty _Elems[_Size];
};
array クラスはコンストラクタを持たず、データメンバが public です。 これは C 言語の構造体のようなもので、C++ では集成体と呼ぶようです。 C 言語で構造体の初期化を以下のように書くことがありますが、その類例ということです。
struct Data {
char name[256];
double value;
int value2;
};
Data d = { "x", 0 };
「C 言語では末尾に 0 を書くと後続の未指定要素は 0 になる」と覚えていたのですが、 C++ の集成体では未指定要素は全て 0 となるようです。 この辺りもう少し調べて分かったら追記します。 → 補足記事
オーバーロードがある場合の注意事項
上で定義した VectorN クラステンプレートですが、インタフェースに関しては制限があります。
{1, 2, 3}
という記述を array に暗黙で変換できることが必要ですが、この記述は initializer_list としても解釈可能です。 VectorN に initialier_list も受け取るオーバーロードが存在するとそちらが呼ばれてしまい array を引数とするコンストラクタが呼ばれません。
array を引数にする場合はオーバーロードは使わない方が障害は少ないかもしれません。
2重の {} を避けられないか?
上でも書いた通り、Vector3(const array&)
を呼び出す以上2重の {} が必要になるのは避けられないような気がします。
やるとしたら以下の案でしょうか。
- Vector3 自体を集成体にする。
- こちら [C++]集成体の要件とその変遷 のページによると、実際上コンストラクタを持てなくなるようです。 大分限定的なインタフェースのクラスになりそう。
- array オブジェクトを引数にするオーバーロードを VectorN<3> を引数にとるメンバ関数に追加する。
- 明示的にオーバーロードを追加すればいけるんじゃないかなあと。 でもオーバーロードすること自体が initializer_list と解釈されるリスクを増やしそう。
- コンストラクタの引数を可変個数にする。
- 簡単なのは array を諦めて initializer_list を受け取るコンストラクタを作ることですが、要素数に関する制約を静的に与えられなくなります。 ただこの欠点は C の配列(ポインタ)を引数にするのと同じであり、むしろ initializer_list::size() があるだけましとも言えます。 妥協としてはありかもしれません。
一番妥当そうな initializer_list を使う場合を参考まで挙げてみます。要素数が一致しないかもしれない状況下でどのようなコンストラクタ実装にするかが鍵でしょうか。
namespace cpp11 {
template<int N>
class VectorN
{
public:
VectorN(const std::initializer_list<double>&);
VectorN& operator += (const VectorN& rhs);
private:
double m_coord[N];
};
}
VectorN<3> v0{0, 1, 2};
v0 += {1, 2, 3};
ちなみに initializer_list を使うなどして一重の {} を実現したとして、オーバーロードが複数あると意図せぬ呼び出しをしてしまうこともあると思います。 そういう事故を防ぐ意味では二重の {} を敢えて避けないといった選択もあるかもしれません。
冗長さを許容するなら
多少冗長な記述を許容するなら、もう少し自然な形でインラインで要素を書けるようになります。
VectorN<3> v0 = std::array<double,3>{1, 2, 3};
VectorN<3> MakeVector(double x, double y, double z);
VectorN<3> v0 = MakeVector(1, 3, 3);
ちょっと頑張ればラッパ関数はN次元版も汎用的に作れるでしょう。きっと。(make_array ってあるかなあと検索してみましたが、標準ではないのですね。)
まとめ
- 集成体である array クラスを使うことで固定長配列を受け取りたい引数で {} を使った初期化、引数指定が可能になる。
- 要素をインラインで書いて初期化したいという目的には十分使える。
- 標準で使えるのもよい。自前で集成体を作らなくてよい。
- array を引数にとるインタフェースは、利用する側のコードの見た目についてはイマイチになる。
- コンストラクタで使う場合、単純に引数に加えるだけだと {} が2重になる(ことを避けにくい)。
- 要素数の静的チェックにこだわらないなら、array ではなく initializer_list を受け付ける方が簡単に自然なインタフェースを実装できる。
- もっと言えば N 個の固定長テンプレートにすることを諦める方が話は容易かつきれい。(多少記述は冗長になるかもしれないが。)
- 個数ごとにクラスを作って単にユニバーサル初期化を使ったり、個数ごとのラッパ関数を作ればよい。
- (このページの主旨を最初から否定してますが。)
メリットよりデメリットが多いようにも見えますが、要素数を静的にチェックできるという観点では array を引数にする手法にも使いどころはあり、見た目にこだわるなら使いにくい、そんなところを結論としたいと思います。
補足
以下に補足記事を追加しました。
参考資料
- array(cpprefjp)
- [C++]集成体の要件とその変遷
- make_array について
- qiita に C の可変長引数についてページがいくつかあったのでリンク。