C++におけるinline指定子の誤解

C++にはinline指定子があります。しかし、このinline指定子は、その名前から誤解されがちです。

(誤解)inline指定子を付けると、その関数はインライン展開される

これは誤解であり、正しくありません。正しくは、以下のような意味になります。

  • inline指定子を付けた場合、複数回の定義が許される。また、複数の翻訳単位*1で定義されたとしても実体は一つになることが保証される*2
  • (C++14まで)inline指定子を付けた場合、コンパイラはその関数をインライン展開するヒントとすることができる。

「inline指定子を付けた場合、コンパイラはその関数をインライン展開するヒントとすることができる。」という記述はC++17以降で無くなりました。 ただし、コンパイラの最適化は見える結果が変わらない範囲で自由に行えるため、inline指定子をヒントにすることは問題ありません。(2020/09/02 なくなっていないとの指摘を受けましたので削除)

ちなみに、テンプレート関数、constexpr関数、クラス定義内で定義される関数、はいずれも暗黙にinline指定されたものとみなされます。default定義されたメンバ関数も暗黙にinline指定されたものとみなされます。

ところで、inline指定されている場合(これには上述の暗黙指定の場合を含みます)、複数回の定義が許されますが、その場合でも全ての定義は字面上同一かつ意味上同一でないといけません(one definition rule, ODR)。

外部リンケージを持つ(static指定されておらず、無名名前空間に囲まれていない)関数がODRを守れていない場合、コンパイラは特に指摘してくれません(診断不要:no diagnostics required)。このようなことが発生すると、リンクの順番によってプログラムの実行結果が変わるなどの意味不明なバグにつながり、非常にデバッグが困難です。つまり、うっかり同名の関数や変数を書いてしまってもコンパイラが指摘してくれず、バグが埋め込まれるということになります。そのため、特にグローバル名前空間inlineなものを置くことは忌避されるべきです。

うっかりを防ぐためには、static inlineとすべきです。ただし、実行効率まで考えると常に最善とは限りません(後述します)。

(誤解)inline指定子を付けると、その関数はインライン展開されやすくなる

先ほどの誤解よりは少しましになりましたが、依然誤解です。「コンパイラはその関数をインライン展開するヒントとすることができる」をうがって読むと、「コンパイラはその関数をインライン展開 しない ことの根拠としても使える」ことがわかります。そんな例は実在するのかという感じですが、以下に実例を示します。

inc.h

#include <array>
#include <cstdint>

#define M1(i) s += (i)*(i), arr[s%SIZE] = i;
#define M2(i) M1(i) M1(i+1)
#define M4(i) M2(i) M2(i+2)
#define M8(i) M4(i) M4(i+4)
#define M16(i) M8(i) M8(i+8)
#define M32(i) M16(i) M16(i+16)
#define M64(i) M32(i) M32(i+32)
#define M128(i) M64(i) M64(i+64)


DECL_SPEC std::array<std::size_t, 128> LargeFunction(std::size_t s) {
    std::array<std::size_t, 128> arr {};
    for( int t = 0; t < 128; ++t ) {
        M128(0);
    }
    return arr;
}

source1.cpp

#include "inc.h"

std::array<std::size_t, 128> callLargeFunction(std::size_t s) { return LargeFunction(s); }

source2.cpp

#include "inc.h"

std::array<std::size_t, 128> anotherCallLargeFunction(std::size_t s) { return LargeFunction(s); }

main.cpp

#include <iostream>
#include <array>
#include <cstdint>

std::array<std::size_t, 128> callLargeFunction(std::size_t s);
std::array<std::size_t, 128> anotherCallLargeFunction(std::size_t s);

int main() {
    std::cout << callLargeFunction(1)[3] << std::endl;
    std::cout << anotherCallLargeFunction(2)[4] << std::endl;
}

以下のコンパイルオプションで分割コンパイルし、リンクした場合、バイナリのサイズは13120バイトとなります。コンパイルオプションとして-Osの代わりに-O1-O2-O3を使う場合、17176バイトとなります。-fltoオプションを付ける場合、四つの最適化オプション-O1-O2-O3-Osのいずれでも12936バイトとなります。

g++-8 -std=c++17 -Os -DDECL_SPEC=inline source1.cpp -c -o source1_inline.o
g++-8 -std=c++17 -Os -DDECL_SPEC=inline source2.cpp -c -o source2_inline.o
g++-8 -std=c++17 -Os main.cpp -c -o main_inline.o

g++-8  source1_inline.o source2_inline.o main_inline.o -o inline

一方、-DDECL_SPEC=inlineの代わりに-DDECL_SPEC=static-DDECL_SPEC='static inline'を指定した場合、最適化オプションによらず17176バイトとなります。また、-fltoオプションを付けた場合、-O1の場合のみ12896バイト、その他の三つの最適化オプション-O2-O3-Osのいずれでも12936バイトとなります。

つまり、 staticstatic inlineの代わりにinlineとすることでインライン展開を抑制することができる 場合がある(具体的には-Os指定かつ-flto指定なし)ということです。

なぜこのようなことが起こったのでしょうか?

-fltoオプションを付けた場合、他の翻訳単位で使われるか否かを見てからインライン展開をするかどうかを判断することができるようにコンパイルすることになります。よって、基本的にはインライン展開せずにコンパイルし、リンク時にインライン展開を行うか判断することになります。そのため約13KBの小さなバイナリとなります。

一方、-fltoオプションを付けない場合、他の翻訳単位で使われるか否かはわからないままインライン展開するべきかの判断を下す必要があります。インライン展開をすべきと判断した場合、二つの関数にインライン展開されるためバイナリサイズが増大し、約17KBのバイナリとなります。

ここで、static指定子がついている場合(-DDECL_SPEC=static-DDECL_SPEC='static inline'とした場合)を考えてみると、他の翻訳単位からその関数が呼ばれることはない*3ため、実体を作る必要はありません。そのため、その翻訳単位で一回しか呼ばれないのならインライン展開してしまうのが最善となります。

一方、単独でinlineとしている場合、「複数の翻訳単位で定義されたとしても実体は一つになることが保証される」ことから、インライン展開しないで実体を作っておけば、他の翻訳単位で作った実体とリンク時にマージできる可能性があり、バイナリサイズを小さくする役に立てることができます。そのため、むしろインライン展開を抑制する働きを持つことがあります。

まとめ

inline指定子はもはやコンパイラにインライン展開を指示するものではありません。また、インライン展開を促進する効果があるとも限らず、むしろインライン展開を抑制する働きを持つことさえあります。この効果はstatic inlineとした場合には働かないため、ODR違反に細心の注意を払いつつstatic inlineとしないことが有効な局面もあり得るということを示しました。

補遺

inlineはむしろインライン展開を抑制する働きを持つことさえある、と書きましたが、これはあくまで特殊例です。現にコンパイラinlineキーワードがついている関数についてインライン展開閾値を下げるということを行っているようです。

また、関数のマージはinlineを付けない限り発生しないわけではなく、コンパイラの最適化によって勝手に行われることがあるようです。しかも、関数ポインタをとっているのに勝手にマージされて同じアドレスになって困るなどの現象も発生することがあったらしいです(VC9で発生した例→RTTI を使わずに Boost.Any を実装する方法 - melpon日記 - HaskellもC++もまともに扱えないへたれのページ)。

*1:難しい用語ですが、分割コンパイルするときの一つのcppファイルだと理解すれば大体あっています。

*2:平たく言うと、ヘッダファイルで定義してソースファイルにインクルードする場合、inlineを付けないと実体が複数になって困りますが、inlineを付ければそういうことが起こらないということです。つまり、ヘッダファイルに定義を書くことができるようになるという利点があります。

*3:"その関数"自体が呼ばれることはあり得ますが、staticがついている以上、実体としては別の関数として扱われます。