Windows と C++

COM のスマート ポインター再考

Kenny Kerr

Kenny KerrCOM (別名 Windows ランタイム) が再び脚光を浴びて以来、効率がよく信頼性の高い COM インターフェイス向けのスマート ポインターの必要性がかつてないほど重要になっています。とは言え、優れた COM のスマート ポインターとはどのようなものでしょう。かなり前から、業界標準の COM スマート ポインターは ATL の CComPtr クラス テンプレートだと感じています。Windows 8 用 Windows SDK で Windows ランタイム C++ テンプレート ライブラリ (WRL) の一部として ComPtr クラス テンプレートが導入され、一部では ATL の CComPtr に代わる最新のスマート ポインターだともてはやされました。当初はこれで一歩前進したと思いましたが、WRL の ComPtr を何度か使ってみたところ、使用するのは避けるべきだという結論に達しました。その理由は、後ほど説明します。

では、どうすればよいでしょう。ATL に戻るべきでしょうか。そんなことはありません。でも、おそらく、Visual C++ 2015 で提供される最新の C++ の一部を COM インターフェイス用の新しいスマート ポインターのデザインに当てはめてみる時期にきているのかもしれません。Connect(); の Visual Studio 2015 と Microsoft Azure の特別号では、Visual C++ 2015 を最大限に活かせば、Implements クラス テンプレートを使って IUnknown と IInspectable を簡単に実装できるようになることを紹介しました。今回は、Visual C++ 2015 をさらに活かして、新しい ComPtr クラス テンプレートを実装する方法を取り上げます。

スマート ポインターの記述は難しいことで有名ですが、C++11 のおかげで昔ほどは難しくなくなっています。その理由の 1 つは、ライブラリの開発者が表現力の乏しい C++ 言語と標準ライブラリを避けて作業できる巧妙な技法が考案されたことにあります。これにより、効率と正確さを損なわずに、独自のオブジェクトを組み込みポインターのように機能させることができます。私たちのようなライブラリ開発者の作業を非常に楽にしてくれるのが rvalue 参照です。もう 1 つの理由は、既存のデザインが適切に機能するしくみがわかると自然と明らかになります。もちろん、すべての開発者には、控えめにして、考えられるすべての機能を 1 つの特定の抽象化に詰め込まないようにするという難題があります。

最も基本的なレベルとして、COM スマート ポインターは基になる COM インターフェイス ポインター用にリソース管理を用意しなければなりません。つまり、スマート ポインターをクラス テンプレートにし、目的の型のインターフェイス ポインターを格納します。技術的に言えば、特定の型のインターフェイス ポインターを実際に格納する必要はなく、代わりに IUnknown インターフェイス ポインターを格納するだけでもかまいません。ただし、その場合は、スマート ポインターを逆参照するたびに static_cast を利用することになります。これは便利な点もありますが、危険な考え方でもあります。これについては今後のコラムで取り上げる予定です。今回は、次のように、厳密に型指定されたポインターを格納するための基本クラス テンプレートから始めます。

template <typename Interface>
class ComPtr
{
public:
  ComPtr() noexcept = default;
private:
  Interface * m_ptr = nullptr;
};

ベテランの C++ 開発者には一見不思議に思えますが、現役の C++ 開発者であればあまり驚かないでしょう。m_ptr メンバー変数は、静的ではないデータ メンバーを宣言時に初期化できるという優れた新機能を使用しています。その結果、時間が経ちコンストラクターが追加、変更されたときに、メンバー変数をうっかり初期化し忘れる危険性が大幅に減少します。特定のコンストラクターが明示的に行う初期化は、この動的な初期化よりも優先されますが、ほとんどの場合、予測できない初期値を必要とするメンバー変数が設定されても、コンストラクターではその初期化を考える必要がありません。

インターフェイス ポインターが確実に初期化されるようになることを前提に、もう 1 つ、特殊なメンバー関数の既定の定義を明示的に要求する新機能も使用できます。上記の例では、既定のコンストラクター (既定の default コンストラクター) の既定の定義を要求しています。それがどうしたと思われるかもしれませんが、特殊なメンバー関数を既定値にしたり削除する機能と、宣言時にメンバー変数を初期化する機能は、Visual C++ 2015 で提供されている機能の中で私のお気に入りの機能なのです。ささいなことにも価値があるのです。

COM スマート ポインターが用意すべき最も重要なサービスは、煩わしい COM 参照カウント モデルの危険から開発者を守ることです。私自身は参照をカウントする COM の手法を気に入っていますが、それをライブラリに対処させようと考えています。この処理は、ComPtr クラス テンプレート全体の多く場面でさりげなく行われていますが、最も明白に行われているのは、呼び出し元がスマート ポインターを逆参照したときです。誤ってでも意図的にでも、呼び出し元が次のようなコードを記述することは望みません。

ComPtr<IHen> hen;
hen->AddRef();

仮想関数の AddRef または Release を呼び出す機能は、スマート ポインターの監視下だけで行われるべきです。もちろん、スマート ポインターでは、逆参照操作などによってその他のメソッドを呼び出せるようにする必要があります。通常、スマート ポインターの逆参照演算子は、次のようになります。

Interface * operator->() const noexcept
{
  return m_ptr;
}

これは COM インターフェイス ポインターには有効です。アサーションは必要ありません。アクセス違反の方が情報を得られるためです。しかし、この実装では依然として、呼び出し元は AddRef と Release を呼び出すことができます。これを解決するには、AddRef と Release の呼び出しを禁止する型を単純に返します。これには、次のような簡単なクラス テンプレートが有効です。

template <typename Interface>
class RemoveAddRefRelease : public Interface
{
  ULONG __stdcall AddRef();
  ULONG __stdcall Release();
};

RemoveAddRefRelease クラス テンプレートは、テンプレート引数のメソッドをすべて継承しますが、AddRef と Release をプライベートで宣言すると、呼び出し元がこれらのメソッドを誤って参照することはなくなります。スマート ポインターの逆参照演算子は、次のように static_cast を使用するだけで、返されるインターフェイス ポインターを保護できます。

RemoveAddRefRelease<Interface> * operator->() const noexcept
{
  return static_cast<RemoveAddRefRelease<Interface> *>(m_ptr);
}

これは、今回の ComPtr が WRL の手法に従っていない例の 1 つにすぎません。WRL では QueryInterface を含め、IUnknown のすべてのメソッドをプライベートにすることが選択されています。このように呼び出し元を制限する理由が私にはわかりません。つまり、WRL ではこの基本的なサービスに代わるものを必ず用意する必要があり、そのため複雑さが増し、呼び出し元を混乱させています。

今回の ComPtr は参照をカウントするコマンドを明確に実行するので、これを正しく行う方法を考えます。まず、プライベート ヘルパー関数のペアから説明します。最初は AddRef です。

void InternalAddRef() const noexcept
{
  if (m_ptr)
  {
    m_ptr->AddRef();
  }
}

大したことは行っていませんが、条件に応じて参照を受け取る必要があるさまざまな関数があります。それでもこの関数は常に適切な処理を確実に行います。対応する Release のヘルパー関数はやや複雑です。

void InternalRelease() noexcept
{
  Interface * temp = m_ptr;
  if (temp)
  {
    m_ptr = nullptr;
    temp->Release();
  }
}

なぜ一時変数が必要なのでしょう。そこで、InternalAddRef 関数の内部で (正しく) 実行すべきことを大まかに捉えて、次のように直感的な実装、しかし間違った実装を考えてみましょう。

if (m_ptr)
{
  m_ptr->Release(); // BUG!
  m_ptr = nullptr;
}

ここで問題なのは、Release メソッドを呼び出すと、イベントのチェーンが作成されることです。このチェーンによって、2 回目にオブジェクトが解放されることがわかります。この InternalRelease による 2 回目の呼び出しでは、null 以外のインターフェイス ポインターを再度見つけて、それに対して再び Release の実行を試みます。これは明らかにあまり一般的ではないシナリオですが、ライブラリ開発者の役割はこのような事態を想定しておくことです。一時変数を利用する最初の実装では、まずスマート ポインターからインターフェイス ポインターをデタッチして、Release を 1 回だけ呼び出すことで、Release の二重呼び出しを回避しています。過去を振り返ってみると、Jim Springfield が ATL でこの厄介なバグを最初に見つけたようです。いずれにせよ、これら 2 つのヘルパー関数を用意したら、そのオブジェクトが動作し、組み込みのオブジェクトのように感じられる特殊なメンバー関数をいくつか実装します。そのシンプルな例がコピー コンストラクターです。

独占的に所有できるスマート ポインターとは異なり、COM のスマート ポインターではコピーによる作成を許可します。いかなる代償を払ってもコピーを阻止する場合は慎重さが必要ですが、呼び出し元が実際にコピーを希望する場合はコピーを許可します。以下に、シンプルなコピー コンストラクターを示します。

ComPtr(ComPtr const & other) noexcept :
  m_ptr(other.m_ptr)
{
  InternalAddRef();
}

これは、コピーによる作成の明確なケースに対処します。ここでは InternalAddRef ヘルパーを呼び出す前に、インターフェイス ポインターをコピーします。このままにしておけば、ComPtr のコピーはほぼ組み込みのポインターのように感じますが、まったくそうはなりません。たとえば、次のようにコピーを作成できます。

ComPtr<IHen> hen;
ComPtr<IHen> another = hen;

これは、次のようなポインターそのものの使い方を表しています。

IHen * hen = nullptr;
IHen * another = hen;

しかし、ポインターそのものは以下の使い方も許可されます。

IUnknown * unknown = hen;

今回のシンプルなコピー コンストラクターでは、ComPtr を使って次のように同じことを行うことは許可されません。

ComPtr<IUnknown> unknown = hen;

最終的に IHen は IUnknown から派生する必要がありますが、ComPtr<IHen> は ComPtr<IUnknown> から派生されず、コンパイラは無関係な型と見なします。必要なのは、論理的に関連する他の ComPtr オブジェクト用に論理コピー コンストラクターとして機能するコンストラクターです。具体的には、作成済みの ComPtr のテンプレート引数に変換できるテンプレート引数を備えた ComPtr です。ここで、WRL は型の特質を利用しますが、実際には必要ありません。必要なのは、変換の可能性を提供する関数テンプレートで、実際に変換可能かどうかはコンパイラにチェックさせるだけです。

template <typename T>
ComPtr(ComPtr<T> const & other) noexcept :
  m_ptr(other.m_ptr)
{
  InternalAddRef();
}

ここで、他のポインターを使用してオブジェクトのインターフェイス ポインターを初期化し、コンパイラがそのコピーが実際に有用かどうかをチェックします。次の場合はコンパイルされます。

ComPtr<IHen> hen;
ComPtr<IUnknown> unknown = hen;

しかし、次の場合はコンパイルされません。

ComPtr<IUnknown> unknown;
ComPtr<IHen> hen = unknown;

これは当然です。もちろん、コンパイラは依然として 2 つを大きく異なる型と見なすため、2 つをフレンド クラスにしない限り、コンストラクター テンプレートは実際には他のプライベート メンバー変数にアクセスできません。

template <typename T>
friend class ComPtr;

IHen は IHen に変換できるため、一部の冗長なコードを削除したくなるかもしれません。なぜ実際のコピー コンストラクターを削除しないのでしょう。問題は、この 2 つ目のコンストラクターがコンパイラによってコピー コンストラクターと見なされないことです。コピー コンストラクターを省略すると、コンパイラはそのコピー コンストラクターを削除し、この削除済みのコンストラクター関数へのすべての参照を拒否していると想定します。先に進みましょう。

コピーによる作成に対処したら、ComPtr で移動による作成も用意することが非常に重要です。移動による作成が特定のシナリオで許容される場合、ComPtr では、参照の移動を保存するときにコンパイラが選択できるようにします。保存操作は、移動操作に比べてコストが高くなります。ムーブ コンストラクターは、InternalAddRef を呼び出す必要がないため、コピー コンストラクターよりもさらにシンプルです。

ComPtr(ComPtr && other) noexcept :
  m_ptr(other.m_ptr)
{
  other.m_ptr = nullptr;
}

rvalue 参照でポインターをクリアまたはリセットする (つまり、オブジェクトを移動する) 前にインターフェイス ポインターをコピーします。ただし、この場合、コンパイラはそれほどこだわりがないので、変換可能な型をサポートする汎用バージョンでは単純にこのムーブ コンストラクターを避けてもかまいません。

template <typename T>
ComPtr(ComPtr<T> && other) noexcept :
  m_ptr(other.m_ptr)
{
  other.m_ptr = nullptr;
}

これで、ComPtr コンストラクターは完成です。デストラクターは想像どおりシンプルです。

~ComPtr() noexcept
{
  InternalRelease();
}

InternalRelease ヘルパーの内部でデストラクションのニュアンスには既に対処しているため、その優れた点を再利用するだけです。コピーによる作成と移動による作成について説明しましたが、このスマート ポインターでこれに対応する代入演算子を、実際のポインターのように感じるように提供することも必要です。そのため、プライベート ヘルパー関数のペアをもう 1 組追加します。1 つ目は、特定のインターフェイス ポインターのコピーを安全に取得するためのものです。

void InternalCopy(Interface * other) noexcept
{
  if (m_ptr != other)
  {
    InternalRelease();
    m_ptr = other;
    InternalAddRef();
  }
}

インターフェイス ポインターが等しくない (またはどちらも null ポインターでない) 場合、既存の参照を解放してから、ポインターをコピーして、新しいインターフェイス ポインターへの参照の安全性を確保します。このようにして、スマート ポインターが既に参照を保持している場合でも、簡単に InternalCopy を呼び出して、特定のインターフェイスへの一意参照の所有権を取得できます。同様に、2 つ目のヘルパーは特定のインターフェイス ポインターと、それが表す参照カウントを安全に移動します。

template <typename T>
void InternalMove(ComPtr<T> & other) noexcept
{
  if (m_ptr != other.m_ptr)
  {
    InternalRelease();
    m_ptr = other.m_ptr;
    other.m_ptr = nullptr;
  }
}

InternalCopy は変換可能な型を自然にサポートしますが、この関数はクラス テンプレート用にこの機能を提供するテンプレートです。一方、InternalMove はほぼ同じですが、追加の参照を取得するのではなく、インターフェイス ポインターを論理的に移動します。前置きはこれぐらいにして、ごく簡単に代入演算子を実装できます。最初は、コピー代入です。コピー コンストラクターと同様、標準形式を用意する必要があります。

ComPtr & operator=(ComPtr const & other) noexcept
{
  InternalCopy(other.m_ptr);
  return *this;
}

次に、変換可能な型のテンプレートを用意します。

template <typename T>
ComPtr & operator=(ComPtr<T> const & other) noexcept
{
  InternalCopy(other.m_ptr);
  return *this;
}

ムーブ コンストラクターも同様に、汎用の移動代入を 1 つ用意するだけです。

template <typename T>
ComPtr & operator=(ComPtr<T> && other) noexcept
{
  InternalMove(other);
  return *this;
}

移動の考え方は多くの場合、参照カウントを利用するスマート ポインターに関して言えば、コピーよりも優れていますが、コストがかからないわけではないので、一部の主要シナリオでは移動の考え方を避ける優れた方法として、交換の考え方を用意することもできます。多くのコンテナーの型では、移動よりも交換操作が好まれます。交換操作では、一時オブジェクトに大きな負荷がかかりません。ComPtr に交換の機能を実装するのは、非常に簡単です。

void Swap(ComPtr & other) noexcept
{
  Interface * temp = m_ptr;
  m_ptr = other.m_ptr;
  other.m_ptr = temp;
}

標準の交換アルゴリズムを使用していますが、少なくとも Visual C++ の実装では、必須の <utility> ヘッダーに間接的に <stdio.h> が含まれているため、交換のためだけにすべてをインクルードさせることはしたくありません。もちろん、今回の Swap メソッドを検索する汎用のアルゴリズム向けに、メンバーではない (小文字の) swap 関数も用意しておく必要があります。

template <typename Interface>
void swap(ComPtr<Interface> & left, 
  ComPtr<Interface> & right) noexcept
{
  left.Swap(right);
}

これが ComPtr クラス テンプレートと同じ名前空間で定義されていれば、コンパイラは汎用アルゴリズムから swap を問題なく使用できるようにします。

C++11 のもう 1 つの優れた機能は、明示的な変換演算子の機能です。従来は、スマート ポインターが論理的に null にならなかったかどうかをチェックするために信頼性の高い明示的なブール演算子を生成するという面倒な作業が必要でした。これが次のようにシンプルになりました。

explicit operator bool() const noexcept
{
  return nullptr != m_ptr;
}

これは、特殊なメンバーや実質的に特殊になるメンバーに対処します。こうしたメンバーは、コンパイラがオーバーヘッドなしで最適化できる多くのサポートを備え、今回のスマート ポインターを組み込みの型とほぼ同じように動作させます。残っているのは、COM アプリケーションでよく必要になる少しのヘルパーです。追加する機能が多くなりすぎないよう注意します。それでも、重要なアプリケーションやコンポーネントのほとんどが利用することになる関数がいくつかあります。まず、基になる参照を明示的に解放する方法が必要です。これは次のようにごく簡単です。

void Reset() noexcept
{
  InternalRelease();
}

基になるポインターを取得する方法も必要です。呼び出し元が他の一部の関数への引数としてそのポインターを渡す必要があるためです。

Interface * Get() const noexcept
{
  return m_ptr;
}

参照をデタッチして、呼び出し元に返すことが必要になる場合もあります。

Interface * Detach() noexcept
{
  Interface * temp = m_ptr;
  m_ptr = nullptr;
  return temp;
}

既存のポインターのコピーを作成することも必要かもしれません。このコピーは、呼び出し元が参照として保持することになります。私は次のように保持するようにしています。

void Copy(Interface * other) noexcept
{
  InternalCopy(other);
}

また、ポインターそのものを保持することもできます。保持するポインターは、追加の参照を必要としないでアタッチするターゲットへの参照を所有します。これは次のように参照を結合する場合も便利です。

void Attach(Interface * other) noexcept
{
  InternalRelease();
  m_ptr = other;
}

最後にいくつか特に重要な役割を果たす関数を少し詳しく説明します。COM のメソッドは以前からポインターへのポインターを使って参照を出力パラメーターとして返します。COM のスマート ポインターでは、このような参照を直接取得する方法を用意することが重要です。そのために、GetAddressOf メソッドを用意します。

Interface ** GetAddressOf() noexcept
{
  ASSERT(m_ptr == nullptr);
  return &m_ptr;
}

ここでも今回の ComPtr は WRL の実装に従いません。その違いは微妙ですが、非常に大きな意味があります。GetAddressOf では、そのアドレスを返す前に参照を保持しないことをアサートしているのがわかります。これは非常に重要です。呼び出し先の関数は保持されている参照を単純に上書きして、参照リークが発生します。アサーションがなければ、このようなバグは検出するのは非常に難しくなります。この対極にあるのが参照を受け渡す機能です。同じ型の参照を受け渡す場合もあれば、基になるオブジェクトが実装する他のインターフェイス用に参照を受け渡す場合もあります。同じインターフェイスへの別の参照が必要な場合、今回は QueryInterface を呼び出すのを避け、COM で定められている規約を使用して単純に追加の参照を返します。

void CopyTo(Interface ** other) const noexcept
{
  InternalAddRef();
  *other = m_ptr;
}

この関数は、次のように使用します。

hen.CopyTo(copy.GetAddressOf());

それ以外にも、ComPtr からのサポートを受けずに、次のように QueryInterface 自体を利用してもかまいません。

HRESULT hr = hen->QueryInterface(other.GetAddressOf());

ここでは実際には、IUnknown によって直接提供される関数テンプレートに利用して、インターフェイスの GUID を明示的に提供する必要をなくしています。

最後に、アプリケーションやコンポーネントは、従来の COM 規約のように呼び出し元に GUID を返す必要なく、インターフェイスに対してクエリを実行する必要がある場合が数多くあります。このような場合、次のように、別の ComPtr の内部に適切に収容されたこの新しいインターフェイス ポインターを返すようにします。

template <typename T>
ComPtr<T> As() const noexcept
{
  ComPtr<T> temp;
  m_ptr->QueryInterface(temp.GetAddressOf());
  return temp;
}

明示的なブール演算子を使用するだけで、クエリが成功したかどうかをチェックできます。最後に、ComPtr では、利便性と、さまざまなコンテナーや汎用アルゴリズムのサポートを目的として、メンバーでなくても期待される比較演算子もすべて用意します。ここまで説明してきたように、これはスマート ポインターを機能させ、組み込みポインターのように感じられ、必要不可欠なサービスを提供して、リソースを適切に管理し、COM アプリケーションやコンポーネントが期待する必要なサービスを提供します。ComPtr クラス テンプレートは、Windows Runtime 用の Modern C++ (moderncpp.com、英語) とはまったく別物です。


Kenny Kerr は、カナダを拠点とするコンピューター プログラマであり、Pluralsight の執筆者です。また、Microsoft MVP でもあります。彼のブログは kennykerr.ca (英語) で、Twitter は twitter.com/kennykerr (英語) でフォローできます。

この記事のレビューに協力してくれたマイクロソフト技術スタックの James McNellis に心より感謝いたします。