野良C++erの雑記帳 このページをアンテナに追加 RSSフィード Twitter

2010-12-10 かていほうげんざい

One-Phase Construction 入門 〜 Constructor run once.

導入

皆さんは C++ と聞いて、まず何を思い浮かべますか?


C++? そんなの過去の遺物だろ? GC ないとか原始的すぎるじゃん?」

って思う人もいれば、

「いや、 C++ って無闇に複雑すぎて使えない。正直 C 言語で十分でしょ」

って人もいるでしょう。

が、今回は、そういう「不便だ」「キモい」「でも迂闊に dis ると闇の軍団怖いし…」的な論争は*1とりあえず置いておくことにします。

代わりに、

  • 「デストラクタや const は便利だよ!」
  • 「 C 言語と連携が取りやすいのがいいね」
  • 「それ以上に、ゼロオーバーヘッドだし、いざとなれば効率化できるのが素晴らしい」
  • 「普段は効率化なんて意識しなくても使えるしね」

的な「 C++ を使う利点」を考えてみると、

テンプレートと、その応用例である STL が、 C++ を使う利点の中でも特に大きい、

そのことに異論を挟む人は少ないと思います。


しかし、その便利な STL コンテナですが、 C++98/03 の段階では、微妙に使いづらい点があります。

それは「コピーできないクラスを扱えない」というもので、

自分で作ったクラスを std::map や std::list で扱いたいけど、コピーは綺麗に実装できない、という場合には、

  • new によって作ったオブジェクトboost::shared_ptr に入れて使う
  • それだと効率が悪いから、諦めて自分でコンテナを書く

といった事をしなければいけません。


勿論、通常は shared_ptr のコストなんて微々たるものですから、 shared_ptr を使えばそれで済む問題ではあります。

しかし、よく考えると、 std::map や std::list といったコンテナの内部に構築されたオブジェクトは、

そのオブジェクトに対するアクセスを参照経由で行う以上、アクセスにおいてコピーは不要ですし、

また std::vector 以外の STL コンテナでは、一度構築されてしまえば、最早そこから動くことはありません。

追記:

これは std::deque の場合もそうで、流石に途中に insert/erase すれば動きますが、両端に push/pop するだけならば、コンテナ内の要素は決して動きません。

また std::vector であっても、必要な固定長の領域をコンストラクタで確保し、決して push_back 等の拡張行為を行わなければ、コンテナ内の要素を動かさずに使うことは出来ますが、そういう使い方は稀だと思います。


ではなぜ C++98/03 の STL コンテナがコピーを要求するかというと、それは構築時の都合であって、

コンテナ内部にオブジェクトを構築するとき、コピーコンストラクタを使えば、

ls.push_back( T( x, y, z, ... ) );

という形で、任意の引数からオブジェクトを構築することができる為です。

実際、 STL コンテナは「アロケータ」によってメモリ管理方法をカスタマイズ出来る設計になっているのですが、

C++98/03 の範囲では、アロケータによるオブジェクト構築は、必ずコピーコンストラクタ経由で行われます。

実際にはデフォルトコンストラクタに対応しても、まぁ悪くはないのですが、

それだと複数のオブジェクト構築関数を作ることになり、保守性が下がってしまう。

そういう実装上の都合により、 STL コンテナには、コピー可能なもののみを格納する設計になっている(いた)のです。


が、それらはあくまで、実装上の都合でしかないですよね?

原理的には、(少なくとも std::vector 以外では、)コピー出来ないものを STL で使ったって何ら問題がない以上、

そんな実装上の都合なんか知ったこっちゃ無い、俺はコピー出来ないもんだって直接 STL で扱いたいんだ、

そう思うのは とても自然なことで、また正論でもあります。


ということで、 C++0x では、二つの異なるアプローチにより、今まで STL コンテナで扱えなかったクラスも問題なく扱えるようになりました。


そのうちの一つが Move Semantics であり、これは

「コピーできないなら、移動させればいいじゃない」

という考え方を基本としています。

今回は本題ではないので細かく説明はしませんが、これは、

コードの簡潔さと安全さを保ったまま効率を改善できる、という非常に便利な物です。

しかし一方で、「移動すら許したくないクラス」というのも依然として存在する以上、これで完全に解決するかというと、そういうわけではありません。


そういう場合に役立つのが、もうひとつのアプローチである one-phase construction です。

このアプローチを使えば、コピーやムーブはそもそも行われません。

なので、「コピー/ムーブできない」というエラーは、原理的に発生しなくなります。


この記事では、そんな魔法のような手法である one-phase construction について、

その基本的な考え方を、簡単な実用例を交えて紹介しようと思います。


one-phase construction とは?

"hoge" という文字列を格納した std::string 型の自動変数 x を構築する場合を考えます。

この動作を実現するコードとしては、

std::string x = "hoge";

あるいは

std::string x("hoge");

の二通りが存在し、 C++ ではこの両者は厳密に区別されます。


前者のコードでは、

「暗黙に呼び出された std::string("hoge") というコンストラクタにより std::string の一時オブジェクトが生成され、生成された一時オブジェクトをコピーすることによりスタック上に自動変数 x を構築する」

という意味になり、後者のコードでは

「スタック上に確保された x の為のメモリ領域に、 "hoge" を引数に取るコンストラクタを『直接的に』呼び出し、自動変数 x を構築する」

という意味になります。


実際には、殆ど全てのコンパイラにおいて、両者のコードは RVO *2 により同じ動作になるでしょうが、

意味的には、この前者と後者には、明らかな違いがあります。

前者は、(意味論上では)まずオブジェクトを作り、それからそのオブジェクトをコピー(あるいはムーブ)する、

つまりコンストラクタを二度走らせることによって x を構築しますが、

後者では、(意味論上でも)コンストラクタは一度だけ、 x を直接的に初期化するためにのみ走ります。


このように、「まず一時オブジェクトを構築してから、コピー(ムーブ)することで目的のオブジェクトを構築する」という動作を行わず

オブジェクトが必要とされる領域に、一度だけコンストラクタを呼び出して直接オブジェクトを構築する

という動作のことを one-phase construction と呼びます。*3


先程の例ではスタック上に自動変数を作る例で紹介しましたが、これは別に自動変数に限りません。

全てにおいて、一時オブジェクトを作らず、コンストラクタを直接呼んで初期化するなら、それは one-phase construction です:

struct person
  : private boost::noncopyable  // noncopyable なクラス
{
  std::string name;
  int age;
  
  // name を char const* から直接つくる → one-phase construction
  person( char const* name_, int age_ )
    : name(name_), age(age_) {}
  
};

// make_shared によって作られる person オブジェクトは one-phase construction
// make_shared された結果を受け取る hoge は one-phase construction ではない
boost::shared_ptr<person> hoge = boost::make_shared<person>( "SubaruG", 25 );
// boost::in_place を使った one-phase construction
boost::optional<person> hage( boost::in_place( "Bjarne Stroustrup", 59 ) );

この定義から分かるように、基本的に new 演算子呼び出しは one-phase construction です。

またクラスメンバの初期化も、多くの場合には one-phase construction であることが分かります。


one-phase construction である利点

上記のように、 one-phase construction 自体は、非常に単純な概念です。

また、多くのケース(自動変数や new 演算子呼び出し等)では、特に意識せず自然に行っていますが、

一方で、上の例の boost::make_shared や hoge.name の初期化、 boost::optional の in_place 構築のように、意識して対応しないと one-phase construction 出来ないこともまた多い、というのも分かるはずです。

では、 one-phase construction が出来ると、何が嬉しいのでしょうか。

言い換えるなら、意識して one-phase construction に対応するだけのメリットは何処にあるのか、というのを挙げてみると:

  • one-phase construction に対応すれば、コピーやムーブが出来ないオブジェクトでも問題なく扱える
    • これが最大の利点。これによりテンプレートで扱える対象がぐっと増える
    • 同時に、無理してコピーやムーブを実装する必要がなくなるので、実装コストを抑えられる
  • 無駄な一時オブジェクトが原理的に生成されない
    • one-phase じゃなくても実際には RVO があるから大丈夫とはいえ、常に RVO が行われるとは言い切れない
    • 一時オブジェクトは寿命の管理が意外と面倒
  • 一般に、オブジェクトの構築が遅延される
    • 状況によっては「やっぱり構築しない」なんてことも出来る

といったものが挙げられます。

ただ、この中で、最後の「オブジェクト構築の遅延」については、ちょっとした危険性を抱えており、

RAII を使った資源管理を行いたい場合には、遅延されると困るのですが、

そういう場合は one-phase construction じゃなくても危険を抱えている場合が殆どなので、基本的には利点だと考えていいでしょう:

struct hoge {
  // あんまりよくない例。
  std::unique_ptr<resource> p;
  // これは原理的に危険。
  // i を計算中に例外が投げられたらどうする?
  hoge( resource* p_, int i/*, ...*/ )
    : p(p_)/*, ...*/ {}
  
  // 一般に、(one-phase construction じゃなくても)、
  // 資源を所有していない状態のポインタ等を直接扱うのは避けるべき。
};

一方でデメリットもあり、

  • 言語組み込みである new 演算子のような例外を除き、どのようにして one-phase construction を行うかを、対応させる側で決めなければいけない
    • インターフェイスを混乱させないデザインは意外と難しい
    • 既存のライブラリの対応例をこれから紹介するので、それを参考にすればいい問題ではある
  • 各々のクラスや関数で one-phase construction を実装するのは、意外と面倒
    • 少なくとも perfect-forwarding に関する知識は必要になる
    • タプルを使って実現する場合などは unpack をする必要があるが、これはトリッキー。
    • 言語によるサポートでない以上、自前でデザインし、自前で実装しなきゃいけないのは面倒

といったものですが、これらは基本的には「面倒」という一言でまとめられます。


が、これらの面倒さは、あくまでライブラリを書く人に課せられた面倒さであって、

ライブラリを使う側としては、 one-phase construction が可能になることによるデメリットはありません。

なので、ライブラリを使う人は、遠慮無く one-phase construction の利益を享受するのがいいでしょう。


Boost における one-phase construction の実現例

では、実際のライブラリで、どのように one-phase construction が使われているかを見てみましょう。


まず、 C++0x 以前、 Boost において、 one-phase construction の考え方を採用しているものは、

といった所でしょうか。


まず、 boost::flyweight<T> は、コンストラクタに渡された引数を、そのまま T のコンストラクタに渡してくれます。

boost::flyweight<std::string> s( 5, 'a' );  // "aaaaa" という文字列を、一時オブジェクトなしで構築

こういった動作をするクラスは、恐らく最もシンプルな形の one-phase construction 対応で、(特に C++0x では)面倒な実装をしなくても実現でき、しかも殆どの場合はこのパターンで済むので、最近ではかなり増えてきています。

これはクラス以外にも、 make_shared 等の関数で広く使われている手法で、便利です。

これには ValueInitialized も含まれ、これはデフォルトコンストラクタしか扱えませんが、

T x = T();

のようなコードを書く必要が無くなるので、これも広い意味では one-phase construction です。


一方で、この「引数を単純に転送する」場合では、上手くいかない場合があります。

そういう場合、何らかの形で引数を一つにパックする必要が出てくるのですが、

そんな時でも問題なく one-phase construction 出来るよう、 Boost には InPlaceFactory というライブラリが用意されています。


その詳細な動作を説明するのは今回は避けますが、これは要するにコンストラクタを呼び出すもので、

実際の使用例としては、前にも日記で書いた Boost.Optional の in_place 構築が挙げられます:

boost::optional<person> x;
x = boost::in_place( "Frandre Scarlet", 495 );

このように書けば、コピー出来ないクラスも Boost.Optional で普通に扱えるようになって、便利です。


C++0x における one-phase construction の実現例

C++0x の標準ライブラリでは、 one-phase construction の考え方が、 Boost 以上に広く用いられています。


まず STL のコンテナは全て one-phase construction に対応しています。キーワードは emplace で、

std::deque<person> xs;  // vector では move が必要になるので deque で
xs.emplace_back( "チェき", 14 );  // 直接的に構築

のように書くことで、コピー出来ないクラスも普通に格納できます。


このような引数転送による one-phase construction は、 C++0x の Variadic templates + Perfect Forward により、

ほとんど苦も無く実装することが出来るようになりました。

なので、これからは標準ライブラリと言わず、様々な局面で使われるようになるでしょう。


一方で、単純な引数転送では無理な場合に対しては、標準ライブラリ

std::tuple を使って引数を pack して渡す、というアプローチを採っています。


比較的最近 規格に入った std::pair の piecewise construction というのがそれで、

std::pair<int, person> p( std::piecewise_construct,
  std::forward_as_tuple(000), std::forward_as_tuple( "Kaori Kanzaki", 18 ) );

のようにすることで、コピー出来ないクラスも std::pair に格納できます。


これと std::map の emplace と組み合わせれば、

std::map<int, person> m;
m.emplace( std::piecewise_construct,
  std::forward_as_tuple(931), std::forward_as_tuple( "すているさん", 14 ) );

のようにして、今まで std::map では使えなかったクラスも格納できるようになります。


このように複数の one-phase construction を組み合わせれば、様々な応用が可能になり、

究極的には全ての一時オブジェクトをプログラムから排除できるようになるでしょう。

そうすることで、より汎用的かつ安全で高速に動くコードが書けるようになる、それが one-phase construction の持つ意義なのです。


まとめ

と、長々と説明してきましたが、この記事で主張したいことは、要するに:

  • one-phase construction なんて言われると難しそうだけど、そんなことはない
    • 要するに「無駄な一時オブジェクトを作らない」という考え方のこと
  • one-phase construction の考え方を使うことで、コピーできないクラスでも問題なく扱えるようになる
    • その他の利点はいろいろ調べてみると面白いかと
  • one-phase construction を実現するには、構築する側で何らかのサポートが必要

程度のことであって、そこから導かれる結論としては、


「one-phase construction は便利だから、何らかのライブラリを書くときは、 one-phase construction 出来るようになっていると、喜ぶ人が多いよ!」


ってところでしょうか。


実際問題として、 one-phase construction の考え方は、それほど目新しいものではないです。

が、それでも意識するのとしないのでは大違いなので、 C++ を書くときは、頭の片隅にでも置いておいて損はないはずです。

*1:無知に基づく的はずれな指摘もあれば、C++を使う身としても「もっともだ」頷かざるを得ない指摘もあるけど、それを語ってると何時まで経っても本題に移れないので

*2:規格によって許された「一時オブジェクトを作りコピーする、というコードを生成する代わりに、一時オブジェクトが作られる時点でその領域に直接オブジェクトを構築する(コピーは行われない)コードを生成する」という最適化。詳しくは検索してください。

*3hoge x; x.init(a,b); のような「コンストラクタ外で初期化する」場合はどう扱うべきか、という問題もありますが、今回は無視します。

y-hamigakiy-hamigaki 2010/12/10 08:15 > std::vector 以外の STL コンテナでは、一度構築されてしまえば、最早そこから動くことはありません
dequeも動きますよ。
http://ideone.com/t1Yho

gintenlabogintenlabo 2010/12/10 12:16 「 insert や erase をしない限り」と付け加えるべきでしたね。

gintenlabogintenlabo 2010/12/10 12:44 該当する機能を使わない限りはエラーにならないのがテンプレートの良いところ。

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証