C++で妙なリンクエラーに遭遇した話

最近、自作ライブラリに機能を追加したのだが、実装中に妙なリンクエラーに遭遇して右往左往したので、メモを残しておく。当初、へなちょこC++使いの私には原因がつかめず、とりあえず回避策でお茶を濁していた。

追加したのはstd::this_thread::sleep_for()のラッパー関数だ。見ての通り、非常に簡単な内容である。

これらの関数は、当初は引数の型をconst参照にしていたのだが、謎のリンクエラーが発生したため、ひとまず参照ではなく実体を渡すようにしたバージョンをコミットした、という経緯がある。例えば関数cun::sleep::millis()の引数の型は、前掲のソースコードではconst std::chrono::milliseconds::repになっているが、初期実装ではconst std::chrono::milliseconds::rep&だった。

// 初期実装時のプロトタイプ宣言
extern void millis(const std::chrono::milliseconds::rep& r) noexcept;
// コミットした版のプロトタイプ宣言
extern void millis(const std::chrono::milliseconds::rep r) noexcept;

この関数を含むライブラリcunは、ユーティリティ類をまとめた静的ライブラリlibcunをビルドした上で、個々のテストアプリにてビルド済みライブラリをリンクして使用する構成になっている。

問題は、テストコードをコンパイルした後、静的ライブラリをリンクする時に発生した。Ubnutu 22.04のGCC 11.4.0がこんなリンクエラーを出力したのだ。

/usr/bin/ld: test_sleep.o: in function `main':
test_sleep.cpp:(.text.startup+0x182): undefined reference to `cun::sleep::millis(long const&)'

興味深いことに、Visual Studio 2022でもリンクエラーが発生した。

test_sleep.obj : error LNK2001: 外部シンボル "void __cdecl cun::sleep::millis(__int64 const &)" (?millis@sleep@cun@@YAXAEB_J@Z) は未解決です
test_sleep.exe : fatal error LNK1120: 1 件の未解決の外部参照

最初は関数名などのスペルミスを疑ったのだが、ミスはなかった。というか、そもそもVisual Studio Code上で関数の定義元に正しくジャンプできるので、名前は正しいはずだ。

一体、何が起きているのだろうか? 疑問はオブジェクトファイル内のシンボル名を見たら半分だけ氷解した。

cun::sleepにはsecs()、millis()、micros()、nanos()の4個の公開関数が定義されているのだが、そのうちmillis()のみ、ライブラリ側とテストコード側とでシンボル名が食い違っていた。

$ nm ../../../build/linux/libcun.a | grep -F sleep
sleep.o:
0000000000000000 T _ZN3cun5sleep4secsERKl
0000000000000000 t _ZN3cun5sleep4secsERKl.cold
00000000000001e0 T _ZN3cun5sleep5nanosERKl
000000000000002d t _ZN3cun5sleep5nanosERKl.cold
0000000000000130 T _ZN3cun5sleep6microsERKl
000000000000001e t _ZN3cun5sleep6microsERKl.cold
0000000000000080 T _ZN3cun5sleep6millisERl
000000000000000f t _ZN3cun5sleep6millisERl.cold
                 U nanosleep
$ nm test_sleep.o | grep -F sleep
                 U _ZN3cun5sleep4secsERKl
                 U _ZN3cun5sleep5nanosERKl
                 U _ZN3cun5sleep6microsERKl
                 U _ZN3cun5sleep6millisERKl
$ _

シンボル名の末尾を見てみると、テストコード側は全てERKlであることを期待しているのだが、ライブラリ側にてなぜかmillis()のみERlとなっている。これでは確かにリンクエラーとなるはずだ。

Visual Studio 2022でも同様に、シンボル名が食い違っていた。エラーメッセージから推測するに、テストコード側は?millis@sleep@cun@@YAXAEB_J@Zというシンボル名を期待していたようだが:

> dumpbin /NOLOGO /LINKERMEMBER ..\..\..\build\msvc\libcun.lib | findstr /l sleep
    C71F4 ??$sleep_for@_JU?$ratio@$00$00@std@@@this_thread@std@@YAXAEBV?$duration@_JU?$ratio@$00$00@std@@@chrono@1@@Z
    C71F4 ??$sleep_for@_JU?$ratio@$00$0DLJKMKAA@@std@@@this_thread@std@@YAXAEBV?$duration@_JU?$ratio@$00$0DLJKMKAA@@std@@@chrono@1@@Z
    C71F4 ??$sleep_for@_JU?$ratio@$00$0DOI@@std@@@this_thread@std@@YAXAEBV?$duration@_JU?$ratio@$00$0DOI@@std@@@chrono@1@@Z
    C71F4 ??$sleep_for@_JU?$ratio@$00$0PECEA@@std@@@this_thread@std@@YAXAEBV?$duration@_JU?$ratio@$00$0PECEA@@std@@@chrono@1@@Z
    C71F4 ??$sleep_until@Usteady_clock@chrono@std@@V?$duration@_JU?$ratio@$00$0DLJKMKAA@@std@@@23@@this_thread@std@@YAXAEBV?$time_point@Usteady_clock@chrono@std@@V?$duration@_JU?$ratio@$00$0DLJKMKAA@@std@@@23@@chrono@1@@Z
    C71F4 ?micros@sleep@cun@@YAXAEB_J@Z
    C71F4 ?millis@sleep@cun@@YAXAEA_J@Z
    C71F4 ?nanos@sleep@cun@@YAXAEB_J@Z
    C71F4 ?secs@sleep@cun@@YAXAEB_J@Z
        3 ??$sleep_for@_JU?$ratio@$00$00@std@@@this_thread@std@@YAXAEBV?$duration@_JU?$ratio@$00$00@std@@@chrono@1@@Z
        3 ??$sleep_for@_JU?$ratio@$00$0DLJKMKAA@@std@@@this_thread@std@@YAXAEBV?$duration@_JU?$ratio@$00$0DLJKMKAA@@std@@@chrono@1@@Z
        3 ??$sleep_for@_JU?$ratio@$00$0DOI@@std@@@this_thread@std@@YAXAEBV?$duration@_JU?$ratio@$00$0DOI@@std@@@chrono@1@@Z
        3 ??$sleep_for@_JU?$ratio@$00$0PECEA@@std@@@this_thread@std@@YAXAEBV?$duration@_JU?$ratio@$00$0PECEA@@std@@@chrono@1@@Z
        3 ??$sleep_until@Usteady_clock@chrono@std@@V?$duration@_JU?$ratio@$00$0DLJKMKAA@@std@@@23@@this_thread@std@@YAXAEBV?$time_point@Usteady_clock@chrono@std@@V?$duration@_JU?$ratio@$00$0DLJKMKAA@@std@@@23@@chrono@1@@Z
        3 ?micros@sleep@cun@@YAXAEB_J@Z
        3 ?millis@sleep@cun@@YAXAEA_J@Z
        3 ?nanos@sleep@cun@@YAXAEB_J@Z
        3 ?secs@sleep@cun@@YAXAEB_J@Z
> _

ライブラリ側のシンボル名は?millis@sleep@cun@@YAXAEA_J@Zだった。

さて、シンボル名が食い違っていることまでは分かったのだが、なぜシンボル名が食い違う状態に陥ったのか、へなちょこC++使いの私には分からなかった(C++のベテランなら、おそらくERKlとERlの違いの意味とかを調べる手立てを知っていると思われるのだが)。

そこで、この問題の回避策として、コミット済みコードから分かるように、引数の型として参照を使うのを止めた。これでシンボル名の食い違いは発生しなくなった。

$ nm ../../../build/linux/libcun.a | grep -F sleep
sleep.o:
0000000000000000 T _ZN3cun5sleep4secsEl
0000000000000000 t _ZN3cun5sleep4secsEl.cold
00000000000001e0 T _ZN3cun5sleep5nanosEl
000000000000002d t _ZN3cun5sleep5nanosEl.cold
0000000000000130 T _ZN3cun5sleep6microsEl
000000000000001e t _ZN3cun5sleep6microsEl.cold
0000000000000080 T _ZN3cun5sleep6millisEl
000000000000000f t _ZN3cun5sleep6millisEl.cold
                 U nanosleep
$ nm test_sleep.o | grep -F sleep
                 U _ZN3cun5sleep4secsEl
                 U _ZN3cun5sleep5nanosEl
                 U _ZN3cun5sleep6microsEl
                 U _ZN3cun5sleep6millisEl
$ _

この現象は異なるコンパイラ(そして異なる標準ライブラリ実装)で発生した。処理系の不具合とかではなく、もっと別の要因、ハッキリ言うと自分自身のヘマに起因しているような気がしたが、明確な根拠はなかった。

あと、どの関数も似たようなコードなのに、なぜかmillis()だけシンボル名の食い違いが発生した――という点もヒントになりそうだった。他との違いは型(std::chrono::milliseconds::rep)だけだ。しかしライブラリ側もテストコード側も同じヘッダファイル(≒同じプロトタイプ宣言)を参照していて、かつコンパイル時に警告すら出ていない。それなのに生成されるシンボル名が食い違うとは……。

原因は2日後に分かった。関数millis()だけ、ライブラリ側のプロトタイプ宣言と関数定義とで引数の型が食い違っていたのだ。

// ヘッダファイルに書かれていたプロトタイプ宣言
extern void millis(const std::chrono::milliseconds::rep& r) noexcept;

// ソースファイルに書かれていた関数定義。
void millis(milliseconds::rep& r) noexcept
{
    delay<milliseconds>(r);
}

プロトタイプ宣言では、引数の型はconst参照だった。なので、ヘッダファイル中のプロトタイプ宣言を参照したテストコード側は、const参照型を前提としたシンボル名を生成した。

一方でライブラリ側は、ソースファイルに書かれている通りに普通の参照型を前提としたシンボル名を生成した。

これにより、両者の間でシンボル名の食い違いが発生した。

なるほど、確かに理屈は通る。実際に、関数定義側の型をconst参照にしてみたら、リンクエラーは発生しなくなった。

「妙なリンクエラー」だと思っていたものは、案の定、自分が埋め込んだバグだった訳だ。

原因は分かったものの、なお個人的に納得できなかったのは、ライブラリをビルドする時に仮引数の型の食い違いが検出されなかったことだ。ソースファイルsleep.cppではヘッダファイルsleep.hppをインクルードしている。宣言と定義とで仮引数の型が異なることを検出できなかった(それも異なる2つのコンパイラで!)だなんて……。

――と、まあこんなことを考えたあたりに、C++に慣れていない(そして予想以上にC言語の影響を受けている)プログラマの後ろ姿が透けてみえるだろう。

多分、C言語なら*1、宣言と定義とで仮引数の型が異なることを検出できた可能性がある。なぜならば、C言語では関数を多重定義(オーバーロード)できないからだ。関数名が同じならば、引数や戻り値の型も一致していなくてはならない。

でもC++では関数の多重定義が可能だ。宣言と定義とで仮引数の型が食い違っているのか、それとも「const参照を引数にとるバージョン」の関数宣言と「普通の参照を引数にとるバージョン」の関数定義が存在するだけなのか、コンパイラには見分けがつかない。だから警告しないというか、警告できないというか。

うーん、C++では「宣言と定義の食い違い」にどう対処すればよいのだろうか? この問題、絶対にすでにつまづいた人がいるはずなんだよなあ。先人たちの解決策を知りたい。

やっぱり、なるべくヘッダファイルのみで完結させる(ソースファイルとヘッダファイルに分けない)ようにするべきなのだろうか? でも、それはそれでビルドにかかる時間が長くなりそうだ。

*1:もちろんC言語の言語仕様に参照は無いので、これは思考実験の類だと思ってほしい。