Edited at

[C++]ヘッダーオンリー or ビルドライブラリ?


はじめに

C++のライブラリは、共有ライブラリとして事前ビルドするライブラリ(以降ビルドライブラリと呼びます)と、ヘッダーオンリーのライブラリの2種類に大別されます。

ビルドライブラリを導入する時、コンパイルエラーやらなんやらで失敗した経験は、皆さんにもあるのではないかと思います。

そんな時、ヘッダーオンリーのライブラリなら、導入時にコンパイル不要!すぐに試せるのでお手軽!という特徴があります。そういった理由があってか、最近のライブラリはヘッダーオンリーのライブラリが増えてきたように感じます。Deep Learningのライブラリとして有名なtiny-dnnも、完全にヘッダーオンリーとして開発されています。

しかし、本当にヘッダーオンリーのライブラリは優れているのでしょうか?

 

かつてインド洋のモーリシャス島に生息していたドードーという鳥は、鳥類であるにもかかわらず飛行能力を有していなかったと言われています。しかしながら、16世紀に人間がこの島に足を踏み入れて以降、わずか200年足らずで地球上から姿を消しました。

この鳥が生物学的に大変興味深い特徴を持っているということに科学者が気づいた時には、すでに手遅れでした。もはや生きた個体を観察する機会もなければ、死骸を解剖して体内組織やDNAを調べることもできません。

 

ビルドライブラリが絶滅危惧種となりつつある中で、我々はもう一度彼らの価値を見直すべきではないのでしょうか!


本当にヘッダーオンリーは優れているのか?


ビルドライブラリのデメリット


使用するためにビルドが必要

ライブラリを使用する際に、事前に実装をコンパイルする必要があります。

なので、あるライブラリをお手軽に試してみたい!とか、いろんなライブラリを試してみて一番しっくりくるものを使いたい!という時にはあまりオススメできないですね。

ずーっとビルドが終わるのを待った挙句、最後の方でコンパイルエラー・ビルドエラーとかなったらそりゃストレス溜まるわな💢


リンクするのが面倒

リンクするのって面倒なんですよ。いちいちコンパイルオプションに-lboost_file_systemとか-lplatform_foldersとか書くのは面倒!Makefileとか書くときにもあれやこれやとどのライブラリをリンクするのか分けわかんなくなって面倒!

普通にコンパイルしようとして、リンカーオプションのつけ忘れでリンクエラーになるなんてもうこりごりだ!


デバッグできねぇ!

プログラムを動かしている時に、使用しているライブラリ内でSegmentation Fault起こして、プログラムが死ぬパターンを何度も経験してきました。

そういった時にどうするかって、プログラムのデバッグを行うわけなんですけど、Releaseモードでビルドとかしてると当然デバッグできないわけで、改めてDebugモードでビルドとかして、ようやくデバッグできるわけなんだけど、それでもヘッダーオンリーのライブラリに比べるとデバッグがやりにくいですね。


開発に余計な手間が加わる

分割コンパイルを行う際には、プログラムの依存関係をしっかり把握しておく必要があるわけでして、これが膨大なプロジェクトとかだったりすると面倒なわけなんですよ。

もうMakefileとかはごっちゃごっちゃになるわけでして、結局何回もMakefile書き直す羽目になるんですよね。

それだけじゃなくて、そもそも宣言と実装を分離する必要があるので、その分コードを書く量が増えます。複雑なテンプレートテクニックとか使ってる場合はもっと面倒な作業になって、実装を分けるためにヘルパー関数を導入するとか、面倒ですよね!


共有ライブラリの読み込み時間

共有ライブラリは実行時に読み込まれるわけですが、その際に様々な処理を行います。

例えばリロケーションという処理は、関数へのジャンプ命令を正常に実行できるようにするためのメモリアドレスの変換処理で、処理時間は共有ライブラリのサイズにおおよそ比例します。

共有ライブラリの読み込みにどれだけ時間がかかるのかを調べるために、OpenBLASに依存するプログラムを動的リンクおよび静的リンクによりコンパイルし、共有ライブラリの読み込みにかかる時間を比較してみます。

# shared library

$ gcc -o openblas_shared openblas_example1.c -lopenblas

$ gcc -o openblas_static openblas_example1.c /usr/lib/x86_64-linux-gnu/libopenblas.a -lpthread

OpenBLASが正常にリンクされているかどうかを調べるため、lddコマンドの出力を確認します。

$ ldd openblas_shared

linux-vdso.so.1 (0x00007ffe74702000)
libopenblas.so.0 => /usr/lib/x86_64-linux-gnu/libopenblas.so.0 (0x00007f9e21ef7000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9e21b06000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f9e21768000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f9e21549000)
libgfortran.so.4 => /usr/lib/x86_64-linux-gnu/libgfortran.so.4 (0x00007f9e2116a000)
/lib64/ld-linux-x86-64.so.2 (0x00007f9e2439f000)
libquadmath.so.0 => /usr/lib/x86_64-linux-gnu/libquadmath.so.0 (0x00007f9e20f2a000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f9e20d12000)

$ ldd openblas_static
linux-vdso.so.1 (0x00007ffd374fa000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007ff4527a8000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ff4523b7000)
/lib64/ld-linux-x86-64.so.2 (0x00007ff454250000)

これらのプログラムを用いて、共有ライブラリの読み込みにかかった時間を調査します。

$ LD_DEBUG=statistics ./openblas_shared


$ LD_DEBUG=statistics ./openblas_static

結果は、以下のようになりました。

共有ライブラリの読み込み時間
試行回数
平均 (cycles)
標準偏差

openblas_shared
5
964517
232800

openblas_static
5
643358
152048

t検定の結果、t=2.310, p=0.04969ということで、5%有意という結果になりました。

やはり、共有ライブラリの読み込み時間に影響しているようですね。


インライン化されない

gccやclangの-O2オプションや、cl.exeの/Otオプション、いわゆる最適化オプションと呼ばれるものは、関数のインライン化を行います。

つまり、アセンブラコードに書き換える際に関数本体を呼び出し元のほうに展開して、ジャンプ命令を排除するということを行います。このインライン化処理はジャンプ命令をスキップするだけでなく、関数をまたいだ単位で最適化を行えるというのが特徴です。

これにより、実行時間がほんのわずかに速くなるのですが、ビルドライブラリでは分割コンパイルを行うために関数のインライン化ができません。


ビルドライブラリのメリット


コンパイル時間が速くなる

ヘッダーオンリーのライブラリが増えるとまず真っ先に危惧されるのは、コンパイル時間の肥大化です。

tiny-dnnやxtensorなどは、ヘッダーオンリーであるにもかかわらず内部で複雑なテンプレートテクニックを使用しているため、コンパイル時間が非常に長いのが特徴です。

試しに、tiny-dnnをインクルードして簡単なCNNのコードを書いたところ、コンパイルに15秒もかかって困り果て果てたりしました。

自分のPCが低スペックというのもあるのですが、もう少しコンパイル時間が短いほうが使いやすいですよね。

ビルドライブラリでは、事前にライブラリ内部の実装をコンパイルするので、それ以降のコンパイルにかかる時間が短縮されます。

特にライブラリの規模が大きくなればなるほど、この効果ははっきりと現れるようになります。


静的解析にかかる時間の短縮化

コンパイル時間が短いと、静的解析にかかる時間も短くなります。

自分は、clang-checkなどのツールを使って、ソースコードの編集中に静的解析を行っていますが、ビルドライブラリだとヘッダーの解析にかかる時間も短くなるので、静的解析のリアルタイム性が向上します。


ドキュメントの可読性を向上させる

Doxygenなどでドキュメントを生成する際、ヘッダーオンリーだとソースコードがすべてドキュメント化されてしまい、ドキュメントが見にくくなります。

また、DoxygenではGraphvizを用いてヘッダーの依存関係を可視化してくれる機能がありますが、ヘッダーオンリーのライブラリではこれらの関係が複雑になることが多いのが特徴です。


関数呼び出しを行うためにコンパイラを必要としない

共有ライブラリとしてコンパイルした後は、その共有ライブラリを読み込む際にもはやコンパイラを必要としません。

つまりは、他の言語から関数を呼び出すのも容易だということです。

Pythonのctypesというモジュールでは、C言語のコードをコンパイルして得られた共有ライブラリを、dlopenのように使用することができます。ただし、C++の関数を呼び出すためには、マングリングとかしなきゃいけないので少し面倒なんですけどね。

(そのあたりのラッパーライブラリがもっと充実してくれるといいなぁ〜)


解決策の提案

このように、ヘッダーオンリーとビルドライブラリでは、それぞれにメリットがあり、どちらを選ぶべきかは状況次第と言えるかもしれません。

しかしながら、ライブラリによって導入方法が違っていたりすると面倒ですし、なるべく統一したほうが便利でしょう。

上記で挙げた双方のデメリットをすべて解消するのは難しいのですが、その問題の一部を解消する方法を考えました。

その名も、ビルドライブラリと見せかけて実はヘッダーオンリーとしても使えるライブラリです。

一見すると矛盾しているようにも見えますが、実はプリプロセッサを用いるだけで、ビルドライブラリとヘッダーオンリーは簡単に切り替えることができます。

例えば、ビルドライブラリの例として以下のようなものを考えます。


fibonacci.hpp

#ifndef FIBONACCI_HPP

#define FIBONACCI_HPP

long fibonacci(int index);

#endif



fibonacci.cpp

#include "fibonacci.hpp"

#include <stdexcept>

long fibonacci(int index) {
if (index < 0) {
throw std::out_of_range("argument must be 0 or positive.");
} else if (index <= 1) {
return 1;
} else {
return fibonacci(index - 1) + fibonacci(index - 2);
}
}

このままではヘッダーオンリーとして使えないので、fibonacci.hppを以下のように変更します。


fibonacci.hpp

#ifndef FIBONACCI_HPP

#define FIBONACCI_HPP

long fibonacci(int index);

#if defined(UNIVERSAL_HEADER_ONLY) || defined(FIBONACCI_HEADER_ONLY)
#include "impl/fibonacci.cpp"
#endif

#endif


さらに、@kazatsuyu さんのご指摘の通り、このままだと常にシンボルが生成されてODR違反が発生するので、fibonacci.cppを以下のように変更します。


fibonacci.cpp

#if defined(UNIVERSAL_HEADER_ONLY) || defined(FIBONACCI_HEADER_ONLY)

#define FIBONACCI_HEADER_ONLY_INLINE inline
#else
#define FIBONACCI_HEADER_ONLY_INLINE
#endif

#include "fibonacci.hpp"
#include <stdexcept>

FIBONACCI_HEADER_ONLY_INLINE long fibonacci(int index) {
if (index < 0) {
throw std::out_of_range("argument must be 0 or positive.");
} else if (index <= 1) {
return 1;
} else {
return fibonacci(index - 1) + fibonacci(index - 2);
}
}

さらに、fibonacci.cppをinclude/implディレクトリにコピーします。これで完了です。

ライブラリを使用する側は、以下のようにするとヘッダーオンリーとして使用することができます。


main.cpp

#define FIBONACCI_HEADER_ONLY

#include <fibonacci.hpp>

さらに、ライブラリが多くなると全部書くのが面倒なので、UNIVERSAL_HEADER_ONLYというマクロで一括制御できるようにします。

この方法の場合、ビルドライブラリとして利用する際には、以前と同じように使用することができますし、ヘッダーオンリーとして使用したい場合も、マクロを一行追加するだけです。

また、マクロなのでソースコードをいじらずにコンパイルオプションで制御することもできます。

ただし、この方法では結局事前コンパイルする必要があり、その点のデメリットだけは解消できないということに注意する必要があります。

ちなみにですが、C++にはconanというパッケージ管理ツールがあり、それを使えばconan.ioに登録されているライブラリを容易に導入することができます。しかしながら、現状ではconan.ioに登録されているライブラリが少ないので、そのあたりがもう少し増えてくれれば便利なのですが。。。

この方法、ヘッダーファイル末尾とソースファイル先頭に決まり文句を追加すれば済む話なのですが、それすらも面倒だという人は、プログラミングに向いていないのでさっさと別の職を探しましょう。

...というのは冗談で、これくらいのことは、マクロの命名規則をしっかりと定めておけば、あとは自分でツール作って全部自動化できます。結局のところ、ファイル名やマクロの命名規則はしっかりと決めておこうって話になるんです。

命名規則は単なる作法ではありません。ソースコード解析ツールとかで作業を自動化するという目的もあります。なので、命名規則はしっかりと決めておきましょう。

話がそれましたが、もしあなたがライブラリの開発者で、ビルドライブラリかヘッダーオンリーにするかで迷っているのであれば、ビルドライブラリとして開発することを推奨します。

そうしておけば、ヘッダーに末尾とソース先頭にステートメントを追加するだけで、ヘッダーオンリーとしても使えるライブラリに早変わりします。

とりあえず、自分はこの作業を自動化するcmakeモジュールでも書こうかな。。。