[解決法] 複数のライブラリをまたぐdynamic_castが引き起こす問題

こちらの記事は「One way dynamic_cast across library boundaries can fail and how to fix it」の抄訳です。

 

Squish IDE では、デバッガを使用してテストスクリプト内の変数および、テスト対象のアプリケーションが持つオブジェクトを参照する変数を調査および展開することができます。

しかし、macOSにおいて、このデバッガによるスクリプト内の変数の展開がうまく機能しないことが判明しました。その原因を調査した結果、異なるC++の実装やプラットフォーム間でのdynamic_cast関数の異なる挙動について、新たな知見を得ることができました。こちらの記事では、調査の過程で見つけた問題、その原因と解決策をご紹介します。

直面した問題

Squish IDEで変数の展開ができなかった理由は、Squishのソースにある次のようなコードが原因でした。

bool VariableWatcher::objectHasComponents( Object* obj ) const
{
    const RemoteObjectBase *ro = dynamic_cast<const RemoteObjectBase*>( obj );
    if ( !ro ) {
        // error handling
        return false;
    }
    return true;
}

問題が発生したmacOS では、引数として渡された Object のインスタンス(obj)が RemoteObjectBase を継承しており、実際の型もプラットフォーム間で同じであるにもかかわらず、dynamic_cast は trueを返すはずがfalseを返していました。そしてこれこそが、macOSにおいてデバッグ時に変数の参照ができなかった原因でした。

状況としては、こちらのobjectHasComponents関数と RemoteObjectBaseクラス異なる共有ライブラリ内に定義されており、さらに、Squishのビルドシステムによって、明示的にエクスポートのタグ付けがされていないシンボルはライブラリ内に隠蔽されるようになっていました。

Qt のドキュメントには、共有ライブラリをまたいだ dynamic_cast の使用によって問題が生じる場合があると記述されています。しかし、私たちの場合、QObjectを基底クラスにもつクラスは使用しておらず、使いたいとも思っていませんでしたので、関係はないはずです。されに、この問題は Linux や Windows では発生していませんでした。そのため、この問題をさらに調査して原因を突き止め、macOS での修正案もしくは回避策を見つける必要がありました。

原因:シンボルの可視性とC++ランタイムの実装

この問題の原因は、シンボルの可視性と、macOSとLinuxで使用されているC++ランタイムライブラリの違いが組み合わさったものでした。

dynamic_cast関数は、引数として与えられたオブジェクトが特定の型を継承しているかどうかを判断するために、型を比較する処理を行います。この比較がどのように行われるかの実装は、libc++(macOSで使用)とlibstdc++(通常Linuxで使用)で異なり、それはllvmプロジェクトのこの古いメールのやり取りの中でも議論されています。libstdc++ の実装では、オブジェクトと型から得られる型情報構造体のname 属性をチェックするのに対し、libc++(macOS)では実際のポインタ値と型情報とを直接比較しています。

型情報のポインタ値にはtypeid関数からアクセスすることができますが、この関数の挙動からもやはり問題を確認することができました。typeidobjectHasComponentから呼び出されたのか、RemoteObjectBaseを定義している共有ライブラリから呼び出されたのかによって、返される値が異なっていたのです。つまり、objectHasComponentから見えているRemoteObjectBaseと、RemoteObjectBaseが定義されている共有ライブラリから見えているRemoteObjectBaseは、別のものだったのです。

RemoteObjectBase型情報は、LinuxやmacOSでは共有ライブラリのシンボルの一部になっています。objectHasComponents関数を含むライブラリ(ibb.dylib)のobjdumpの出力を見ると、このことがわかります。

objdump -Ct libb.dylib | grep RemoteObjectBase | grep typeinfo
0000000000003f2a lw O __TEXT,__const typeinfo name for RemoteObjectBase
0000000000004048 lw O __DATA_CONST,__const typeinfo for RemoteObjectBase

(こちらの libb.dylib ライブラリは、Squish コードベース全体を使用せずにこの問題を再現することができる小さなサンプル プロジェクトから引用)

行頭のlwフラグは、RemoteObjectBaseの型情報が、libb.dylibのローカルシンボルであることを示しています。このローカルシンボルは、libb.dylib 内で dynamic_cast が呼び出されると、共有ライブラリ側のRemoteObjectBaseを見かけ上シャドウします。つまり、共有ライブラリ側のRemoteObjectBaseに対してlibb.dylibライブラリ内に定義されたローカルのRemoteObjectBaseが優先されます。しかし、実際にキャストされるオブジェクト(最初のコード例で引数となっているobj)内の型情報は、グローバルシンボル、つまり共有ライブラリに実際に定義された型情報であるため、それによって型のミスマッチが発生して比較結果がtrueとなりませんでした。結果として、macOSでは上述の通り、objが確かにRemoteObjectBaseを継承していたにもかかわらず、ローカルに定義された別のRemoteObjectBaseと比較されていたため、dynamic_castの結果がfalseとなっていたのです。

解決方法:インポートマクロを調整する

先ほどのサンプルプロジェクトによって新たに明らかになったこととして、指定された可視性フラグに基づいて、コンパイラが型情報シンボルをobjectHasComponents関数が定義されているライブラリ内でローカルなものとして生成していることが確認できました。さらに、RemoteObjectBase共有ライブラリのシンボルの非表示を無効にすることで、RemoteObjectBaseobjectHasComponents関数の定義されたライブラリ内でコンパイル時に作成されたローカルのものではなく、グローバルのシンボル、つまり共有ライブラリのものが使用されるようになることがわかりました。そうすれば、macOS でも dynamic_cast は期待通りに動作します。

ここで少し回り道をして、ご存じ出ない読者の方のために、シンボルの可視性が通常どのように扱われるのか、その背景を説明したいと思います。

シンボルの扱われ方はプラットフォーム固有の方法に依存するものなので、通常はクラス宣言の前にコンパイラ固有のコマンドを配置することになります。プラットフォームによっては、クラスそのものをコンパイルする場合(シンボルのエクスポート)と、クラスを別のモジュールから使用する場合(シンボルのインポート)とで、別々のコマンドが必要になります。この設定は通常、プリプロセスのマクロとdefineによって行われ、クラスをエクスポートするためのマクロとインポートするためのマクロを定義し、さらにその2つを切り替えるための定義が設定されます。

LinuxとmacOSでは、エクスポートだけがコンパイラコマンド(__attribute__((visibility('default')))を必要とし、さらにコンパイラコマンドラインオプションはデフォルトで共有ライブラリのすべてのシンボルを隠蔽しています(-fvisibility=hidden)。したがって、インポートマクロはしばしば空の定義として展開されます。今回のケースでは、objectHasComponents関数の定義されたライブラリ内ではRemoteObjectBaseはインポートされる側にあたるので、マクロは__attribute__((visibility('default'))ではなく、空の定義として展開されていました。その結果、デフォルトの(-fvisibility=hidden)が使用され、RemoteObjectBaseはローカルシンボルとして生成されました。

 

macOSでのシンボル隠しを回避する解決策を探していたところ、例えばappleseedプロジェクトなど、importマクロがlinux/macOSのexportマクロと同じように定義されているプロジェクトをいくつか発見しました。また、定義されているライブラリの外でクラスが使用される場合、マクロが空として展開される代わりに、クラスの前に __attribute__((visibility('default'))) が追加されていることを発見しました。我々もインポートマクロを変更することで、コンパイラは libb.dylib 共有ライブラリ (および Squish の対応するライブラリ) 内でグローバルシンボルと同じシンボルを生成するようになります。以下では、先ほどのlwgw(グローバル)に変化していることが見て取れます。

objdump -tC libb.dylib| grep RemoteObj
0000000000004050 gw    O __DATA_CONST,__const typeinfo for RemoteObjectBase
0000000000003f2a gw    O __TEXT,__const typeinfo name for RemoteObjectBase

 

この解決策は、qobject_cast のような「問題が起きている dynamic_cast の代わりを見つける解決策」と「ライブラリのすべての内部クラスと関数を公開する解決策(パフォーマンス面などでデメリットあり)」の中間として最適であることがわかりました。これは重要なことで、たとえば、 dynamic_cast の代わりにqobject_cast を使用しようとすると、わざわざQObject 型をクラス階層に導入しなければなりませんが、そういった状況を避けることができたのです。

私たちはこの時点で、なぜコンパイラがローカルシンボルを生成するのかについて、さらに調査することをやめました。もし、この件に関してより詳しい背景をご存知の方がいらっしゃいましたら、コメントにて教えてください。

おわりに

以上が、記事の内容となります。

SquishをはじめとするQtのQA(品質保証)ツールにご興味のおありの方は、Qt JapanのEメールアドレス:japan@qt.ioまでお気軽にご連絡ください。

概要のご説明から詳細な技術的相談、また無料のツールトライアルのご案内もいたしております。


Blog Topics:

Comments