ObjectSquare [2009 年 11 月号]

[技術講座]


4.boost1.39.0における問題の修正


株式会社 オージス総研
組み込みソリューション部
近藤 貴俊

目次

1. 修正ポイントの切り出し
2. 修正の実施
2.1. shared_ptr_helper::resetの仕様
2.2. 継承関係にあるオブジェクトのアドレス比較
2.3. m_pointersの修正
3. おわりに

 1.修正ポイントの切り出し

前回の記事では、バグの原因を突き止めるところまで説明しました。バグの内容を簡単に説明すると「shared_ptr_helper::resetメンバ関数テンプレートの2回目の呼び出しにおいて、引数 s が正しく設定されない。」ということになります。

class shared_ptr_helper {
    略
public:
    略
    template<class T>
    void reset(shared_ptr<T> & s, T * r);
    略
};

バグの詳細については、前回の記事を参照していただくとして、今回は、このバグをどうやって修正したのかに関して詳細に説明します。

バグの修正においては、様々な試行錯誤が必要となるため、より簡単にバグを再現させる環境を準備しておくと効率的に作業を進めることができます。そこで、以下のようなテスト関数でバグを再現できるようにしました。

// 問題の箇所をピンポイントで再現するためのテストコード
void test()
{
    // 生のポインタ
    // iarchiveから、読み込んだ状況を想定
    Sub *pSub = new Sub;
    Base2 *pBase2 = pSub;

    // 空のshared_ptr
    // 生のポインタの関係と対応する形で、
    // 共有(shared)状態を作り出しつつ、構築される必要がある
    boost::shared_ptr<Sub> spSub;
    boost::shared_ptr<Base2> spBase2;

    // shared_ptr_helperを直接利用
    boost::archive::detail::shared_ptr_helper sh;

    sh.reset(spSub, pSub);
    sh.reset(spBase2, pBase2);

    assert(spSub.get() == pSub);     // success
    assert(spBase2.get() == pBase2); // fail
}

なお、クラスBase1,Base2,Subは、前回の記事と同様のクラスで、Subは、Base1とBase2をこの順で多重継承します。まず、ポインタpSubとpBase2がそれぞれSubのインスタンスを指し示すように設定します。そして、shared_ptr_helper::resetを使って、これらのポインタをshared_ptr spSubおよびspBase2にそれぞれ設定します。これら2つのshared_ptrは、同じオブジェクトを共有している状態です。

このテスト関数でバグを再現できるようにするには、もう少しテストコードが必要となります。まず、前述のテスト関数をソースファイルsol_flow_bef.cppに定義します。また、テスト関数が正しく動作するように、ソースファイル中に、あらかじめvoid_casterメカニズムに対し型情報を登録するコードを定義しておく必要があります。なぜなら、テスト関数中で使っているshared_ptr_helperはその内部でvoid_casterメカニズムを使っているため、あらかじめ型情報を登録しておかないと、shared_ptr_helperが期待通りに動作しないからです。この関係を下図にまとめてみました。

修正箇所の抽出

まず上図の1に対応する部分ですが、BOOST_CLASS_EXPORTによって、グローバルなコードが実行され、この段階で、Base1,Base2,Subといったクラスの型情報が、void_casterメカニズムに記憶されます(具体的には、以下の関数が呼び出されます)。

boost_1_39_0/libs/serialization/src/void_cast.cpp(version 1.39.0)

void_caster::recursive_register(bool includes_virtual_base) const

こうしておくことで、以降、boost::mplという仕組みを通して、テンプレートの型引数が継承関係にあるかどうかをチェックしたり、登録されているクラスのインスタンスのポインタをvoidポインタのまま、アップキャストしたりダウンキャストしたりすることができるようになるのです。詳細に関心がある方は、上記、void_cast.cppのソースコードを読んで理解しましょう。余談ですが、バグ修正におけるライブラリ作者とのやりとりの中で、彼は、私がこのあたりのメカニズムをドキュメント無しで理解したことに驚いていました。そんなわけなので、ドキュメントはきっとありません(笑)。

次に上図の2に対応する部分で、テストコードtest()が実行され、問題となっている振る舞いを発生させます。

最後に上図の3に対応する部分では、shared_ptr_helperの内部から、boost::serializationライブラリの各種メンバに必要に応じてアクセスすることとなります。

これで、修正を進めるための環境が整いました。ソースをコンパイルして実行すると、test関数の最後の行のassertが失敗します。

    assert(spBase2.get() == pBase2); // fail

これは、前回の記事で説明したとおり、spBase2に設定したポインタが、ずれているためです。よって、このassertが成功するように、shared_ptr_helperを修正できれば、問題は解決したことになります。

 2. 修正の実施

 2.1. shared_ptr_helper::resetの仕様

まず、問題を解決するために、shared_ptr_helper::resetメンバ関数テンプレートの仕様ついて整理しておきましょう。関係する部分のコードを以下に抜粋します。

boost/archive/shared_ptr_helper.hpp (version 1.39.0)

class shared_ptr_helper {
    typedef std::map<const void *, shared_ptr<void> > collection_type;
    typedef collection_type::const_iterator iterator_type;
    // list of shared_pointers create accessable by raw pointer. This
    // is used to "match up" shared pointers loaded at different
    // points in the archive. Note, we delay construction until
    // it is actually used since this is by default included as
    // a "mix-in" even if shared_ptr isn't used.
    collection_type * m_pointers;

// 略

    // return a void pointer to the most derived type
    template<class T>
    const void * object_identifier(T * t) const {
        const boost::serialization::extended_type_info * true_type 
            = boost::serialization::type_info_implementation<T>::type
                ::get_const_instance().get_derived_extended_type_info(*t);
        // note:if this exception is thrown, be sure that derived pointer
        // is either registered or exported.
        if(NULL == true_type)
            boost::serialization::throw_exception(
                boost::archive::archive_exception(
                    boost::archive::archive_exception::unregistered_class
                )
            );
        const boost::serialization::extended_type_info * this_type
            = & boost::serialization::type_info_implementation<T>::type
                    ::get_const_instance();
        const void * vp = void_downcast(
            *true_type, 
            *this_type, 
            static_cast<const void *>(t)
        );
        return vp;
    }
public:
    template<class T>
    void reset(shared_ptr<T> & s, T * r){
        if(NULL == r){
            s.reset();
            return;
        }
        // get pointer to the most derived object.  This is effectively
        // the object identifer
        const void * od = object_identifier(r);

        if(NULL == m_pointers)
            m_pointers = new collection_type;

        iterator_type it = m_pointers->find(od);

        if(it == m_pointers->end()){
            s.reset(r);
            m_pointers->insert(collection_type::value_type(od,s));
        }
        else{
            s = static_pointer_cast<T>((*it).second);
        }
    }
    // 略
};

resetメンバ関数テンプレートの、テンプレートパラメタTは、復元したい型すなわち最終的に作り出す型となります。そして、T* 型の引数 r には、実際にオブジェクトを指すポインタが格納されています。ただし、本当のオブジェクトの型はT型か、Tと継承関係にある型か分かりません。shared_ptr<T>の参照である s は、何も指していない状態で渡され、この中身を r にして返すことが期待されています。そして何より重要なのは、複数回のresetメンバ関数テンプレート呼び出しにおいて、引数 r が同じオブジェクトを指している場合は、引数 s がそれらを共有する形で(適切に参照カウントされる形で)復元しなければならないという点です。

 2.2. 継承関係にあるオブジェクトのアドレス比較

また、object_identifierメンバ関数テンプレートは、引数のポインタtが指し示すオブジェクトの、実際のオブジェクトの型(最も派生した型)を取得する機能を提供します。 resetメンバ関数テンプレートでは、な ぜobject_identifierメンバ関数テンプレートを使う必要があるのでしょうか。

オブジェクトが親クラスのポインタ経由で渡されることがある以上、同一オブジェクトであるかどうかを比較する際は、親クラスのポインタ経由でアドレス比較するのではなく、実際のオブジェクトの型を示すポインタ経由でアドレス比較することが必要となります。

ここで、おかしいな?と感じる方もいらっしゃるかもしれません。というのも、同一オブジェクトを指すポインタであれば、それが自分のクラスのポインタ型でも、親クラスのポインタ型でも、比較結果は一致するはずだからです。しかし、問題は void *で比較を行っている点にあります。下記のコードをご覧ください。

    Sub *pSub = new Sub;
    Base2 *pBase2 = pSub;

    assert(pSub == pBase2);   // success
    void *pvSub = pSub;
    void *pvBase2 = pBase2;
    assert(pvSub == pvBase2); // fail

このように、void *経由での比較は、同一オブジェクトを指すポインタであっても、一致するとは限らないのです。そこで、object_identifierメンバ関数テンプレートを利用することで、void * 経由でも、オブジェクトの同一性が判断できるようにしています。

なお、shared_ptr_helper::resetメンバ関数テンプレートでは、共有状態を復元するために、デシリアライズの過程で現れる様々なポインタを実行時にm_pointersというmapに保存しておく必要があります。そのため、様々な型を(実行時に)まとめて扱える仕組み、すなわちvoid * やshared_ptr<void>が必要なのです。

 2.3. m_pointersの修正

さて、これを踏まえて修正を行います。具体的には、m_pointersの扱う key-value ペアの内、valueの内容を修正します。

m_pointersの修正

m_pointersのkeyには実際のオブジェクトのアドレスを示すvoid * odを登録しています。一方valueには、resetメンバ関数テンプレートの引数 s として設定したshared_ptr<T>を、shared_ptr<void>としてコピーコンストラクトしたものを登録しています。当然、sと同じオブジェクト(アドレス)を指し示します。

valueへのこの登録が問題となります。valueに登録されるshared_ptr<void>は、親クラス経由の調整されたアドレスかもしれませんし、実際のオブジェクトのアドレスかもしれません。にも関わらずshared_ptr<void>としてコピーコンストラクトしてしまうと、その情報が失われるのです。よって、何とかして、shared_ptr<void>が指し示す先を、実際のオブジェクトのアドレスにしておきたいのです。しかし、ここで新たな問題が発生します。もし、実際のオブジェクトのアドレスを指すようにした場合、引数 s に設定するアドレスはどうすればよいでしょう?これは、テンプレートパラメタ T で調整されたアドレスになっていなければなりません。つまり、参照カウントは共有しながらも、異なるアドレスを指し示す必要があるのです。

これを踏まえて修正後の図を見てみましょう。valueに格納される sp は、resetメンバ関数テンプレートの引数 s と参照カウントは共有するものの、実際に指す先は sの指すオブジェクトではなく od となるようなshared_ptr<void>です。これは、比較的最近shared_ptrに追加された機能で、aliasingコンストラクタと呼ばれます(C++の言語仕様にaliasingコンストラクタという機能があるわけではありません)。第1引数が、参照カウントを共有する対象となるshared_ptrで、第2引数が、指し示すアドレスとなります。第2引数が指し示すアドレスの先は、当然、参照カウントが0になるまで有効であることを、利用者側で保証する必要があります。今回のケースでは、この第2引数の指し示すアドレスが、同じオブジェクトの、若干(継承におけるレイアウトの「ずれ」の分)異なる位置を指すだけなので、当がオブジェクトの参照カウントが0になるまで有効であることが保証されています。

aliasingコンストラクタ

上図に示すように、shared_ptrは、所有権をsp_counted_implというクラスを共有することで管理すると共に、指し示すオブジェクトは、pxという T* 型のポインタで管理しています。aliasingコンストラクタでは、このpxを任意のアドレスで指定できるのが特徴です。

なお今回のケースでは、aliasingが効果を発揮するのは、ここで説明したm_pointersへのオブジェクト登録時ではなく、後述するm_pointersからのオブジェクト取り出し時です。今回のケースでは、設定すべき shared_ptr<T> s の型Tが、(たまたま)実際のオブジェクトの型であるため、aliasing を行っても shared_ptrの指す先は変化しないことになります。しかし、型Tが親クラスとなるケースもあり得ることを考慮すると、s に設定して返すshared_ptrと、m_pointersに記憶すべきshared_ptr<void>は異なるアドレスを指すことになります。このようなケースに対応する必要があることから、m_pointersへのオブジェクト登録時にもaliasingを行っています。

具体的な修正コードの詳細は、パッチshared_ptr_mlt_inh.patchをご覧ください。関連部分を以下に抜粋します。

         if(it == m_pointers->end()){
             s.reset(r);
-            m_pointers->insert(collection_type::value_type(od,s));
+            // make shared_ptr. 
+            // data member pn of shared_ptr sp shared with s,
+            // but data member px of shared_ptr sp is od.
+            shared_ptr<void> sp(s, const_cast<void *>(od)); // aliasing
+            // insert shared_ptr of the most derived object into m_pointers.
+            m_pointers->insert(collection_type::value_type(od,sp));
         }

さて、m_pointersへのオブジェクトの登録を修正したので、次は、m_pointersからのオブジェクトの取り出しを修正します。

修正前の実装は、極めてシンプルで、keyが一致する要素があれば、それを、shared_ptr<T>型にキャストたものを、s にコピー代入して返していました。以下のコードのelseの中がその部分です。

        if(it == m_pointers->end()){
            s.reset(r);
            m_pointers->insert(collection_type::value_type(od,s));
        }
        else{
            s = static_pointer_cast<T>((*it).second);
        }

ここで、(*it).secondはm_pointersに格納されるvalueを指します。オブジェクト登録を修正したことによって、valueの中身は実際のオブジェクトを指し示すshared_ptr<void>になっています。そこで、m_pointersからvalueを取り出して s に設定する際は、取り出した value を実際のオブジェクトの型から、取り出したい型 shared_ptr<T> にアップキャストして s に設定すればよいことになります。この際も、参照カウントと実際のアドレスに「ずれ」が発生するので、aliasingコンストラクタを利用します。修正パッチの内容を見ていきましょう。

         else{
-            s = static_pointer_cast<T>((*it).second);
+            const boost::serialization::extended_type_info * true_type 
+                = boost::serialization::type_info_implementation<T>::type
+                    ::get_const_instance().get_derived_extended_type_info(*r);
+            if(NULL == true_type)
+                boost::serialization::throw_exception(
+                    boost::archive::archive_exception(
+                        boost::archive::archive_exception::unregistered_class
+                    )
+                );
+            const boost::serialization::extended_type_info * this_type
+                = & boost::serialization::type_info_implementation<T>::type
+                        ::get_const_instance();
+            // get this type pointer from pointer to the most derived object.
+            void * vp = void_upcast(
+                *true_type, 
+                *this_type, 
+                (*it).second.get()
+            );
+            // make and set shared_ptr.
+            // data member pn of shared_ptr s shared with (*it).second,
+            // but data member px of shared_ptr s is vp.
+            s = shared_ptr<T>((*it).second, static_cast<T *>(vp)); // aliasing
         }

まず、最初に、変数true_typeを実際の型情報で初期化しています。なお、実際の型情報は、前述の通りBOOST_CLASS_EXPORTによって、main関数実行前に登録されています。ここでは、その登録済みの型を*r をキーとして検索することで、取得しています。

次に、変数this_typeを、取得したい型情報で初期化します。そして、true_typeとthis_typeをvoid_upcastにそれぞれ、第1引数、第2引数として渡し、さらに、キャストしたいvoid *を第3引数として渡すことで、適切にアドレス調整されたポインタを得ることができます。

よって、変数vpには、実際は継承されたオブジェクトを指し、親クラスにそのままキャストすればちょうど「ずれ」が無くなるようにアドレス調整されたポインタが入っていることになります。

このvpを得ることができたなら、後は、先ほども利用したshared_ptrのaliasingコンストラクタの第1引数に、所有権を共有したいshared_ptrすなわち(*it).second、第2引数にshared_ptrが実際に指し示すアドレスとして、vpをT型にstatic_castしたものを渡せばOKです。

m_poitnersとaliasing

この修正によって、多重継承とweak_ptrのいかなる組み合わせにおいても、適切にシリアライズ、デシリアライズが可能となりました。

 3. おわりに

さて、4回にわたってboost::serializationのバグを修正するまでの流れをご紹介してきました。今回の取り組みを通じて、いろいろなことを感じました。まず、オープンソースの重要性です。ライブラリにバグがあるかもしれないという疑いを抱くケースにおいて、ソースにアクセスできるというのは非常に助かります。

また、boostライブラリの設計、実装技術の高さに加えプログラミング言語C++の奥深さもあらためて感じました。BOOST_CLASS_EXPORTによるグローバルコードの実行とsingletonパターンの組み合わせなど、言語の仕組みを巧みに利用した設計と言えます。

そして、恥ずかしながら、自分の英語能力の低さと共に、それでもコミュニケーションをする楽しさを感じました。特にライブラリの作者がちゃんと相手をしてくれたとき、私の解決策を褒めてくれたとき、子供のように純粋な喜びを感じたのをよく覚えています。会話における言語の表現力が不十分なとき、プログラミング言語が共通語の役割を果たし、お互いの理解の促進に役立つということもありました。

オープンソースの世界は、気付いた人がアクションを取ることの連鎖で動いていることも実感しました。今後も、何かに気付いたときは、適切なアクションがとれるように、アンテナを張ると共に、自己研鑽を行っていきたいと思いを新たにしました。


© 2009 OGIS-RI Co., Ltd.
Prev Index
Prev Index