由於 C++ 本身的 stream 要拿來做文字的格式化,使用上很麻煩,所以 Heresy 這邊之前都是用 Boost C++ Libraries 的 boost format 來做。
本來是期望 boost format 可以整到 std 裡面變成標準,不過很遺憾的是,C++ 20 雖然加入了 format 函式庫,但是卻不是基於 Boost format、而是基於 {fmt} 這個函式庫(官網、GitHub)的版本。
考慮到個人是希望盡量使用標準函式庫,這邊就來稍微整理一下 C++20 的 format 吧~
首先,這部分的文件可以參考:https://en.cppreference.com/w/cpp/utility/format
特色
std::format 這個函式庫基本上也是用來取代 C 傳統的 printf() 的一個資料格式化函式;相較於 printf() 沒有去檢查型別的正確性的問題,std::format 則是一個 type-safe 的方案。
他採用的格式化語法是以 Python 的 Format Specification Mini-Language(文件)為基礎修改而來的(實際上不完全相同),所以如果本來有在寫 Python 的人,應該會比較習慣。
同時,針對自行定義的型別,std::format 也有提供良好的擴展性、允許使用者自行定義格式化的方式。
而且相較於 boost format 對於使用者自行定義的型別是透過 operator<<() 來支援、難以自行定義格式化的參數(透過 iostream 的 flag 應該可以玩到一定程度、參考),std::format 則是透過 std::formatter<> 讓開發者可以定義出允許使用者自行調整輸出格式的形式,在使用上的彈性應該是更大的。
在效能的部分,{fmt} 是號稱他除了執行速度極快外,編譯的速度也快、同時產生的檔案也很小;以它所提供數字來看(參考),{fmt} 的處理速度不但接近 boost format 的十倍,甚至也比 C 的 printf() 還快!
不過由於 C++20 的 std::format 和 {fmt} 還是有一定程度的不同,所以這邊的效能數據應該也會完全一樣就是了;尤其是 std::format 將不少 {fmt} 在編譯階段(compile time)進行的工作搬到執行階段(run time)來做了,所以速度應該是會稍慢一些的。
編譯器支援度
首先,std::format 在主流編譯器的支援度(參考)…個人覺得不算很理想。
儘管微軟的 Visual Studio 在 2019 16.10 之後就有支援了,但是在 /std:c++20
的情況下並不支援、需要切換到 /std:c++latest 下才能使用。
clang 的部分,是到今年三月推出的 14 才支援,但是似乎也支援的不完整。
至於 g++…不知道為什麼,至今(12)都還沒能支援。 orz
雖然說在不支援的編譯環境下,還是可以透過 {fmt} 來使用類似的功能,但是由於 {fmt} 和 std::format 還是不完全相容,再加上 header、namespace 等問題,老實說如果要考慮跨平台的話,個人會覺得現在似乎還不太適合使用。
基本使用
下面就是一個 std::format 最簡單的使用例子:
#include <iostream> #include <format> int main() { int iVal = 0; std::string sv = "Hello World"; std::string s = std::format("{} - {}", iVal, sv); // 0 - Hello World }
std::format 的 header 檔基本上只有一個 <format>,而最主要的函式,則是
format()(文件)。
他的第一個引數就是格式化字串,也就是要怎麼產生格式化過的字串,這邊就是「“{}
– {}”」。
在 std::format 中,「{}」就是代表一個要輸出的引數,像在上面的例子裡面,第一個「{}」就是對應後面的 iVal、第二個「{}」則是對應後面的 sv,他是會依序處理的。
除了 std::string 的版本外,他也支援 std::wstring,只要把輸入和輸出都換成 std::wstring 就可以了。
int iVal = 0; std::wstring wsv = L"Hello World"; std::wstring ws = std::format(L"{} - {}", iVal, wsv);
而如果想要重複使用後面的引數、或者不按照順序處理的話,也可以在「{}」中加入引數的編號來做控制。下面就是一個簡單的例子:
std::cout << std::format("{0}-{1}-{0}", iVal, sv); // 0-Hello World-0
另外,由於「{}」是特殊控制字元,所以如果是真的要輸出「{」和「}」的話,則是要透過「{{」和「}}」來處理。
格式化語法的基礎
std::format 的格式文字的格式,基本上就是用 {} 來包起來,以確保可以抓到頭尾;而他的格式基本上是:
{[arg_id][:(format_spec)]}
在 [arg_id] 的部分,就如同前面的例子,是透過從 0 開始的整數來對應後面的引數,如果不給的話,就是依序使用。
後面的 (format_spec) 則是用來描述這個引數要怎麼轉換成文字的設定,在使用的時候前面要加上「:」和 [arg_id] 做分隔;在使用的時候,就算沒有指定 [arg_id],也還是要在前面加上「:」。
而 (format_spec) 的定義,會根據資料的型別而不同;以標準函式庫提供的部分來說,就分成針對基礎型別的標準格式規格(Standard format specification、以 Python 的版本為基礎)、以及針對 std::chrono 中各項時間日期型別制定的特殊標準(參考)。
之後如果要針對自己的型別來做格式化的話,也可以定義自己的格式化標準。
基礎型別的標準格式規格
針對比較基本的型別部分,std::format 定義的格式是:
[[fill]align][sign]["#"]["0"][width]["." precision]["L"][type]
上面的各個項目雖然都不一定要有,但是使用時是要依照順序的,不可以任意更換順序;而他們基本上可以分成下面幾組來看:
- [[fill]align]:align 是用來做輸出對齊用的,需要搭配後面的 [width] 使用;而 fill 則是用來設定空白處要填什麼字元。
- [sign][“#”][“0”]:針對數值型的資料做額外的控制
- [width][“.” precision]:針對數值型的資料作輸出位數的控制
- [“L”]:區域化設定
- [type]:針對資料型別指定輸出的格式
這邊詳細的內容可以參考 C++ Reference,下面則是針對部分內容作說明:
型別
首先,先來看最後面的型別部分。
std::format 支援的基礎型別,包括了字串、字元、布林變數、指標、整數和浮點數;根據不同的型別,大多都可以設定要輸出的格式。
而設定的方法基本上就是一個字元,如果不給的話就會採用預設值。
- 字串(s)
字串最為單純沒有特別的格式可以設定,但是可以指定「s」來強調這邊是要用字串複製的方法來處理。
- 字元(c / b / B / d / o / x / X)
字元預設會是使用「c」、代表的是使用字元複製的模式來處理。
而除了直接複製字元外,他也還可以把字元當作數值(整數)來做輸出;這時候就可以去設定要使用的格式,包括了二進位(b / B)、八進位(o)、十進位(d)、十六進位(x / X)。
下面就是簡單的例子:
char c = 'a'; std::cout << std::format("{}", c) << "\n"; // a std::cout << std::format("{:c}", c) << "\n"; // a std::cout << std::format("{:b}", c) << "\n"; // 1100001 std::cout << std::format("{:B}", c) << "\n"; // 1100001 std::cout << std::format("{:o}", c) << "\n"; // 141 std::cout << std::format("{:d}", c) << "\n"; // 97 std::cout << std::format("{:x}", c) << "\n"; // 61 std::cout << std::format("{:X}", c) << "\n"; // 61
這邊可以看到,其實不同進位的顯示很難區分,而且大小寫(b / B、x / X)也看不出來差別是什麼?
這是因為這邊還需要搭配「#」這個「alternate form」的關係。針對整數的資料,加上「#」的話,他就會在不同的進位格式前加上對應的前綴(prefix),分別是「0b」(二進位)、「0」(八進位)、「0x」(十六進位);而大小寫則會會呈現在這個前綴上。
下面就是加上「#」的例子:
std::cout << std::format("{:#b}", c) << "\n"; // 0b1100001 std::cout << std::format("{:#B}", c) << "\n"; // 0B1100001 std::cout << std::format("{:#o}", c) << "\n"; // 0141 std::cout << std::format("{:#d}", c) << "\n"; // 97 std::cout << std::format("{:#x}", c) << "\n"; // 0x61 std::cout << std::format("{:#X}", c) << "\n"; // 0X61
- 整數(c / b / B / d / o / x / X)
整數的部分基本上和字元的設定是完全一致的,能用的格式也都相同。
唯一可能要注意的,就是整數過大超過字元的範圍的話,那用「c」把它當作字元輸出是會有問題而丟出例外的。
- 浮點數(a / A / e / E / f / F / g / G)
在把浮點數轉成文字顯示的部分,基本上有四種格是可以使用:
- a / A:十六進位
- e / E:指數(科學記號)
- f / F:固定(fixed)、使用一般的數值顯示方法,大小寫沒影響
- g / G:一般(general)、根據數值的大小自動選擇指數或固定
下面就是用 pi 和 epsilon(極小的浮點數)來做為示意的例子:
constexpr float pi = std::numbers::pi; constexpr float eps = std::numeric_limits<float>::epsilon(); std::cout << std::format("{:a}\t{:a}", pi, eps) << "\n"; std::cout << std::format("{:A}\t{:A}", pi, eps) << "\n"; std::cout << std::format("{:e}\t{:e}", pi, eps) << "\n"; std::cout << std::format("{:E}\t{:E}", pi, eps) << "\n"; std::cout << std::format("{:f}\t{:f}", pi, eps) << "\n"; std::cout << std::format("{:F}\t{:F}", pi, eps) << "\n"; std::cout << std::format("{:g}\t{:g}", pi, eps) << "\n"; std::cout << std::format("{:G}\t{:G}", pi, eps) << "\n";
這樣的範例的結果會是:
piepsilona1.921fb6p+1 1p-23 A1.921FB6P+1 1P-23 e3.141593e+00 1.192093e-07 E3.141593E+00 1.192093E-07 f / F3.141593 0.000000 g3.14159 1.192093e-07 G3.14159 1.192093E-07 這邊可以看到,針對 pi 來說,g 和 f 的顯示基本上是一樣的,只有小數點預設的位數不同;但是針對數值極小的 epsilon,g 則會採用指數形式來表示、避免顯示結果只剩下 0 而沒有細節,而如果碰到極大的數字,也會是同樣的狀況。
另外,「#」的「alternate form」針對浮點數的效果,應該是在沒有小數的時候,也會強制顯示小數點。
- 布林變數(s / c / b / B / d / o / x / X)
針對布林變數(bool)的格式化呈現,預設會是把它當作字串(s)來處理,輸出「true」或「false」;如果有針對區域設定做調整,則也可能會有其他結果。
而如果不想顯示文字的話,也可以把它單純作為 0 和 1 這樣的整數來看,此時他的設定方法就和整數、或是字元一樣了。
- 指標(p)
std::format 也可以用來輸出指標的位址,不過他似乎是只支援 void*、所以在使用的時候必須手動轉型才行。下面是個簡單的例子:
std::format("{:p}", static_cast<void*>(&fVal))
這邊另外一提,這邊把數值(整數、浮點數)轉換成文字的部分,基本上大多都是用 C++17 的 std::to_chars() 來實作的。
對齊和填滿
一般在將變數格式化成文字的時候,都會自動判斷所需的文字長度,所以其實沒有對齊的問題;而通常要用到對齊的功能,大多是會是在要將多個變數用固定的寬度輸出的時候才會需要。
比如說在輸出多個數字的時候,預設都會靠左對齊,在比較數字上,有的時候會比較不方便;例如下面這個例子:
std::cout << std::format("{}\n{}\n{}\n", 12345, 12, 345);
它的結果會是:
12345 12 345
而如果看起來都靠右對齊、讓數值比較好看的話,就可以透過這邊的 align 來做控制。
std::format 的 align 有三個選擇,是「<」、「^」、「>」,代表的分別是靠左、置中、靠右。
像上面的例子如果要都靠右的話,就可以改成:
std::cout << std::format("{:>6}\n{:>6}\n{:>6}\n", 12345, 12, 345);
其中,「>」代表的就是靠右,而「6」則是代表至少要用六個字元做輸出;這樣的話,結果就會變成:
12345 12 345
在數值的呈現上,這樣應該是更為合適的。
而如果想要在空白處用特定的字元來填滿的話,則可以在對齊字元前面加上要填滿的字元(不能是「{」或「}」);下面就是例子:
std::cout << std::format("{:6}", 42) << "\n"; std::cout << std::format("{:6}", 'x') << "\n"; std::cout << std::format("{:*<6}", "Hi") << "\n"; std::cout << std::format("{:->6}", 'x') << "\n"; std::cout << std::format("{:!^6}", 'x') << "\n";
它的結果會是:
42 x Hi**** -----x !!x!!!
另外,這邊要注意的是,指定的寬度是代表「至少要用多少字元的寬度」,如果要輸出的內容超過指定的寬度的話,那他會繼續輸出、而不是被截斷;所以如果要用來排版的話,也需要確認所有內容的長度是否都在指定的寬度內。
正負號
針對數值型的資料,這邊也可以透過「+」、「–」、「 」來控制數值前方的正副號顯示。
他預設會是「–」,代表僅有負數前面會顯示負號;而如果是「+」的話,則是會在正數前方顯示正號、是「 」則是會在正數前面留一個空白。
下面就是簡單的例子:
std::format("{0:},{0:+},{0:-},{0: }", 1); // "1,+1,1, 1" std::format("{0:},{0:+},{0:-},{0: }", -1); // "-1,-1,-1,-1"
位數與精度
針對數值型的資料,這邊也還可以透過設定格式化時要使用的寬度(字串長度),針對浮點數也可以控制小數點後要顯示的位數。
這邊的格式基本上就是 [0][width][.precision],[width] 代表的是整體的寬度,前面多加一個「0」的話,會在前面用 0 補滿(否則是空白);在寬度後面用小數點再加一個整數,就是代表小數部分要顯示的位數。
下面是個簡單的例子:
int iVal = 123; std::cout << std::format("{:10d}\n", iVal); std::cout << std::format("{:010d}\n", iVal); float pi = std::numbers::pi; std::cout << std::format("{:.5f}\n", pi); std::cout << std::format("{:10f}\n", pi); std::cout << std::format("{:10.3f}\n", pi); std::cout << std::format("{:010.5f}\n", pi);
這樣的結果會是:
123 0000000123 3.14159 3.141593 3.142 0003.14159
而這邊比較有趣的,是寬度和精度的部分,也是可以參數化的!透過把這兩個位置的數值改成 {},就可以讓他透過後面的引數取得數值。
下面兩個寫法,其結果會是相同的。
std::format("{:010.5f}\n", pi); std::format("{:0{}.{}f}\n", pi, 10, 5);
這邊的最後,可能要稍微注意的是,前面補 0 的部分不能和 align 和在一起使用;如果有指定 align 的話,那 0 就會失效。
區域化設定
這部分的最後,如果加上了「L」這個字元的話,那針對算術型別(arithmetic)的資料(整數、浮點數、布林變數),就會去套用區域化的設定。
針對整數和浮點數,他可能會加上千分號這類的數字分組字元(digit group separator ),小數點可能也會被換成別的字元。
而針對布林變數,則是有可能會用不同區域的文字,來取代「true」和「false」。
在使用時,可能會需要搭配 std::locale(參考)來使用;下面是一個例子:
std::format(std::locale("en_US.UTF-8"), "{:L}", 1234567890); // 1,234,567,890
針對 std::format 的第一篇整理大概就是這樣了。
之後來會再針對它其他的函式、還有要如何針對自定義的型別做格式化來做整理的。
本系列目錄:
- part 1:基本使用與格式設定
- part 2:其他的函式
- part 3:自定義型別處理
參考: