Windows と C++

Visual C++ 2015 によりレガシー コードで最新の C++ を利用する

Kenny Kerr

Kenny KerrWindows でのシステム プログラミングは、ハンドルという不明瞭な概念を数多く利用します。このハンドルは、C スタイルの API の背後に隠れたオブジェクトを表します。開発者は、かなり高度なプログラミング スキルを備えていない限り、さまざまな型のハンドルの管理に追われることになります。ハンドルという概念は、多くのライブラリやプラットフォームに存在し、Windows OS だけが特別なわけではありません。スマート ハンドル クラス テンプレートを初めて取り上げたのは、C++11 初期言語機能の一部が Visual C++ に導入され始めた 2011 年のことです (msdn.microsoft.com/magazine/hh288076、英語)。Visual C++ 2010 では正しい意味合いを持つ便利なハンドル ラッパーを作成できるようになりましたが、C++11 のサポートが最小限に抑えられたため、そのようなクラスを正しく作成するには依然として多くの作業を必要としました。今年は Visual C++ 2015 がリリースされます。そこで、このトピックを見直し、最新の C++ を C スタイルの古いライブラリで利用する方法について、多くのアイデアをお伝えしようと考えました。

最高のライブラリはリソースを一切確保しません。そのため、ラップする必要は最低限に抑えられます。私が好んで取り上げる例は、Windows のスリム リーダー/ライター (SRW: Slim Reader/Writer) ロックです。このロックを使用する準備として必要なのは、SRW ロックを作成し、初期化することだけです。

SRWLOCK lock = {};

SRW ロックの構造には、1 つの void * ポインターが含まれるだけで、クリーンアップするものはありません。使用前に初期化しなければならず、唯一の制限事項は移動もコピーもできないことです。当然、近代化する際に深く関係するのは、リソースの管理ではなく、ロック保持中の例外安全性です。しかし、こうしたシンプルな要件を満たすには、最新の C++ が役に立ちます。まず、静的ではないデータ メンバーを初期化する機能を使用できます。このデータ メンバーは、SRW ロックを使用する準備のために宣言します。

class Lock
{
  SRWLOCK m_lock = {};
};

これで初期化は行われますが、このままではロックのコピーと移動は可能です。そこで、既定のコピー コンストラクターとコピー代入演算子を次のように削除する必要があります。

class Lock
{
  SRWLOCK m_lock = {};
public:
  Lock(Lock const &) = delete;
  Lock & operator=(Lock const &) = delete;
};

これで、コピーも移動もできなくなります。これらをクラスのパブリック部分で宣言することで、適切なコンパイル エラー メッセージが生成されやすくなります。もちろん、既定のコンストラクターが想定されなくなるため、これを用意する必要があります。

class Lock
{
  SRWLOCK m_lock = {};
public:
  Lock() noexcept = default;
  Lock(Lock const &) = delete;
  Lock & operator=(Lock const &) = delete;
};

コードを一切記述していないにもかかわらず、ごく簡単にロックを作成できるようになります。つまり、コンパイラがすべてを生成してくれます。

Lock lock;

コンパイラは、ロックのコピーも移動も許可しません。

Lock lock2 = lock; // Error: no copy!
Lock lock3 = std::move(lock); // Error: no move!

次に、さまざまな方法でロックを取得および解放する必要がありますが、こうしたメソッドも簡単に追加できます。SRW ロックは、その名前のとおり、読み取りの場合は共有ロックをかけ、書き込みの場合は排他ロックをかける二面性を提供します。図 1 は、単純な排他ロックをかけるために必要なメソッドの最小限のセットを示しています。

図 1 簡単かつ効果的な SRW ロック

class Lock
{
  SRWLOCK m_lock = {};
public:
  Lock() noexcept = default;
  Lock(Lock const &) = delete;
  Lock & operator=(Lock const &) = delete;
  void Enter() noexcept
  {
    AcquireSRWLockExclusive(&m_lock);
  }
  void Exit() noexcept
  {
    ReleaseSRWLockExclusive(&m_lock);
  }
};

この驚くほどシンプルなロック プリミティブを支えるテクニックの詳細については、「Windows と C++ での同期の進化」 (msdn.microsoft.com/magazine/jj721588、英語) を参照してください。残っている作業は、ロックの所有権に関連する簡単な例外安全性を提供することです。次のようなコードはあまり記述したくはありません。

lock.Enter();
// Protected code
lock.Exit();

代わりに、特定のスコープのロックを取得および解放を行うロック ガードを使用します。

Lock lock;
{
  LockGuard guard(lock);
  // Protected code
}

このようなロック ガードでは、基盤となるロックへの参照を保持するだけです。

class LockGuard
{
  Lock & m_lock;
};

Lock クラス自体と同様、guard クラスでもコピーと移動をどちらも許可しません。

class LockGuard
{
  Lock & m_lock;
public:
  LockGuard(LockGuard const &) = delete;
  LockGuard & operator=(LockGuard const &) = delete;
};

あとは、コンストラクターでロックをかけ、デストラクターでロックを解放するだけです。図 2 は、この例をまとめたものです。

図 2 簡単なロック ガード

class LockGuard
{
  Lock & m_lock;
public:
  LockGuard(LockGuard const &) = delete;
  LockGuard & operator=(LockGuard const &) = delete;
  explicit LockGuard(Lock & lock) noexcept :
    m_lock(lock)
  {
    m_lock.Enter();
  }
  ~LockGuard() noexcept
  {
    m_lock.Exit();
  }
};

公平に言えば、Windows SRW ロックはかなり独特な小さい gem にすぎず、ほとんどのライブラリでは少量のストレージまたはなんらかのリソースを用意して明示的に管理する必要があります。COM インターフェイス ポインターを管理する最善の方法については、「COM のスマート ポインター再考」 (msdn.microsoft.com/magazine/dn904668) で既に取り上げています。そこで、今回は、不明瞭なハンドルの一般的な例に注目します。以前説明したように、ハンドル クラス テンプレートでは、ハンドルの型をパラメーター化する方法だけでなく、ハンドルを閉じる方法、さらには無効なハンドルを正確に表すしくみを提供しなければなりません。無効なハンドルを表す場合、すべてのライブラリが Null 値やゼロ値を使用するわけではありません。初めてハンドル クラス テンプレートを作成したとき、呼び出し側がハンドルの特性を表すクラス (以下、特性クラス) を用意して、必要なセマンティクスと型情報を提供することを考えました。その後数年の間、このような特性クラスを数多く作成してきましたが、その大半が似たようなパターンに従うことがわかりました。C++ 開発者がよく言うように、パターンとは、適切に記述されたテンプレートです。そこで、今回はハンドル クラス テンプレートと共に、ハンドルの特性クラス テンプレートを採用することにします。ハンドルの特性クラス テンプレートは必須ではありませんが、使用すると大半の定義が簡略化されます。その定義を以下に示します。

template <typename T>
struct HandleTraits
{
  using Type = T;
  static Type Invalid() noexcept
  {
    return nullptr;
  }
  // Static void Close(Type value) noexcept;
};

HandleTraits クラス テンプレートが提供するものと、明確には提供しないものに注目してください。これまで、nullptr 値を返す Invalid メソッドを数多く作成してきました。明らかにこれが既定の方法のように思えたためです。一方、見ればわかるように、各具象特性クラスは独自の Close メソッドを提供しなければなりません。コメントに従うべきパターンを示しています。型エイリアスもオプションで、このテンプレートから派生する独自の特性クラスの定義を簡単にするために用意しています。したがって、Windows の CreateFile 関数から返されるファイル ハンドル用の特性クラスを定義すると、次のようになります。

struct FileTraits
{
  static HANDLE Invalid() noexcept
  {
    return INVALID_HANDLE_VALUE;
  }
  static void Close(HANDLE value) noexcept
  {
    VERIFY(CloseHandle(value));
  }
};

CreateFile 関数は、関数でエラーが発生すると INVALID_HANDLE_VALUE 値を返します。ハンドルが正しく作成されたら、そのハンドルを CloseHandle 関数を使用して閉じる必要があります。これは、明らかに独特の使い方です。Windows の CreateThreadpoolWork 関数は、work オブジェクトを表す PTP_WORK ハンドルを返します。これは不明瞭なポインターで、エラー時には nullptr 値を返すのが自然です。そのため、work オブジェクト用の特性クラスに HandleTraits クラス テンプレートを利用できます。そうすれば、少しだけ入力が少なくなります。

struct ThreadPoolWorkTraits : HandleTraits<PTP_WORK>
{
  static void Close(Type value) noexcept
  {
    CloseThreadpoolWork(value);
  }
};

では、実際の Handle クラス テンプレートの方はどうなるでしょう。こちらでは、特定の特性クラスを利用して、ハンドルの型を推測し、必要に応じて Close メソッドを呼び出すだけです。推測は、decltype 式の形式でハンドルの型を判断します。

template <typename Traits>
class Handle
{
  using Type = decltype(Traits::Invalid());
  Type m_value;
};

この方法では、特性クラスの作成者が型エイリアスまたは typedef を含めて型を明示的かつ冗長的に指定する必要がなくなります。ハンドルを閉じることが最優先の処理になるため、安全な Close ヘルパー メソッドを Handle クラス テンプレートのプライベート部分に収めます。

void Close() noexcept
{
  if (*this) // operator bool
  {
    Traits::Close(m_value);
  }
}

この Close メソッドは明示的なブール演算子を使用してハンドルを閉じる必要があるかどうかを判断してから、特性クラスを呼び出して実際に操作を実行します。パブリックの明示的ブール演算子を明示的変換演算子として実装できるという点で、2011 年に作成したハンドル クラス テンプレートよりも強化されています。

explicit operator bool() const noexcept
{
  return m_value != Traits::Invalid();
}

これで、あらゆる種類の問題が解決され、ブール風の演算子を実装する従来のアプローチよりも非常に簡単に定義できるようになることは間違いありません。そのうえ、コンパイラが許可しかねない恐ろしい暗黙の変換を防ぐことができます。他にも言語の改善点があります。今回既に取り上げた、特別なメンバーを明示的に削除する機能です。ここで、コピー コンストラクターとコピー代入演算子でこれを試してみましょう。

Handle(Handle const &) = delete;
Handle & operator=(Handle const &) = delete;

既定のコンストラクターは特性クラスを使用して、予測可能な方法でハンドルを初期化することができます。

explicit Handle(Type value = Traits::Invalid()) noexcept :
  m_value(value)
{}

デストラクターでは Close ヘルパーを使用するだけです。

~Handle() noexcept
{
  Close();
}

これで、コピーは許可されなくなります。しかし、SRW ロックは別にして、メモリ内でのハンドルの移動が認められないハンドル リソースは思い付きません。ハンドルを移動する機能は非常に便利です。ハンドルの移動には、デタッチとアタッチ、またはデタッチとリセットと呼ばれる 2 つの個別の操作が必要です。デタッチでは、ハンドルの所有権を呼び出し元に解放します。

Type Detach() noexcept
{
  Type value = m_value;
  m_value = Traits::Invalid();
  return value;
}

ハンドル値を呼び出し元に返し、ハンドル オブジェクトのコピーを無効にして、デストラクターが特性クラスによって指定された Close メソッドを呼び出さないようにします。アタッチまたはリセットの補完的な操作として、既存のハンドルを閉じてから、新しいハンドル値の所有権を想定します。

bool Reset(Type value = Traits::Invalid()) noexcept
{
  Close();
  m_value = value;
  return static_cast<bool>(*this);
}

Reset メソッドの既定値はハンドルの無効な値で、ハンドルを早期に閉じる簡単な方法になります。また、便宜上、明示的ブール演算子の結果も返します。これはまったく通常どおりに次のパターンを作成していることがわかります。

work.Reset(CreateThreadpoolWork( ... ));
if (work)
{
  // Work object created successfully
}

以下は、明示的ブール演算子を使用して、事後にハンドルが有効かどうかをチェックしています。これを 1 つの式にまとめると、非常に便利です。

if (work.Reset(CreateThreadpoolWork( ... )))
{
  // Work object created successfully
}

これでこのハンドシェイクが整ったので、移動操作を実に簡単に実装できます。まず、移動コンストラクターから始めます。

Handle(Handle && other) noexcept :
  m_value(other.Detach())
{}

Detach メソッドは rvalue 参照で呼び出され、新しく構築した Handle は事実上、他の Handle オブジェクトから所有権が移されます。移動代入演算子はほんの少し複雑になります。

Handle & operator=(Handle && other) noexcept
{
  if (this != &other)
  {
    Reset(other.Detach());
  }
  return *this;
}

最初に ID チェックを実行し、閉じられたハンドルがアタッチされるのを防ぎます。基になる Reset メソッドは、移動代入ごとに 2 つの分岐が追加で必要になるため、このようなチェックをわざわざ実行しません。1 つなら堅実ですが、2 つは冗長です。もちろん、移動という考え方も重要ですが、交換という考え方も便利です。特に標準コンテナーでハンドルを格納している場合は重要です。

void Swap(Handle<Traits> & other) noexcept
{
  Type temp = m_value;
  m_value = other.m_value;
  other.m_value = temp;
}

一般に、汎用性を目的として、非メンバーの小文字の swap 関数が必要です。

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

Handle クラス テンプレートへの最終的な調整として、Get メソッドと Set メソッドをペアの形式で含めます。Get は明確です。

Type Get() const noexcept
{
  return m_value;
}

基になるハンドル オブジェクトを返すだけです。おそらく、このオブジェクトをさまざまなライブラリ関数に渡す必要があります。Set は Get ほど明確ではありません。

Type * Set() noexcept
{
  ASSERT(!*this);
  return &m_value;
}

上記は間接的な設定操作です。アサーションがこの事実をよく表しています。過去にこの GetAddressOf を呼び出したことがありますが、その名前は真の目的をあいまいにしているか、矛盾しています。そのような間接的な設定操作が必要となるのは、ライブラリが出力パラメーターとしてハンドルを返す場合です。WindowsCreateString 関数は、数多くの中の一例にすぎません。

HSTRING string = nullptr;
HRESULT hr = WindowsCreateString( ... , &string);

この方法で WindowsCreateString を呼び出してから、結果のハンドルを Handle オブジェクトにアタッチするか、単純に Set メソッドを使用して所有権を直接想定します。

Handle<StringTraits> string;
HRESULT hr = WindowsCreateString( ... , string.Set());

その方がはるかに信頼が高く、データが遷移していく方向が明確にわかります。また、Handle クラス テンプレートでは通常の比較演算子も提供しますが、言語が明示的な変換演算子をサポートするようになったため、暗黙の変換を避ける必要がなくなりました。通常の比較演算子も便利ですが、それについては調べてみてください。Handle クラス テンプレートは、Windows Runtime 用の Modern C++ (moderncpp.com、英語) とはまったく別物です。


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