[C++]final指定子と最適化

final specifier

final指定子はC++11にてoverride指定子とともに導入されたもので、指定した関数がオーバーライドされないことを明示し、オーバーライドされた場合にコンパイルエラーを起こすものです。
また、クラスに対しても指定でき、継承できないことを表します。継承した場合はコンパイルエラーとなります。
クラスの名前の後ろ、関数の引数定義の後ろ(overrideと同じところ)に書くことが出来ます。
overrideと違ってあまり利用されていないような気がする子です・・・

仮想関数テーブル参照のスキップ

このfinal指定子ですが、指定しておくとある条件の下で最適化に利用される可能性があります。
以下のようなコードで実験してみます。

struct Base {
    virtual int Num() = 0;
};

struct Derived1 : Base {
    int Num() override {
        return 10;
    }
};

struct Derived2 : Base {
    int Num() override {
        return 100;
    }
};

auto output(Base* ptr) {
    return ptr->Num();
}

auto output(Derived2* ptr) {
    return ptr->Num();
}

このコードをコンパイルしてアセンブリ出力を見てみます。一番見やすかったのでclang7.0.0のものを見てみます(両output関数の部分のみコピペします)。

output(Base*): # @output(Base*)
  mov rax, qword ptr [rdi]
  jmp qword ptr [rax] # TAILCALL
output(Derived2*): # @output(Derived2*)
  mov rax, qword ptr [rdi]
  jmp qword ptr [rax] # TAILCALL

アセンブラが読めなくても、なんとなくポインタでジャンプしてんなあというのが分かると思います。
私も詳しくはないですが簡単に説明すると
mov rax, QWORD PTR [rdi]→rdiの値を64bitポインタとしてraxにコピー
jmp qword ptr [rax]→raxの値を64bitポインタとしてジャンプ
両方ともこんな感じで仮想関数テーブルを参照しているらしい事が分かります。

次にDerived2を修正します。

struct Derived2 : Base {
    int Num() override final {
        return 100;
    }
};

このようにDerived2のNum()にfinalをつけておきます、すると

output(Base*): # @output(Base*)
  mov rax, qword ptr [rdi]
  jmp qword ptr [rax] # TAILCALL
output(Derived2*): # @output(Derived2*)
  mov eax, 100
  ret

お分かりいただけたでしょうか?
Derived2のポインタからNum()を呼び出す方は、仮想関数テーブルの参照がスキップされ、関数がインライン展開されています。
何が起きたのかというと
final指定を追加したことによって、コンパイラはDerived2::Num()がこれ以上オーバーライドされないことを知っているため、Derived2のポインタからの関数呼び出しに関しては仮想関数テーブルを参照して関数呼び出しをする必要がない、と判断することが出来ます。
結果、Derived2*からのNum()の呼び出し先は静的に確定し、インライン展開することが出来ます。
ちなみに、Derived2クラス自体にfinal指定をして、オーバーライドした関数にfinalを付けなくても結果は同じです。これ以上継承されないという事は関数がオーバーライドされることはありません。

その他のコンパイラ

clangだけではあれなのでgccやmsvcでも結果を見てみましょう。
Compiler Explorer
上記のclangの出力も含めてこちらのサイトを利用しました。

GCC 8.1 x64

finalなし(10行目付近)

output(Derived2*):
  mov rax, QWORD PTR [rdi]
  mov rax, QWORD PTR [rax]
  cmp rax, OFFSET FLAT:Derived2::Num()
  jne .L7
  mov eax, 100
  ret

finalあり(7行目付近)

output(Derived2*):
  mov eax, 100
  ret
MSVC 19.14 x64

finalなし(95行目付近)

int output(Derived2 * __ptr64) PROC ; output, COMDAT
  mov rax, QWORD PTR [rcx]
  rex_jmp QWORD PTR [rax]
int output(Derived2 * __ptr64) ENDP ; output

finalあり(95行目付近)

int output(Derived2 * __ptr64) PROC ; output, COMDAT
  mov eax, 100 ; 00000064H
  ret 0
int output(Derived2 * __ptr64) ENDP ; output
icc 19.0.0 x64

finalなし(43行目付近)

output(Derived2*):
  mov rax, QWORD PTR [rdi] #22.12
  mov rdx, QWORD PTR [rax] #22.12
  jmp rdx #22.12

finalあり(36行目付近)

output(Derived2*):
  mov eax, 100 #22.17
  ret #22.17

どのコンパイラでも同じようにfinalを指定すると仮想関数テーブル参照がスキップされている事が分かるでしょう。
特筆すべきはfinalなしの時のGCCの出力で、渡されたアドレスがDerived2のものであるかを調べて、そうであれば仮想関数テーブル参照をスキップするコードを出力しています。なんという貪欲さでしょう・・・

まとめ

以上のように、final指定子を追加しておくだけでC++使いの悩みの種?である仮想関数テーブル参照をスキップする最適化を促進できる場合があります。
その条件とは以下の二つ

  1. あるクラスが仮想関数テーブルを持っていて(virtual指定された関数を持つ別クラスを継承している)、かつ
  2. そのクラスのポインタからメンバ関数を呼び出す時(動的ポリモーフィズムしないとき)

派生する予定がない、してほしくない、するつもりがない、場合には積極的にfinalを指定してやるとよいかと思われます。その場合、関数毎につけるよりはクラスに直接つけた方が楽でしょう。