本文章是由機器翻譯。

Windows 與 C++

添加編譯時類型檢查 Printf

Kenny Kerr

Kenny Kerr我探索的一些技巧製作更方便地與現代 c + + 在我 2015 年 3 月的專欄文章中使用的 printf (msdn.microsoft.com/magazine/dn913181)。我展示了如何變換參數使用可變參數範本彌合官方的 c + + 字串類和陳舊的 printf 函數之間的差距。何必呢?嗯,printf 是速度非常快,而且解決方案格式化輸出,可以充分利用同時允許開發人員編寫更安全,更高級別的代碼是當然可取。其結果是可以像這樣一個簡單的程式列印可變參數函數範本:

int main()
{
  std::string const hello = "Hello";
  std::wstring const world = L"World";
  Print("%d %s %ls\n", 123, hello, world);
}

並有效地展開內部 printf 函數,如下所示:

printf("%d %s %ls\n", Argument(123), Argument(hello), Argument(world));

參數函數範本會然後也走了編譯並留下必要的訪問器函數:

printf("%d %s %ls\n", 123, hello.c_str(), world.c_str());

雖然這是方便現代 c + + 和傳統 printf 函數之間的橋樑,它並不能解決編寫正確的代碼使用 printf 的困難。 printf 仍然是 printf,我能依靠不完全無所不知的編譯器和庫來嗅出任何轉換說明符和由調用方提供的實際參數之間的不一致。

當然現代 c + + 可以做得更好。許多開發人員試圖做得更好。問題是不同的開發人員有不同的要求或優先事項。一些也很樂意承擔小的性能損失,單純依靠 < iostream > 類型檢查和可擴充性。其他人發明了精心製作的庫,提供有趣的特性,需要額外的結構和分配要跟蹤格式設置狀態。就個人而言,我不滿意介紹性能和運行時開銷什麼應該是一個基本的和快速的操作的解決方案。如果它很小,快速在 C 中,它應該是小型,快速和便於使用正確的 c + +。"慢"不應進入那個等式。

所以可以做什麼?好吧,有點抽象是順序,只要可以帶走編譯抽象留下點東西非常接近手寫的 printf 語句。關鍵是要認識到如 %d 和 %s 轉換說明符是真的只是預留位置的參數或遵循的價值觀。麻煩的是這些預留位置使相應參數的類型的假設沒有任何辦法知道這些類型是否正確。而不是試圖添加運行時類型檢查,將確認這些假設,讓我們扔掉這個偽類型資訊和相反讓編譯器推斷出參數的類型,作為他們自然完全解決。所以與其寫作:

printf("%s %d\n", "Hello", 2015);

我反而應該限制格式字串對輸出和擴大的任何預留位置的實際字元。我甚至可能會使用相同的元字元作為預留位置:

Write("% %\n", "Hello", 2015);

真是比前面的 printf 示例中此版本中沒有較少類型資訊。唯一的原因 printf 需要嵌入額外的類型資訊是因為 C 程式設計語言缺乏支援可變數量範本。後者也是乾淨得多。要不是繼續尋找各種 printf 格式說明符,以確保沒有是只是記錯了,我一定會幸福。

也不想限制為只顯示在主控台的輸出。什麼格式轉換為字串嗎?其他的一些目標呢?使用 printf 的挑戰之一是雖然它支援輸出到不同的目標,它通過不同的職能,不是重載和很難籠統地使用。為了公平起見,C 程式設計語言不支援重載或泛型程式設計。同樣,讓我們不想重複歷史。要能夠列印到主控台一樣輕鬆地和一般可以列印到字串或檔?心裡有什麼所示圖 1

圖 1 通用輸出與類型推導

Write(printf, "Hello %\n", 2015);
FILE * file = // Open file
Write(file, "Hello %\n", 2015);
std::string text;
Write(text, "Hello %\n", 2015);

從設計或實現的角度來看,是應該有一種寫函數範本,作為一名司機和任意數量的目標或目標配接器綁定一般基於由調用方確定目標。然後,開發人員應該能夠根據需要輕鬆地添加額外的目標。那麼如何將這項工作嗎?一種選擇是,將目標範本參數。這樣的事情:

template <typename Target, typename ... Args>
void Write(Target & target,
  char const * const format, Args const & ... args)
{
  target(format, args ...);
}
int main()
{
  Write(printf, "Hello %d\n", 2015);
}

這工程,一個點。我可以寫信給預期的 printf 的簽名符合其他目標,它應該工作得不夠好。我可以寫出符合並將輸出追加到字串的函數物件:

struct StringAdapter
{
  std::string & Target;
  template <typename ... Args>
  void operator()(char const * const format, Args const & ... args)
  {
    // Append text
  }
};

然後我可以使用它與同一寫函數範本:

std::string target;
Write(StringAdapter{ target }, "Hello %d", 2015);
assert(target == "Hello 2015");

起初,這似乎相當典雅和更靈活。各種配接器函數或函數物件可以寫但在實踐中它很快變得很沉悶乏味。它將更加可取,簡單的字串,直接作為目標傳遞給照顧適應它基於其類型寫函數範本。所以,讓我們來寫函數範本匹配與追加或格式化輸出的重載函數對請求的目標。間接定址編譯時有點走很長的路。實現多可能需要要寫入的輸出將只是無任何預留位置文字,我將添加不一,但對重載。第一次只是將附加文本。這裡是一個附加函數用於字串:

void Append(std::string & target,
  char const * const value, size_t const size)
{
  target.append(value, size);
}

然後,我可以為 printf 提供追加的重載:

template <typename P>
void Append(P target, char const * const value, size_t const size)
{
  target("%.*s", size, value);
}

我本來可以避免的範本通過使用匹配的 printf 簽名的函數指標,但這是有點更靈活,因為其他功能可以想像將綁定到此相同的實現,編譯器並不以任何方式阻礙了指標間接定址。我還可以為檔或流的輸出提供重載:

void Append(FILE * target,
  char const * const value, size_t const size)
{
  fprintf(target, "%.*s", size, value);
}

當然,格式化的輸出仍然至關重要。這裡是一個 AppendFormat 函數用於字串:

template <typename ... Args>
void AppendFormat(std::string & target,
  char const * const format, Args ... args)
{
  int const back = target.size();
  int const size = snprintf(nullptr, 0, format, args ...);
  target.resize(back + size);
  snprintf(&target[back], size + 1, format, args ...);
}

它首先確定調整目標和設置文本格式直接插入字串之前需要多少額外的空間。就忍不住要試著避免調用 snprintf 兩次通過檢查緩衝區中是否有足夠的空間。我傾向于總是調用 snprintf 兩次的原因是因為非正式測試表明兩次調用它是通常便宜比調整能力。即使分配並不是必需的這些額外的字元把注意力時,這往往要更貴。然而,這是非常主觀的依賴于資料模式和目標字串如何頻繁地重複使用。在這裡是一個用於 printf:

template <typename P, typename ... Args>
void AppendFormat(P target, char const * const format, Args ... args)
{
  target(format, args ...);
}

檔輸出的重載是只是很簡單的:

template <typename ... Args>
void AppendFormat(FILE * target,
  char const * const format, Args ... args)
{
  fprintf(target, format, args ...);
}

我現在有一個構建基塊中寫入驅動程式函數的地方。其他必要的構建基塊是處理參數格式的泛型方法。雖然我我 2015 年 3 月的專欄仲介紹的方法是簡單和優雅,它缺乏能夠處理不直接映射到 printf 受支援的類型的任何值。它還不能處理參數擴展或複雜的參數值 (如使用者定義的類型。再一次,一組重載函數可以很優雅地解決問題。讓我們假設寫驅動程式函數會將每個參數傳遞給 WriteArgument 函數。下面是一個用於字串:

template <typename Target>
void WriteArgument(Target & target, std::string const & value)
{
  Append(target, value.c_str(), value.size());
}

各種的 WriteArgument 函數將始終接受兩個參數。第一次表示通用目標,而第二個是要寫的特定參數。在這裡,我能依靠的附加函數匹配目標,來照顧將值追加到目標年底的存在。WriteArgument 函數不需要知道這個目標實際上是什麼。我可以想像可避免目標配接器的功能,但這將導致在 WriteArgument 重載) 中的二次增長。這裡是另一個 WriteArgument 函數為整數參數:

template <typename Target>
void WriteArgument(Target & target, int const value)
{
  AppendFormat(target, "%d", value);
}

在這種情況下,WriteArgument 函數需要一個 AppendFormat 函數,以匹配目標。隨著的追加和 AppendFormat 的重載,寫額外的 WriteArgument 函數是直截了當。此方法的優點是論點配接器不需要返回一些值沿堆疊向上到 printf 函數,在 2015 年 3 月版本中那樣。相反,WriteArgument 重載實際上範圍輸出,這樣的目標立即寫入。這意味著可以使用複雜類型作為參數,並臨時存儲可甚至依靠若要設置格式的文本表示形式。這裡是 Guid 的 WriteArgument 重載:

template <typename Target>
void WriteArgument(Target & target, GUID const & value)
{
  wchar_t buffer[39];
  StringFromGUID2(value, buffer, _countof(buffer));
  AppendFormat(target, "%.*ls", 36, buffer + 1);
}

我甚至可以取代 Windows StringFromGUID2 功能和格式它直接,也許是為了提高性能或添加可攜性,但這清楚地表明這種方法的靈活性與功率。加上一個 WriteArgument 重載,可以輕鬆地支援使用者定義的類型。在這裡,我已經叫他們重載,但嚴格說來他們不需要。輸出庫當然可以提供一套為共同的目標和參數的重載,但寫驅動程式函數不應該假設配接器函數重載,相反,應該對待他們像非党開始和結束函式定義的標準 c + + 庫所。非党開始和結束功能可擴展,能適應各種標準和非標準容器正是因為他們不需要駐留在 std 命名空間中,而是應該門當戶對的類型的命名空間的本地。在同樣的方式,這些目標和參數的配接器函數應該能夠駐留在其他命名空間,以支援開發人員的目標和使用者定義的參數。那麼是什麼寫的驅動程式功能樣子?對於初學者來說,那裡是唯一寫函數:

template <typename Target, unsigned Count, typename ... Args>
void Write(Target & target,
  char const (&format)[Count], Args const & ... args)
{
  assert(Internal::CountPlaceholders(format) == sizeof ... (args));
  Internal::Write(target, format, Count - 1, args ...);
}

它需要做的第一件事是確定是否在格式字串中的預留位置的個數等於可變參數包中的參數數目。在這裡,我使用的運行時斷言,但這真的應該在編譯時檢查格式字串的頁本頁。不幸的是,Visual c + + 尚不完全。不過,我可以編寫代碼,以便當編譯器急起直追,可以方便地更新代碼,以在編譯時檢查格式字串。因此,內部的 CountPlaceholders 功能,應為 constexpr:

constexpr unsigned CountPlaceholders(char const * const format)
{
  return (*format == '%') +
    (*format == '\0' ? 0 : CountPlaceholders(format + 1));
}

當 Visual c + + 實現完全符合 C + + 14 時,至少在 constexpr,你應該能夠簡單地替換頁本頁寫函數內部斷言。然後它是內部的重載寫函數,在編譯時推出參數特定于輸出。在這裡,我可以依賴編譯器生成並調用內部的 Write 函數,以滿足擴大的可變參數包的必要重載:

template <typename Target, typename First, typename ... Rest>
void Write(Target & target, char const * const value,
  size_t const size, First const & first, Rest const & ... rest)
{
  // Magic goes here
}

不一會兒,我將重點介紹的魔力。最終,編譯器會用完參數和一個非可變參數的重載將需要完成的操作:

template <typename Target>
void Write(Target & target, char const * const value, size_t const size)
{
  Append(target, value, size);
}

這兩個內部的寫函數接受的值,以及值的大小。可變參數寫函數範本必須進一步假設值中有至少一個預留位置。非可變 Write 函數需要作出任何假設和簡單地可以使用泛型的附加函數編寫任何尾隨部分的格式字串。可變 Write 函數可以編寫它的參數之前,它必須先寫任何前導字元,當然,找到的第一個預留位置或元字元:

size_t placeholder = 0;
while (value[placeholder] != '%')
{
  ++placeholder;
}

它才可以編寫前導字元:

assert(value[placeholder] == '%');
Append(target, value, placeholder);

然後可以寫的第一個參數和過程會重複,直到沒有進一步的論據,並離開了預留位置:

WriteArgument(target, first);
Write(target, value + placeholder + 1, size - placeholder - 1, rest ...);

現在,我可以支援中的泛型輸出圖 1。很簡單,我甚至可以轉換一個 GUID 的字串:

std::string text;
Write(text, "{%}", __uuidof(IUnknown));
assert(text == "{00000000-0000-0000-C000-000000000046}");

多一點有趣的東西呢?如何視覺化向量:

std::vector<int> const numbers{ 1, 2, 3, 4, 5, 6 };
std::string text;
Write(text, "{ % }", numbers);
assert(text == "{ 1, 2, 3, 4, 5, 6 }");

為此,我只需要寫一個 WriteArgument 功能範本,接受一個向量作為參數,如中所示圖 2

圖 2 視覺化向量

template <typename Target, typename Value>
void WriteArgument(Target & target, std::vector<Value> const & values)
{
  for (size_t i = 0; i != values.size(); ++i)
  {
    if (i != 0)
    {
      WriteArgument(target, ", ");
    }
    WriteArgument(target, values[i]);
  }
}

請注意如何我不強迫的向量中的元素的類型。這意味著我現在可以使用相同的實現來想像一個字串向量:

std::vector<std::string> const names{ "Jim", "Jane", "June" };
std::string text;
Write(text, "{ % }", names);
assert(text == "{ Jim, Jane, June }");

當然,這引出了一個問題:如果我想進一步擴大一個預留位置嗎?當然在此基礎上,可以給一個容器的 WriteArgument,它提供了沒有彈性的調整輸出。想像一下我需要定義一個應用程式的色彩配置的調色板和我有原色和中學的顏色:

std::vector<std::string> const primary = { "Red", "Green", "Blue" };
std::vector<std::string> const secondary = { "Cyan", "Yellow" };

Write 函數將高興地格式化這對我來說:

Write(printf,
  "<Colors>%%</Colors>",
  primary,
  secondary);

輸出中,然而,並非相當什麼:

<Colors>Red, Green, BlueCyan, Yellow</Colors>

這是明顯的錯誤。相反,我想標記的顏色,我知道這是初級,哪些是次要。也許這樣的事情:

<Colors>
  <Primary>Red</Primary>
  <Primary>Green</Primary>
  <Primary>Blue</Primary>
  <Secondary>Cyan</Secondary>
  <Secondary>Yellow</Secondary>
</Colors>

讓我們添加一個更多的 WriteArgument 函數,可以提供這種級別的可擴充性:

template <typename Target, typename Arg>
void WriteArgument(Target & target, Arg const & value)
{
  value(target);
}

注意該操作似乎在它頭上翻轉。而不是將值傳遞到目標,目標正在傳遞給的值。這種方式,我可以提供一個綁定的函數作為 argu­發展而不是只是一個值。我可以附加一些使用者定義的行為,而不僅僅是一個使用者定義的值。這裡是一個做自己想要的什麼的 WriteColors 函數:

void WriteColors(int (*target)(char const *, ...),
  std::vector<std::string> const & colors, std::string const & tag)
{
  for (std::string const & color : colors)
  {
    Write(target, "<%>%</%>", tag, color, tag);
  }
}

請注意這不是一個函數範本,我受夠了本質上是硬編碼為一個單一的目標。這是特定于目標的自訂項,但顯示什麼是可能的甚至當你需要走出直接由寫驅動程式功能提供泛型型別推導。但如何可以合併到更大的寫操作­名詞嗎?嗯,你可能是試探一下,寫這篇文章:

Write(printf,
  "<Colors>\n%%</Colors>\n",
  WriteColors(printf, primary, "Primary"),
  WriteColors(printf, secondary, "Secondary"));

撇開不談了一會兒這將無法編譯的事實,它真的不會給你正確的一連串事件,不管怎麼說。如果這是工作,會在開幕 < 顏色 > 之前列印顏色 標記。很明顯,他們應否稱為好像他們是論據在它們出現的順序。而這正是新的 WriteArgument 函數範本的允許。我只被需要綁定 WriteColors 調用,這樣他們可以在稍後階段調用。若要使用寫驅動程式功能的人,甚至更簡單,我可以提供一個方便的綁定包裝:

template <typename F, typename ... Args>
auto Bind(F call, Args && ... args)
{
  return std::bind(call, std::placeholders::_1,
    std::forward<Args>(args) ...);
}

此綁定函數範本只是要確保一個預留位置是預留將寫入到的最終目標。我然後可以正確地設置格式我的調色板,如下所示:

Write(printf,
  "<Colors>%%</Colors>\n",
  Bind(WriteColors, std::ref(primary), "Primary"),
  Bind(WriteColors, std::ref(secondary), "Secondary"));

然後標明預期的輸出。Ref helper 函數不是絕對必要的但是避免使調用包裝容器的副本。

不相信嗎?可能性是無窮無盡的。您可以處理有效地為寬和正常的字元的字元字串參數:

template <typename Target, unsigned Count>
void WriteArgument(Target & target, char const (&value)[Count])
{
  Append(target, value, Count - 1);
}
template <typename Target, unsigned Count>
void WriteArgument(Target & target, wchar_t const (&value)[Count])
{
  AppendFormat(target, "%.*ls", Count - 1, value);
}

以這種方式我可以輕鬆地和安全地編寫使用不同的字元輸出設置:

Write(printf, "% %", "Hello", L"World");

如果你不明確或者最初需要寫輸出,但相反只需要計算將需要多少空間嗎?沒問題,我可以簡單地創建一個新的目標,總結了:

void Append(size_t & target, char const *, size_t const size)
{
  target += size;
}
template <typename ... Args>
void AppendFormat(size_t & target,
  char const * const format, Args ... args)
{
  target += snprintf(nullptr, 0, format, args ...);
}

我現在可以很簡單,計算所需的大小:

size_t count = 0;
Write(count, "Hello %", 2015);
assert(count == strlen("Hello 2015"));

這是安全地說,這種解決方案最後網址類別型脆弱中保留大部分的性能優勢,而直接使用 printf 固有的。現代 c + + 是超過能夠滿足開發人員尋找一個高效的環境與可靠的類型檢查同時保持的性能,其中 C 和 c + + 傳統上的眾所周知的。


Kenny Kerr 是一個位於加拿大,以及作者 Pluralsight 和微軟最有價值球員的電腦程式員。他的博客 kennykerr.ca ,你可以跟著他在 Twitter 上 twitter.com/kennykerr

感謝以下 Microsoft 技術專家,檢討這篇文章:JamesMcNellis