組込み現場の「C++」プログラミング 明日から使える徹底入門

高木 信尚(株式会社クローバーフィールド

3.10 オーバーヘッドはここから始まる

C++を導入しようとするとき,一番気になるのはオーバーヘッドではないでしょうか? ここでは,どのようなコードを書いたときにオーバーヘッドが発生するのかを考察することにします.そして,オーバーヘッドを最小にするための方法についても解説します.

3.10.1 Cとまったく同じ記述をした場合のオーバーヘッド

多くの場合,Cとまったく同じコードをC++としてコンパイルした場合,Cとしてコンパイルした場合に比べて,オーバーヘッドはまず発生しません.しかし,一部の処理系ではこの限りではありません.あらかじめ,ごく簡単なソースをC++としてコンパイルしてみることをお勧めします.組込み向けのコンパイラではまずないでしょうが,PCなど,汎用のプラットフォーム向けのC++コンパイラでは,Cと同じコードでもオーバーヘッドが発生することがよくあります.

たとえば,次のごく簡単なソースをコンパイルしてみます.

void foo();
void bar()
{
    foo();
}

Linux上のGCC 3.4.6で「-O2」オプションを付けてコンパイルすると,次のようになりました*2

    .file   "test.cpp"
    .text
    .align 2
.globl _Z3barv
    .type   _Z3barv, @function
_Z3barv:
.LFB2:
    pushl   %ebp
.LCFI0:
    movl    %esp, %ebp
.LCFI1:
    subl    $8, %esp
.LCFI2:
    call    _Z3foov
    leave
    ret
.LFE2:
    .size   _Z3barv, .-_Z3barv
    .section    .eh_frame,"a",@progbits
.Lframe1:
    .long   .LECIE1-.LSCIE1
.LSCIE1:
    .long   0x0
    .byte   0x1
    .string "zP"
    .uleb128 0x1
    .sleb128 -4
    .byte   0x8
    .uleb128 0x5
    .byte   0x0
    .long   __gxx_personality_v0
    .byte   0xc
    .uleb128 0x4
    .uleb128 0x4
    .byte   0x88
    .uleb128 0x1
    .align 4
.LECIE1:
.LSFDE1:
    .long   .LEFDE1-.LASFDE1
.LASFDE1:
    .long   .LASFDE1-.Lframe1
    .long   .LFB2
    .long   .LFE2-.LFB2
    .uleb128 0x0
    .byte   0x4
    .long   .LCFI0-.LFB2
    .byte   0xe
    .uleb128 0x8
    .byte   0x85
    .uleb128 0x2
    .byte   0x4
    .long   .LCFI1-.LCFI0
    .byte   0xd
    .uleb128 0x5
    .align 4
.LEFDE1:
    .section    .note.GNU-stack,"",@progbits
    .ident  "GCC: (GNU) 3.4.6 20060404 (Red Hat 3.4.6-3)"

関数そのものは次の部分だけであり,Cとしてコンパイルした場合に比べて,特にオーバーヘッドがあるようには見えません.

_Z3barv:
.LFB2:
    pushl   %ebp
.LCFI0:
    movl    %esp, %ebp
.LCFI1:
    subl    $8, %esp
.LCFI2:
    call    _Z3foov
    leave
    ret

しかし,その後に何やら大量のデータが続いています.これは例外処理を解決するためのデータです.この処理系では,例外が送出されない場合の速度的なオーバーヘッドを最小にするために,サイズを犠牲にしているようです.また,いったん例外処理が発生した場合の実行速度も決して速いとはいえません.

同じGCCでも,Cygwin用のGCC 3.4.4でコンパイルすると,次のようになりました.これは,本当に関数そのものしかありません.

    .file   "test.cpp"
    .text
    .align 2
    .p2align 4,,15
.globl __Z3barv
    .def    __Z3barv;   .scl    2;  .type   32; .endef
__Z3barv:
    pushl   %ebp
    movl    %esp, %ebp
    popl    %ebp
    jmp __Z3foov
    .def    __Z3foov;   .scl    3;  .type   32; .endef

組込み開発で使用する処理系の場合,筆者が知るかぎりでは,Cygwin用のGCCと同様に,Cと同じコードをコンパイルしたときに比べて,速度的にもサイズ的にもオーバーヘッドが発生することはまずありません.

*2 「3.4.6」とバージョンはずいぶん古いのですが,比較するCygwinのGCCのバージョンに近いために,このバージョンを使用しました.

3.10.2 デストラクタを持つ自動オブジェクト

次に,先ほどの例に登場したbar関数の中で,デストラクタを持つ自動オブジェクトを定義してみることにしましょう.具体的なコードは次のとおりです.

void foo();
struct A
{
    ~A();
};
void bar()
{
    A a;
    foo();
}

これを,先ほどはオーバーヘッドが発生しなかったCygwin用のGCCでコンパイルしてみた結果は次のとおりです.

    .file   "test.cpp"
    .def    __Unwind_SjLj_Resume;   .scl    2;  .type   32; .endef
    .def    ___gxx_personality_sj0; .scl    2;  .type   32; .endef
    .def    __Unwind_SjLj_Register; .scl    2;  .type   32; .endef
    .def    __Unwind_SjLj_Unregister;   .scl    2;  .type   32; .endef
    .text
    .align 2
    .p2align 4,,15
.globl __Z3barv
    .def    __Z3barv;   .scl    2;  .type   32; .endef
__Z3barv:
    pushl   %ebp
    movl    %esp, %ebp
    leal    -24(%ebp), %eax
    subl    $120, %esp
    movl    %eax, -60(%ebp)
    leal    -92(%ebp), %eax
    movl    %ebx, -12(%ebp)
    movl    %esi, -8(%ebp)
    movl    %edi, -4(%ebp)
    movl    $___gxx_personality_sj0, -68(%ebp)
    movl    $LLSDA2, -64(%ebp)
    movl    $L6, -56(%ebp)
    movl    %esp, -52(%ebp)
    movl    %eax, (%esp)
    call    __Unwind_SjLj_Register
    movl    $1, -88(%ebp)
    call    __Z3foov
L1:
    movl    $-1, -88(%ebp)
    leal    -40(%ebp), %eax
    movl    %eax, (%esp)    call    __ZN1AD1Ev
    leal    -92(%ebp), %eax
    movl    %eax, (%esp)
    call    __Unwind_SjLj_Unregister
    movl    -12(%ebp), %ebx
    movl    -8(%ebp), %esi
    movl    -4(%ebp), %edi
    movl    %ebp, %esp
    popl    %ebp
    ret
    .p2align 4,,7
L6:
L2:
L4:
    addl    $24, %ebp
    movl    $0, -88(%ebp)
    movl    -84(%ebp), %eax
    movl    %eax, -96(%ebp)
    leal    -40(%ebp), %eax
    movl    %eax, (%esp)
    call    __ZN1AD1Ev
    movl    $-1, -88(%ebp)
    movl    -96(%ebp), %eax
    movl    %eax, (%esp)
    call    __Unwind_SjLj_Resume
    .section    .gcc_except_table,"dr"
LLSDA2:
    .byte   0xff
    .byte   0xff
    .byte   0x1
    .uleb128 LLSDACSE2-LLSDACSB2
LLSDACSB2:
    .uleb128 0x0
    .uleb128 0x0
LLSDACSE2:
    .text
    .def    __Z3foov;   .scl    3;  .type   32; .endef
    .def    __ZN1AD1Ev; .scl    3;  .type   32; .endef

前回のコンパイル結果と比べると,驚くほど大量の命令が生成されてしまいました.これは,foo関数から例外が送出されたときに,aのデストラクタを呼び出すためのコードです.foo関数には例外指定がありませんから,C++コンパイラは,あらゆる例外が送出される可能性があると判断するのです.

それでは,今度はfoo関数の宣言に例外指定を付けて,例外が送出されないことをコンパイラに教えてみましょう.

void foo() throw();
struct A
{
    ~A();
};
void bar()
{
    A a;
    foo();
}

このコンパイル結果は次のようになりました.

    .file   "test.cpp"
    .text
    .align 2
    .p2align 4,,15
.globl __Z3barv
    .def    __Z3barv;   .scl    2;  .type   32; .endef
__Z3barv:
L2:
    pushl   %ebp
    movl    %esp, %ebp
    subl    $40, %esp
    call    __Z3foov
    leal    -24(%ebp), %eax
    movl    %eax, (%esp)
    call    __ZN1AD1Ev
    leave
    ret
L1:
    .def    __Z3foov;   .scl    3;  .type   32; .endef
    .def    __ZN1AD1Ev; .scl    3;  .type   32; .endef

関数の出口で__ZN1AD1Evをcallしていますが,これがaのデストラクタの呼び出しです.このように,非常に簡単なコンパイル結果になりました.

ここまでの実験からわかるように,明示的なデストラクタを持つ自動オブジェクトの生存期間内で,例外を送出する可能性のある処理を行うようなコードを書くと,例外処理のための命令が大量に挿入されます.しかも,try,catch,throwという例外処理にかかわるキーワードをいっさい記述していないにもかかわらずです.例外処理を有効活用するのであれば,それらは必要なコストですが,そうでなければ純粋なオーバーヘッドになってしまうのです.

C++を使い始めて,たいしたことは書いていないのに,プログラムが予想以上に肥大化し,実行速度が低下してしまうのは,これが原因である可能性が高いといえます.この現象を理解することが,C++を思いどおりに使いこなすための重要な鍵であるといっても過言ではありません.

3.10.3 オブジェクトの構築と解体

C++ではクラスを使うことができます.C++を使うのであれば,必ずクラスを使うべきだと主張する人も少なくありません.たとえば,単なるchar型の配列であるCスタイルの文字列ではなく,std::stringを使うべきだと主張するわけです.しかし,ここに大きな罠があります.

std::string型のオブジェクトを構築する際には,内部で管理するメモリを動的に割り付け,初期値としてコンストラクタに渡した文字列をコピーします.そして,オブジェクトを解体する際には,内部で管理していたメモリを解放します.

これらの処理は決して軽量とはいえず,単なる整数配列にすぎなかったCスタイルの文字列に比べて大きなコストがかかります.std::stringならではの機能を使うのなら,そうしたコストは有益でしょうが,単にCスタイルの文字列を置き換えただけであれば,純粋なオーバーヘッドになってしまいます.

Cスタイルの文字列からstd::stringへ暗黙の型変換ができるため,文字列を受け取る関数の仮引数は,下記のようにしてしまいがちです.

void func(const std::string& str);

しかし,これでは単純な文字列リテラルを渡しただけでもstd::stringオブジェクトの構築と解体が行われ,大きなオーバーヘッドとなります.そのため,可能であれば,下記のように多重定義を行うほうがよいのです.

void func(const char* s);
inline void func(const std::string& str)
{
    func(str.c_str());
}

また,このようなオーバーヘッドを避ける意味でも,クラス型への暗黙的な型変換を許す変換コンストラクタの定義は最小限に抑えるべきです.

また,Cでは,ブロックの途中で変数の宣言を行えなかったため*3,関数の先頭ですべての変数を宣言する傾向にありました.しかし,変数の中には,条件次第ではまったく使用されないものもあります.もし,そうした変数(というよりオブジェクト)がクラス型で,明示的なコンストラクタやデストラクタを持っていたとしたらどうでしょう.まったく使用されないにもかかわらず,オブジェクトの構築と解体が行われ,それが純粋なオーバーヘッドとなります.これを避けるには,宣言は実際に使用する直前で行うほうが望ましいのです.

*3 C99では,ブロックの途中でも宣言が行えるようになりました.

3.10.4 一時オブジェクト

先ほどのオブジェクトの構築と解体に関連しますが,一時オブジェクトの存在も忘れてはなりません.「一時オブジェクト」というのは,式の評価結果として生成されるオブジェクトのことです.関数の返却値も一時オブジェクトになります.そして,意外に見落としがちなのが,多重定義された演算子の評価結果です.

たとえば,Cでは,その評価結果を使わないかぎり,増分演算子(++)の前置形式と後置形式に違いはありませんでした.

int a = 0;
++a;
    // ← 前置形式と後置形式の違いはない
a++;

しかし,C++ではそうはいきません.

class A
{
public:
     …
    A& operator++()   // ← 前置形式 
    {
        ++value_;
        return *this;
    }
    A operator++(int) // ← 後置形式 
    {
        A temp(*this);
        ++*this;
        return temp;
    }
private:
    int value_;
};

上記のように,増分演算子を多重定義したクラスを考えてみましょう.引数無しのほうが前置形式,int型の引数を取るほうが後置形式です.後置形式では,以前の値を返す必要がありますから,値を退避するためのtempと,返却値である一時オブジェクトの,2つのオブジェクトの構築と解体が発生します.もしコンストラクタとデストラクタで比較的重い処理を行っていた場合,前置形式で済むところに後置形式を使ってしまうと,深刻なオーバーヘッドに繋がります.

「2.6.4 Iteratorパターン」で解説した反復子(Iterator)を使う場合には,この問題に直面することが多いので要注意です.

void func(Iterator first, Iterator last)
{
    for (Iterator iter(first); iter != last; iter++) // ← ここにオーバーヘッドあり! 
    {
         …
    }
}

上記のコードのように,評価結果を使うわけでもないのに後置形式の増分演算子を使うのは禁物です.

次のようにするだけでも,状況によってはオーバーヘッドをかなり軽減できます.

    for (Iterator iter(first); iter != last; ++iter)

また,一時オブジェクトが構築されるということは,その過程で例外が送出されるかもしれないことを意味しています.例外が送出される可能性があれば,それだけプログラムが肥大化しますし,実行パスも複雑になります.その意味でも,一時オブジェクトには十分な注意を払う必要があります.