共用方式為


本文章是由機器翻譯。

Windows 中的 C++

C++ 與 Windows API

Kenny Kerr

Windows API 向 C++ 開發人員提出了一項挑戰。組成 API 的眾多庫大都表現為 C 語言風格的函數和控制碼或是 COM 風格的介面。這些用起來都不太方便,需要進行一定的封裝或間接操作。

C++ 開發人員的難題是如何確定合理的封裝級別。與 MFC 和 ATL 這樣的庫一起成長起來的開發人員可能傾向于將所有內容都包裝為類和成員函數,因為這是他們長久以來依靠的 C++ 庫所表現出的模式。也有些開發人員可能對任何形式的封裝都嗤之以鼻,而只是直接使用原始函數、控制碼和介面。可以說這部分開發人員不是真正的 C++ 開發人員,而只是有身份問題的 C 開發人員。我相信,現在的 C++ 開發人員有著更為自然的中間立場。

我在MSDN 雜誌重新開始了我的專欄,我在此將向您展示如何使用 C++0x(很可能會命名為 C++ 2011),以及如何使用 Windows API 將本機 Windows 軟體發展從黑暗時代解救出來。接下來幾個月,我將帶您更深入體驗 Windows 執行緒池 API。在這個過程中,您會看到如何編寫極具可擴展性的應用程式,而無需使用花哨的新語言以及複雜或昂貴的運行時。您只需要有優秀的 Visual C++ 編譯器、Windows API 和掌握技巧的願望就足夠了。

像所有好專案一樣,良好的基礎是成功的一半。那麼,我要如何“包裝”Windows API 呢?我不想在後面每個專欄中拘泥于這些細節,因此打算在本專欄中講清楚建議的做法,在以後依此執行。有關 COM 風格介面的問題暫不做討論,因為下麵幾個專欄還用不到這種介面。

Windows API 由很多庫組成,這些庫公開一組 C 語言風格的函數,以及一個或多個稱為控制碼的不透明指標。這些控制碼通常表示庫或系統資源。有相應的函數可用於創建、操作和釋放使用控制碼的資源。例如,CreateEvent 函數創建一個事件物件,並返回一個到該事件物件的控制碼。要釋放該控制碼並告知系統您已使用完該事件物件,只需將該控制碼傳遞給 CloseHandle 函數即可。如果同一事件物件再無其他現用控制碼,系統將銷毀該物件:

auto h = CreateEvent( ...
);
CloseHandle(h);

New to C++

如果您是初次接觸 C++ 2011,我要指出一點:auto 關鍵字會告知編譯器根據初始設定式推斷變數類型。 這在您不知道運算式類型時非常有用,在進行元程式設計或只想保存一些擊鍵時,常常會出現這種情況。

但您幾乎在任何時候都不應編寫這樣的代碼。 毫無疑問,C++ 提供的最有價值功能就是類的功能。 範本很酷,標準範本庫 (STL) 很神奇,但如果沒有類,C++ 中的一切都毫無意義。 C++ 程式簡明可靠的優點要歸功於類。 我說的不是虛函數、繼承和其他花哨的功能。 我說的只是構造函數和析構函數。 這往往就是您所需要的一切,還有, 這不需要付出任何成本。 在實踐中,您需要瞭解異常處理的開銷,本專欄末尾將討論這個問題。

要馴服 Windows API 並使其為 C++ 開發人員所用,需要一個封裝控制碼的類。 是的,您所喜愛的 C++ 庫已經有一個控制碼包裝,但它是完全為 C++ 2011 設計的嗎? 您能放心地將這些控制碼存儲在 STL 容器中,然後在程式中傳遞它們並跟蹤其所有者嗎?

C++ 類是完美的控制碼抽象。 請注意,我沒有說“物件”。要記住,控制碼是物件在程式中的代表,往往不是物件本身。 需要看管的是控制碼,而不是物件。 Windows API 物件與 C++ 類之間若存在一對一關聯性有時會非常方便,不過這是另外一個問題。

雖然控制碼一般是不透明的,但仍會存在不同類型的控制碼,而且往往有微妙的語義區別,這就有必要使用類範本以常規方式對控制碼充分進行包裝。 需要使用範本參數來指定控制碼類型以及控制碼的具體特性或特徵。

在 C++ 中,特徵類通常用於提供關於給定類型的資訊。 這樣,我可以為多個控制碼編寫一個類範本,並為 Windows API 中不同類型的控制碼提供不同的特徵類。 控制碼的特徵類還需要定義控制碼的釋放方式,以使控制碼類範本能夠根據需要自動釋放控制碼。 例如,下麵就是事件控制碼的一個特徵類:

struct handle_traits
{
  static HANDLE invalid() throw()
  {
    return nullptr;
  }

  static void close(HANDLE value) throw()
  {
    CloseHandle(value);
  }
};

因為 Windows API 中的很多庫共用這些語義,所以它們不單用於事件物件。 如您所見,特徵類只包含靜態成員函數。 如此一來,編譯器即可輕鬆嵌入代碼而不引入任何開銷,同時為元程式設計提供極大的靈活性。

無效函數返回無效控制碼的值。 這個值通常為 nullptr,是 C++ 2011 中的一個新關鍵字,表示 null 指標值。 不同于傳統的同類值,nullptr 是強類型的,因此適用于範本和函數重載。 無效控制碼有時也定義為非 nullptr 的值,這就會導致特徵類中包含無效函數。 close 函數封裝關閉或釋放控制碼的機制。

給出了特徵類的輪廓,我可以繼續並開始定義控制碼類範本,如圖 1所示。

圖 1 控制碼類範本

template <typename Type, typename Traits>
class unique_handle
{
  unique_handle(unique_handle const &);
  unique_handle & operator=(unique_handle const &);

  void close() throw()
  {
    if (*this)
    {
      Traits::close(m_value);
    }
  }

  Type m_value;

public:

  explicit unique_handle(Type value = Traits::invalid()) throw() :
    m_value(value)
  {
  }

  ~unique_handle() throw()
  {
    close();
  }

我將它命名為 unique_handle,因為它與標準的 unique_ptr 類範本有些神似。 很多庫還使用相同的控制碼類型和語義,因此有必要為最常用的情況提供一個 typedef,簡單地叫它 handle 就好了:

typedef unique_handle<HANDLE, handle_traits> handle;

現在,我可以創建一個事件物件並將其聲明為“handle”,如下所示:

handle h(CreateEvent( ...
));

我已將 copy 構造函數和 copy 設定運算子聲明為私有,並且保持它們未實現。 這會阻止編譯器自動生成它們,因為它們很少適合控制碼。 Windows API 允許複製特定類型的控制碼,但這是與 C++ copy 語義非常不同的概念。

構造函數的值參數依靠特徵類提供預設值。 析構函式呼叫私有的 close 成員函數,該函數又依靠特徵類根據需要關閉控制碼。 這樣,我就得到了一個堆疊友好且異常安全的控制碼。

不過這些還不夠。 close 成員函數依靠 Boolean 轉換來確定是否需要關閉控制碼。 雖然 C++ 2011 引入了顯式轉換函數,但在 Visual C++ 還沒有這樣的函數,因此我使用一種通用的 Boolean 轉換方法來避免編譯器在正常情況下允許的令人擔心的隱式轉換:

private:

  struct boolean_struct { int member; };
  typedef int boolean_struct::* boolean_type;

  bool operator==(unique_handle const &);
  bool operator!=(unique_handle const &);

public:

  operator boolean_type() const throw()
  {
    return Traits::invalid() != m_value ?
&boolean_struct::member : nullptr;
  }

這意味著我現在可以簡單地測試控制碼是否有效,而不會允許危險的轉換暗地進行:

unique_handle<SOCKET, socket_traits> socket;
unique_handle<HANDLE, handle_traits> event;

if (socket && event) {} // Are both valid?
if (!event) {} // Is event invalid?
int i = socket; // Compiler error!
if (socket == event) {} // Compiler error!

使用更明顯的布林運算子會允許最後兩個錯誤暗地發生。 雖然如此,這的確會允許兩個通訊端之間的比較,因此需要顯式實現相等運算子或將它們聲明為私有並保持未實現。

unique_handle 擁有控制碼的方式與標準 unique_ptr 類範本擁有物件並通過指標管理物件的方式相似。 因此,可以通過提供我們所熟悉的 get、reset 和 release 成員函數來管理基礎控制碼。 get 函數非常簡單:

Type get() const throw()
{
  return m_value;
}

reset 函數稍微複雜,但我們已經討論過它的構建基礎:

bool reset(Type value = Traits::invalid()) throw()
{
  if (m_value != value)
  {
    close();
    m_value = value;
  }

  return *this;
}

我冒昧對 unique_ptr 提供的 reset 函數模式做了輕微的改動,使其返回一個布林值,指示物件是否已用有效控制碼進行了重置。 這樣方便進行錯誤處理,稍候我們再討論這個問題。 release 函數現在顯而易見:

Type release() throw()
{
  auto value = m_value;
  m_value = Traits::invalid();
  return value;
}

複製與 移動

最後是考慮複製與移動語義。 因為我已經禁止了控制碼的複製語義,所以應該允許移動語義。 如果要在 STL 容器中存儲控制碼,這一點非常重要。 這些容器在傳統上依賴複製語義,但隨著 C++ 2011 的引入,開始支援移動語義。

這裡我不詳細介紹移動語義和 rvalue 引用,只是告訴大家,其基本理念是允許物件值以開發人員可預測並且對於庫作者和編譯器一致的方式在物件間傳遞。

在 C++ 2011 之前,開發人員不得不求助於各種複雜的技巧來避免語言(廣義上講為 STL)對複製物件的過度喜愛。 編譯器常常創建一個物件副本,然後立即銷毀原始物件。 使用移動語義,開發人員可以聲明一個物件將不再使用,其值移至別處,通常伴隨有盡可能少的指標交換。

在某些情況下,開發人員需要明確指出這一點;但大多數情況下,編譯器可以利用移動感知物件並執行前所未有的超高效優化。 好消息是對您自己的類啟用移動語義非常簡單。 就像複製依賴于複製構造函數和複製設定運算子一樣,移動語義依賴于移動構造函數和移動設定運算子:

unique_handle(unique_handle && other) throw() :
  m_value(other.release())
{
}

unique_handle & operator=(unique_handle && other) throw()
{
  reset(other.release());
  return *this;
}

The rvalue Reference

C++ 2011 引入了一種新型引用,名為 rvalue 引用。 這種引用通過 && 聲明;前面代碼中在 unique_handle 成員中使用過。 雖然與現在名為 lvalue 引用的舊引用相似,但新的 rvalue 引用在初始化和重載解析方面展現出些許不同的規則。 這一話題先到此為止(稍後會有進一步討論)。 控制碼至此有了移動語義,其主要優點是可以在 STL 容器中正確有效地存儲控制碼。

錯誤處理

對 unique_handle 類範本的討論到此為止。 本月的最後一個主題是錯誤處理,這也是為後續專欄做的準備。 對異常和錯誤代碼利弊的爭論看似無休無止;但是,只要您想使用標準 C++ 庫,就必須習慣異常。 當然,Windows API 使用錯誤代碼,因此需要有所妥協。

我的錯誤處理方法是盡可能地少做錯誤處理,編寫異常安全的代碼但避免捕獲異常。 如果沒有例外處理常式,Windows 會自動生成一個錯誤報告,其中包含可以事後調試的小型崩潰轉儲。 僅在發生意外的執行階段錯誤時引發異常,通過錯誤代碼來處理所有其他情況。 引發異常的原因不外乎代碼中的 bug 和電腦上降臨的災難。

我喜歡以訪問 Windows 註冊表為例。 如果無法寫入註冊表值,通常揭示程式中存在難以合理處理的較大問題。 這種情況應導致異常。 不過,無法讀取註冊表值應是可以遇見的情況,並妥善加以處理。 這種情況不應導致異常,而應返回一個布林值或枚舉值,以指示是否無法讀取值以及發生此情況的原因。

Windows API 與其錯誤處理方式不甚一致;這是 API 多年演變的結果。 錯誤大都作為 BOOL 或 HRESULT 值返回。 對於某些其他錯誤,我習慣通過比較返回值與文檔記錄值來明確處理。

如果我的程式依賴某給定函式呼叫的成功才能繼續可靠工作,那麼我使用圖 2中所列的一個函數檢查返回值。

圖 2 檢查返回值

inline void check_bool(BOOL result)
{
  if (!result)
  {
    throw check_failed(GetLastError());
  }
}

inline void check_bool(bool result)
{
  if (!result)
  {
    throw check_failed(GetLastError());
  }
}

inline void check_hr(HRESULT result)
{
  if (S_OK != result)
  {
    throw check_failed(result);
  }
}

template <typename T>
void check(T expected, T actual)
{
  if (expected != actual)
  {
    throw check_failed(0);
  }
}

關於這些函數,有兩點需要指出。 第一點是 check_bool 函數經過重載,以便同時檢查控制碼物件的有效性,它理應不允許隱式轉換為 BOOL。 第二點是 check_hr 函數,它顯式與 S_OK 進行比較,而不是使用更為常見的 SUCCEEDED 宏。 這可避免靜默接受開發人員向來不願看到的其他可疑成功代碼(如 S_FALSE)。

我初次嘗試編寫這些檢查函數時使用了一組重載。 但當我在各種不同專案中使用這些重載時,我意識到 Windows API 定義了太多的結果類型和宏,要創建一組適用于所有結果類型和宏的重載根本不可能。 這就用到了裝飾函數。 有幾次,我發現由於發生意外的重載解析而未能捕獲錯誤。 引發 check_failed 類型非常簡單:

struct check_failed
{
  explicit check_failed(long result) :
    error(result)
  {
  }

  long error;
};

我可以給它裝飾各種花哨的功能,如添加錯誤消息支援,但有什麼用呢? 我提供了錯誤值,以便對崩潰的應用程式進行檢查時可以輕鬆找出錯誤。 除此之外,別的功能只是徒增障礙而已。

有了這些檢查函數,我可以創建一個事件物件並為其設置信號,在出現錯誤時引發異常:

handle h(CreateEvent( ...
));

check_bool(h);

check_bool(SetEvent(h.get()));

異常處理

異常處理的另一個問題與效率有關。 開發人員對此又一次出現分歧,往往是因為他們有一些先入為主、不切實際的觀念。

異常處理的成本體現在兩個方面。 一方面是引發異常的成本。 引發異常往往比使用錯誤代碼慢,這也正是只應在出現致命錯誤時才應引發異常的原因之一。 如果一切順利,您根本不需要付出這樣的代價。

性能問題的另一方面,也是更常見的一方面原因是:萬一引發異常,那麼為了確保調用正確的析構函數,會產生運行時開銷。 需要通過代碼來跟蹤需要執行哪些析構函數;這當然也會增加堆疊大小,這在大型代碼庫中對性能會有顯著影響。 請注意,無論是否實際引發異常,都需要付出此代價,因此儘量減少這種情況對確保良好的性能十分重要。

這意味著要確保編譯器對於什麼函數可能引發異常擁有良好的判斷。 如果編譯器可以證明某些函數不會引發任何異常,它可以優化生成的代碼來定義和管理堆疊。 這就是我用異常規範裝飾整個控制碼類範本和特徵類成員函數的原因。 雖然在 C++ 2011 中已不再使用,但它是一項非常重要的平臺特定優化功能。

本月內容到此結束。 您現在掌握了使用 Windows API 編寫可靠程式的關鍵要素之一。 下個月,請和我一起探索 Windows 執行緒池 API。

Kenny Kerr 是一位熱衷於本機 Windows 開發的軟體專家。您可以通過kennykerr.ca 與他聯繫。