StringPiece というライブラリの話

例えばこう、ディレクトリの名前とその中のファイル名を / でくぎって結合する関数を書くとします。引数が std::string でも使いたいし const char* でも使いたい、ということで、たいていは

void JoinFilePathStr(const string& dir, const string& base, string* out) {
    out->clear();
    out->append(dir);
    out->push_back('/');
    out->append(base);
}

なんてのを書くんじゃないかと思います。この関数で問題になるのは const char* を渡すと不要な string object が一度できることで、敬虔な C++ 屋さんだと、

void JoinFilePathStr(const string& dir, const char* base, string* out);
void JoinFilePathStr(const char* dir, const string& base, string* out);
void JoinFilePathStr(const char* dir, const char* base, string* out);

などと 3 パターン用意したりするかもしれません。パフォーマンスとかを考えると、 std::string の場合は文字列サイズを求めるために strlen する必要が無いとかそういう理由から、

void JoinFilePathStr(const char* dir, size_t dir_len,
                     const char* base, size_t base_len,
                     string* out);

なんてのも用意して、これを他の4つから使うことになるのかなぁ、と思います。めんどくさいです。

こういうものを解決するために Google でよく使われているのが、 StringPiece という小さいクラスです。文字列の実体の所有権は持ってないけど、文字列の先頭へのポインタとサイズを持っている、みたいな物体で、他の opensource プロジェクトに混じってリリースされたりもしています。例えば Chromium のやつはここにあります。

http://src.chromium.org/viewvc/chrome/trunk/src/base/string_piece.h

この StringPiece は std::string や const char* から暗黙の変換で変換されるようになっているため、関数のプロトタイプでこれを引数としておくと、 std::string で受けた場合と同様に std::string が入力として与えられても、 const char* が入力であっても呼び出せる感じになります。具体的にはこんな感じ。

void JoinFilePathSp(const StringPiece& dir, const StringPiece& base,
                    string* out) {
    dir.CopyToString(out);
    *out += '/';
    base.AppendToString(out);
}

int main() {
    const string& dir = "/tmp";
    const string& base = "hoge.c";
    string joined;

    // 以下はどれも結果は同じ
    JoinFilePathSp(dir, base, &joined);
    JoinFilePathSp("/tmp", base, &joined);
    JoinFilePathSp(dir, "hoge.c", &joined);
    JoinFilePathSp("/tmp", "hoge.c", &joined);
}

適当に std::string で受けた場合と比較してベンチマークしてみます。

#define BENCH(msg, expr) do {                                           \
        joined.clear();                                                 \
        time_t start = clock();                                         \
        for (int i = 0; i < 1000000; i++) {                             \
            expr;                                                       \
        }                                                               \
        int elapsed = clock() - start;                                  \
        assert(!strcmp(joined.c_str(), "/tmp/hoge.c"));                 \
        printf("%s %f\n", msg, (double)elapsed / CLOCKS_PER_SEC);       \
    } while (0)

int main() {
    const string& dir = "/tmp";
    const string& base = "hoge.c";
    string joined;

    BENCH("Str(const char*, const char*)",
          JoinFilePathStr("/tmp", "hoge.c", &joined));
    BENCH("Str(string, const char*)",
          JoinFilePathStr(dir, "hoge.c", &joined));
    BENCH("Str(const char*, string)",
          JoinFilePathStr("/tmp", base, &joined));
    BENCH("Str(string, string)",
          JoinFilePathStr(dir, base, &joined));

    BENCH("Sp(const char*, const char*)",
          JoinFilePathSp("/tmp", "hoge.c", &joined));
    BENCH("Sp(string, const char*)",
          JoinFilePathSp(dir, "hoge.c", &joined));
    BENCH("Sp(const char*, string)",
          JoinFilePathSp("/tmp", base, &joined));
    BENCH("Sp(string, string)",
          JoinFilePathSp(dir, base, &joined));
}

結果は、

Str(const char*, const char*) 0.250000
Str(string, const char*) 0.140000
Str(const char*, string) 0.140000
Str(string, string) 0.050000
Sp(const char*, const char*) 0.060000
Sp(string, const char*) 0.060000
Sp(const char*, string) 0.050000
Sp(string, string) 0.060000

という感じになりました。 const char* => std::string の変換が無いぶん、最初3つのケースでは StringPiece の方が速くなっています。このベンチマークでは allocation のコストがだいたい律速する感じのようなので、だいたい const char* => std::string の変換が一度も起きていない、 Str(string, string) と Sp(*, *) が同じような結果になっています。

同時に、 std::string で受けるのと同じように一つのバージョンだけ書けば良くてラクなので、まぁなにかと便利な物体だったりします。

今回のベンチマークのコードはこのへんにあります。ちなみに、 StringPiece::AppendToString は string::append(const string&) じゃなくて string::append(const char*, size_t) を使うようで、そのぶん Str(string, string) のケースは少しだけ Sp(string, string) のケースより速い傾向にあるみたいでした、がまぁ allocation に比べれば誤差の範囲かと思います。

http://github.com/shinh/test/blob/1f58c3c370e690595f3d2e0d12d4ed8e8aafab55/bench_string_piece.cc

完全に同じ感覚で使える、というほどのものではないものの、 std::string と似たようなインターフェイスをそれなりに持っているので、 std::string の allocation コストがバカにならない部分だけ移行する、というのもそれなりにラクにできるかと思います。特に、 StringPiece::substr なんかは、設計上当たり前ですが、 string::substr と違って allocation 不要なので、そういうことをよくするコードだと結構速くなるんじゃないかと思います。

まとめると、

  • 仮引数の型を const std::string& にした場合と同じ程度にラクに書ける
  • 仮引数を const std::string& にした場合と違って、引数として const char* を渡した場合に allocation のコストがかからないので、ほとんど overhead がない
  • Chromium にまぎれこんでるので適当に使える

という感じでそれなりに気が効いてるいいものなので、使ってみてもいいんじゃないかと思います。

なにかあれば下記メールアドレスへ。
shinichiro.hamaji _at_ gmail.com
shinichiro.h