Cocos2d-x の CCString::createWithFormat について


※誤記修正:initWithFormat -> createWithFormat
※計測値を末尾に追加 2014.03.03


ちょっと引っかかったのでメモ。


Cocos2d-xには、CCString という std::string のラッパークラスみたいなものがある。
これには createWithFormat という、いわゆる sprintf みたいな format ができるメンバ関数があるので便利なのだが、「そういえばこれの実装ってどうなってるんだろ。メモリの確保とかどうやってるのかな?」と気になってソースを見たところ、ちょっとびっくりしてしまった。
createWithFormatは、最終的に以下の関数を呼び出して、書式文字列を生成する。

bool CCString::initWithFormatAndValist(const char* format, va_list ap)
{
    bool bRet = false;
    char* pBuf = (char*)malloc(kMaxStringLen);
    if (pBuf != NULL)
    {
        vsnprintf(pBuf, kMaxStringLen, format, ap);
        m_sString = pBuf;
        free(pBuf);
        bRet = true;
    }
    return bRet;
}


要するに vsnprintfでフォーマットしてstd::stringにコピーしているだけなのだが、問題は mallocで確保している kMaxStringLen である。これの定義を見てみると

#define kMaxStringLen (1024*100)

こうなっていた。これを見て、わたしはひっくり返りそうになった。


何故かというと、このcreateWithFormat関数、割と使うのである。
Cocos2d-xでのスプライト読み込みは、多くの人がおそらく「TexturePacker」で生成したテクスチャアトラスを使って、「元のファイル名の文字列で」スプライトを参照してると思うのだが(私はこの「文字列で何かを参照する」やり方が嫌で嫌でたまらないのだが)、たとえばスプライトに「obj000.png」「obj001.png」のような連番でファイル名をつけていた場合、スプライト読み込みや切り替え時等で「CCString:: createWithFormat("obj%03d.png", object_id)」みたいなことをやることになる。というかそういうサンプルコードをよく見かける。プログラムの作りにもよるが、下手をすると、毎フレームごとにこれをやりかねない。するとどうなるか。
毎フレームごとに何度も無駄な100KBのメモリ確保と解放が行われるのである。


実際には、そういうコードを書いていたって60フレームで問題なく動く。
しかし、私はなんか嫌だったので、上記を調べている過程で見つけた vasprintfを使って、createWithFormatの実装を以下のように書き直した。

bool CCString::initWithFormatAndValist(const char* format, va_list ap)
{
    bool bRet = false;
    char* pBuf = NULL;
    if(vasprintf(&pBuf, format, ap) != -1)
    {
        m_sString = pBuf;
        free(pBuf);
        bRet = true;
    }
    return bRet;
}


vasprintf は内部でメモリ確保までしてくれる便利な関数である。というか、こんなのがあったなんて、いままで知らなかった。もちろん、この関数の実装がどうなっているかはわからないので、メモリの確保量が多少なりとも減るのかどうかはわからない。検証もしていない。→末尾に速度の計測結果を追記(追記3:2004.03.03)
なので、上記の改造は、個人的な単なる気休めである。
また、この改造は、iOSAndroid用にはコンパイルが通ったけれど、vasprintf自体がGNU Cの拡張みたいなので、他のプラットフォーム用にはコンパイルが通らないケースがあるかもしれない。


蛇足だが、この「文字列で」データを引くやり方は、Macのプログラミングでは割と普通に行われているらしい。たとえば、テキストリソースを多国語対応する場合に NSLocalizedString という関数を使うのだが、これは「文字列キー」でそれぞれの言語用の「文字列」を引くものである。要は内部で map か何かを使ってキーでデータを引っ張ってるだけなのだが、今まであまり「文字列で」データを引っ張ることがなかった私には非常に違和感のあるやり方である。古いWindowsプログラミング(VC++)では、ビルド時に自動生成されるresource.hに定義された定義値を使って文字列を引っ張っていたし、今のAndroidプログラミングでも、string.xmlに定義した文字列は、ビルド時に自動生成されるRクラスの定義参照用静的メンバ変数を使って引っ張っている。
この多言語対応処理の Cocos2d-xでのやり方をググると、NSLocalizedString のI/Fを真似た CCLocalizedString というのを見つけることができる。しかし、これも文字列で文字列を引っ張るものなので、私はどうしてもそのまま使うのが嫌で、結局改造した上で利用させてもらっている。私のやり方では、OpenOffice の calc で他言語用の文字列を一元管理し、ヘッダとデータファイルをマクロで吐き、そのデータファイルを改造版 CCLocalizedString で読み込み、文字列ではなくヘッダの定義値でデータを参照できるようにしている。こうすることで、ヘッダの定義値(ただのdefine定義)がXcode上でコード補完の対象になるため、コードもすこしだけ書きやすくなって、ちょっといい感じになる。


※追記
vasprintf の実装が気になって眠れなくなったので、ちょっとAndroidソース配下のglibc配下系のそれっぽいのを探して見てみたのだけど、なんというか凄いコードで、正直よくわからなかった。でも凄い長い処理でいろいろやってる風でかつ出力後の長さを計算して確保してる風げだったので、たぶんおそらくきっと 1024*100 の決めうち malloc よりはマシなはず、と思う。思っている。思いたい。
#私が見たのは、./external/bluetooth/glib/glib/gnulib/vasnprintf.c の実装。これが実際に実機内で動いているやつとは思わないが、おそらくこれと同様の実装になってると思われる。ちなみに別の実装ではmalloc(128)なんてやってるやつもあったのだけれど、それに関しては検証したところ128文字以上の書式文字列を正常に処理してたので、それじゃないことは確認済み。


※追記2
古めの glibc のソースを落として vasprintf の実装を調べたらさらに頭が混乱した。内部で vfprintfをコールしていて、そっちの実装がまたえらいことになっている。これ何で関数の中で長大なマクロ定義してるんだろう。別関数だと遅いのかな。でも他では使わないマクロだから関数内で定義してるのかな。とりあえず、末尾が"\"の長いマクロを延々スクロールしている段階で私の頭がソースを読むのを諦めたので、もういい、信じる。きっと素晴らしい実装なんだと信じて私は別のことをやる。


※追記3

元の実装とvasprintf使用版で速度を計測してみた。
結果、


シミュレータ:元の実装が速い
実機    :vasprintfの実装が速い


ただし、1万回以上繰り返しても1秒も差が開かなかったので、実際の利用時に体感できるレベルではない。
よって、上記の改造は、個人的な単なる気休めレベルであった。


なお、同じcount数だとシミュレータが早すぎて計測できなかったので
シミュレータだけcount数を10倍して出した結果になっている。
#逆にシミュレータの数と同じ回数を実機でやると、
#計測中に落ちてしまうので回数を合わせられなかった。


シミュレータ iPhone iOS 5.0
Cocos2d: result average (set:10 count:100000)
Cocos2d: org: 614 ms
Cocos2d: new: 729 ms


iPod touch 第3世代 iOS 4.3.1
Cocos2d: result average (set:10 count:10000)
Cocos2d: org: 1574 ms
Cocos2d: new: 1152 ms


iPod touch 第5世代 iOS 7.0.6
Cocos2d: result average (set:10 count:10000)
Cocos2d: org: 1305 ms
Cocos2d: new: 819 ms


以下はテストに使ったソース

#include "cocos2d.h"
#include <string>
using namespace std;
USING_NS_CC;


static long sequence_time;
long get_current_time()
{
    struct timeval now;
    gettimeofday(&now, NULL);
    return now.tv_sec * 1000 + now.tv_usec / 1000.0f;
}
void log_sequence_start(const char* msg)
{
    CCLOG("%s: Start", msg);
    sequence_time = get_current_time();
    
}
long log_sequence_end(const char* msg)
{
    long result = get_current_time() - sequence_time;
    CCLOG("%s: End   : %lu ms", msg, result);
    return result;
}

#define TEST_STRING_FORMAT "TEST %d (%s) %05d"
#define TEST_STRING_256        "0123456789001234567890012345678900123456789001234567890012345678900123456789001234567890012345678900123456789001234567890012345678900123456789001234567890012345678900123456789001234567890012345678900123456789001234567890012345678900123456789001234567890012345678900123456789012345"
long test_org(int count)
{
    log_sequence_start("createWithFormat");
    for (int i = 0; i < count; i++) {
        std::string msg = CCString::createWithFormat(TEST_STRING_FORMAT, i, TEST_STRING_256, i)->getCString();
    }
    return log_sequence_end("createWithFormat");
}
long test_new(int count)
{
    log_sequence_start("createWithFormatEx");
    for (int i = 0; i < count; i++) {
        std::string msg = CCString::createWithFormatEx(TEST_STRING_FORMAT, i, TEST_STRING_256, i)->getCString();
    }
    return log_sequence_end("createWithFormatEx");
}
void test_format(){
    int count = 10000;
    int set = 10;
    long sum_org = 0;
    long sum_new = 0;
    try{
        for (int i = 0; i < set; i++) {
            sum_org += test_org(count);
            sum_new += test_new(count);
        }
    }catch(...){
        CCLOG("Exception!!");
    }
    CCLOG("result average (set:%d count:%d)", set, count);
    CCLOG("org: %lu ms", sum_org / set);
    CCLOG("new: %lu ms", sum_new / set);
    
}