std::string の正体(gcc-4.4.3)と細かい話

# 環境依存な内容な上,無駄に細かい話なので,「そういうこともあるかもねー」くらいに流しちゃってください.

(追記 2011-01-11)新しい規格では std::string の Copy on Write(CoW: 書き込み時に複製)が実質禁止になるとのことです.後,gcc 4.5 の時点で CoW はやめてしまうみたいですし,「そんな時代もあった」くらいに軽く流しちゃってください.id:gintenlabo さん,コメントありがとうございます.

(追記の続き)個人的には,std::string の CoW 動作は挙動が分かりにくくなるので止める方に賛成です.でも,std::vector なんかを拡張するときはどうするのかな…?コピーしてしまうのか,swap() を使うようにするのか….

(さらに追記 2011-01-11)おおっと,ムーブコンストラクタにムーブ代入演算子なんてものが….

Google Code Archive - Long-term storage for Google Code Project Hosting. を実装していて,std::string のメモリ消費は計算が難しいなーと思い,少し調べてみました.後は,その結果から考えた内容になっています.

std::string の正体について

結論から入ります.std::string の正体はポインタで,参照先のオブジェクトが参照カウンタを持っていました.std::tr1::shared_ptr みたいな感じです.

参照カウントを持っているわけですから,std::string のコピー・代入については,ポインタのコピー・代入と参照カウントの操作くらいしかしないことになります.そして,共有されている文字列に対して編集が発生しかねない操作をおこなうと,文字列の複製が作成されるようになっています.

std::string str = "abc";

// この時点では,文字列の複製は作成されません.
std::string str2 = str;

// std::string::operator[]() は編集可能(非 const)な参照を返すので,
// この時点で文字列の複製が作成されることになります.
char c = str[0];

append() や operator+=() なんかは編集するのが明らかで分かりやすいのですが,上述の operator[]() や begin(), end() などは油断していると編集が発生しかねない操作であることを忘れてしまいがちです.ちょっとした罠かも….というわけで,const は大事です.

(追記 2011-01-11)CoW がなくなるのであれば,コピーの段階で文字列が複製されるので,こんなことを気にする必要はなくなりそうです.

std::string を受け取る関数について

基本的に,引数の型は const std::string & にするのが妥当だと思います.でも,もしかすると,参照にしない方が良いこともあるのかもしれません.

# おすすめはしません.止めておいた方が良いと思います.

std::string が std::tr1::shared_ptr のような存在であるならば,普通に std::string で受け渡しをおこなっても,オーバーヘッドはほとんど無視できそうです.ただし,迂闊なことをすると文字列の複製が作成されてしまうという罠があるので,const std::string にしておいた方がよさそうです.少しばかり胡散臭いのは気にかかりますが….

void Func(const std::string &str) {
  str[0];  // 当然,大丈夫です.
}

void Func1(std::string str) {
  str[0];  // おおっと.思わぬところで時間がかかっているのかも…?
}

void Func2(const std::string str) {
  str[0];  // こちらは大丈夫です.
}

(追記 2011-01-11)std::string の CoW 動作がなくなると下の 2 つは文字列の複製をつくってしまいます.やっぱり参照を使いましょう.

std::string の複製について

ときに,std::string をそのまま複製するより,std::string::c_str(), std::string::length() を使って明示的に文字列の複製を作成した方が良い場合もあります.とりあえず,二種類の複製方法を比べてみましょう.

std::string str;

// 文字列は str, str2 により共有された状態になります.
std::string str2(str);

// 文字列の複製を明示的に作成し,str, str3 を独立した存在にします.
std::string str3(str.c_str(), str.length());

わざわざ後者を使うべきときとは,marisa::Trie::predict() のような関数の場合です.すなはち,std::string に細かい変更(operator+=() による延長と resize() による短縮)を繰り返し,その途中経過を std::vector<std::string> に格納していくようなときです.なんというか,すごく稀なケースです.

以下に例を用意してみました.

void FuncX(std::vector<std::string> *keys) {
  // 文字列 "0123456789" が作成されます.
  std::string str = "0123456789";

  // "012" に短縮します.ただし,割り当てられている領域はそのままです.
  str.resize(3);

  // 1. 複製によって str は共有状態になります.このとき,keys に格納される
  //    文字列には,"0123456789" を格納できるだけの領域が割り当てられています.
  //    次に str を編集したタイミングで,str 用に新たな領域が割り当てられます.
  keys->push_back(str);

  // 2. こちらは "012" 用に領域を確保し直して keys に格納します.
  //    str には影響が発生しません.
  //    (追記 2011-01-11)std::string() が抜けていたので追加しました.
  keys->push_back(std::string(str.c_str(), str.length()));

  ...
}

続けて str を編集していく場合,2. の方が適切です.その理由は,無駄に大きな領域を割り当てた文字列を keys に格納しないことと,str に割り当てられている領域を狭くしないことです.1 つ目の理由は,メモリ消費を抑えることにつながり,2 つ目の理由は,メモリの確保・解放によるオーバーヘッドを抑えることにつながります.

marisa::Trie::predict() の場合,2. の方法に切り替えることで,処理時間を平均 4% くらい短くできました.とはいえ,こういうチューニングには手を出さないのが無難だと思います.自身の環境では改善されても,他の環境では悪化しかねませんから….

(追記 2011-01-11)先頭にも書きましたが,std::string の CoW はなくなるもようです.そうなってくると,上記の違いはなくなると予想されます.

(追記 2011-01-11)えーっと,後者は,push_back() の前にコンストラクタでオブジェクトを作成して,さらに push_back() の中でコピーコンストラクタが…ということで二重になります.str.c_str() だけで渡すと NULL 文字が入ると困るし,結局のところ,そのまま渡すの(前者)が妥当でしょうか.