C++

COM (IUnknown) の実装で shared_ptr / weak_ptr を使いたい (IWeakReference を簡単に実装する)

はじめに

Windows でネイティブ開発をする場合、 COM とは切っても切れない関係です。

その COM の IUnknown の実装は参照カウンターを実装することと大体同義なわけですが、ここで shared_ptr を参照カウンター管理に使いたいなあと思い、何かうまい方法がないかなと考えた話です。

IUnknown の実装は ATL などライブラリーを使えばいい話ですし、素で実装してもたいしたことありませんが、それでもあえて shared_ptr を使いたいのは COM で "弱参照 (Weak Reference)" の実装を容易に行いたいからなのです。

弱参照は循環参照問題の回避策としてないとかなり不便 (生ポインターで持つ、いわゆる Unowned Reference だと生存状態の把握が正しくできない) なのですが、いざ正しく実装しようとすると結構面倒です。そこで C++ の shared_ptr の対として実装されている weak_ptr を使えば下手に車輪の再発明するよりよっぽど信頼性のある実装になるのではないか、と考えたわけです。

とりあえず目的は達成できたので、実装例を上げておきました。

環境は Visual Studio 2017 です。 Win32 COM の作法は大分省略しています (今回は必要ない) 。例外、エラー処理もほとんどしていません。

サンプルでのインターフェース定義は下記のようになります。 IWeakReference は Windows Runtime の同名インターフェースに順じた形に、 IWeakReferenceSource は面倒なので ISum に混ぜました。

class __declspec(uuid("{05DF99DC-2A25-4B49-AE17-31DE9A2BFBFF}"))
    IWeakReference :
    public IUnknown
{
public:
    virtual HRESULT STDMETHODCALLTYPE Resolve(REFIID iid, void **ppv) = 0;
};

class __declspec(uuid("{3FEC4416-AAB7-4595-B99C-A5998E294C0F}"))
    ISum :
    public IUnknown
{
public:
    virtual HRESULT STDMETHODCALLTYPE GetWeakReference(IWeakReference **weakReference) = 0;

    virtual void STDMETHODCALLTYPE Increment(int32_t a) = 0;
    virtual int32_t STDMETHODCALLTYPE GetCurrent() = 0;
};

実装の実体を COM 実装でラップする

まず考えられるパターンとして SumInternal に実装本体を定義し、 SumWrapper で ISum インターフェースを実装して SumInternal にバイパスする、というやり方ですがあんまり意味がありません。

  • SumWrapper 側は SumInternal を見ることができますが、逆ができません。普通に双方向にすると循環参照になります。
  • IWeakReference が実装できていません。

そもそも目的が "shared_ptr を COM の参照カウンターに使いたい" なので、その趣旨から外れてしまっています。

IUnknown 実装と shared_ptr を統合する

という事で本題の方法を検討します。

shared_ptr の参照カウンターで AddRef / Release を実装する

まずこれを考えましたが、 shared_ptr の public メンバーで直接参照カウンターを制御できるものがありません。安全じゃなくなるので当然なことではありますが・・・

コンパイラーの実装系依存にする、のであればできなくもなさそう (試してはいませんが VC++ の shared_ptr の実装は参照カウンター制御が public になっています。ただ戻り値でカウンター値を返すようになっていないようなので完全な対応は不可) ですが、それはやってはダメでしょう。

参照カウンターは実装するが、カウンター値に応じて shared_ptr の保持/解放を制御する

で結論的にはこの形に落ち着きました。

class Sum :
    public ISum,
    public std::enable_shared_from_this<Sum>
{
private:
    std::atomic_int32_t m_ref;
    std::shared_ptr<Sum> m_sharedThis;

まず ISum を実装する COM クラスでありながら shared_ptr で管理される事を前提としたクラスとします。また、メンバーに自身の参照を持つようにしますが、ぱっと見ではおかしい定義に見えますね。

直接 new をされると問題を起こす可能性があるので factory を用意します。

HRESULT Sum::CreateInstance(ISum **instance)
{
    return std::make_shared<Sum>()->QueryInterface(IID_PPV_ARGS(instance));
}

ポイントは AddRef / Release の実装です。

ULONG STDMETHODCALLTYPE Sum::AddRef(void)
{
    int32_t count = ++m_ref;
    if (count == 1)
    {
        m_sharedThis = weak_from_this().lock();
    }
    return count;
}

ULONG STDMETHODCALLTYPE Sum::Release(void)
{
    int32_t count = --m_ref;
    if (count == 0)
    {
        m_sharedThis = nullptr;
    }
    return count;
}

※参照カウンターは std::atomic を使用しています。

AddRef で参照カウンターが 1 になった時に自身の shared_ptr を自身で保持 (所有) し、 Release で 0 になるタイミングで保持していた参照を解放します。参照カウンターは 1 になる瞬間と 0 になる瞬間が状態変更として一番重要で、他はカウンター値を適切に管理していれば重要ではありません。

また、 Release の一般的な実装は "カウンターが 0 になったら delete this; をする" だと思いますが、このインスタンスは shared_ptr で管理されているので直接 delete をすることはできません (そもそもカウンターが 0 になった時の挙動は絶対ではありません。実装によっては参照カウンターの上げ下げだけをしてそれ以外は何もしないと、ということもあり得ます) 。

  • 自身で自身の参照を持つことで一見循環参照をしているように見えますが、これは AddRef に応じた shared_ptr の参照カウンター制御であり循環参照にはなりません。
    • shared_ptr による管理が一時的に無効化されているだけ、と見ることもできます。実体として直接 new - delete しているのと変わらない。
  • COM の参照カウンターが 0 になっても他の shared_ptr による参照があればインスタンスは削除されない。これは COM の参照が一時的になくなっても再度 COM 参照を取得できる事を意味します。

WeakReference を実装する

class WeakReference :
    public IWeakReference
{
private:
    std::atomic_int32_t m_ref;
    std::weak_ptr<Sum> m_weak;

public:
    WeakReference(std::weak_ptr<Sum> const& sharedThis);

本体の Sum を weak_ptr で保持します (コンストラクタ経由で渡します。 Sum::GetWeakReference 参照) 。

HRESULT STDMETHODCALLTYPE WeakReference::Resolve(REFIID iid, void **ppv)
{
    auto locked = m_weak.lock();
    if (locked != nullptr)
    {
        return locked->QueryInterface(iid, ppv);
    }

    *ppv = nullptr;
    return S_OK;
}

weak_ptr::lock() はインスタンスが有効であれば有効な参照が shared_ptr で取得されます。

ここで有効なインスタンス参照が取得できたとして、ポイントは COM の参照カウンターが 0 になっている 可能性があることです。しかし、あくまで 参照が有効かどうかは shared_ptr の仕組みで管理されている ため COM のカウンターが 0 でもインスタンスが削除されているわけではないので問題なくアクセス可能です。ここから QueryInterface が呼ばれることで COM 側の参照カウンターも 1 になり、完全に有効な状態に復帰するようになります。

おわりに

今回は毎回個別に IUnknown の実装をしてしまっていますが、実際は何かしらのクラスライブラリを実装するか ATL の COM 実装クラスなどとうまく組み合わせる方法を模索した方がいいとは思います。

IUnknown に限らずクラスに直接参照カウンターを実装している系では同じ手法をとることができると思います。 IUnknown のように参照カウンターを直接組み込んでいるものは C++ 外との相互運用で C++ インスタンスの管理に便利なので Windows 以外でも応用がきくのではないでしょうか。

関連してそうな記事