C++20 的標準資料格式化函式庫 part 1

| | 0 Comments| 08:59
Categories:

由於 C++ 本身的 stream 要拿來做文字的格式化,使用上很麻煩,所以 Heresy 這邊之前都是用 Boost C++ Librariesboost 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 / Bx / 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";
    

    這樣的範例的結果會是:

    pi
    epsilon
    a
    1.921fb6p+1 1p-23
    A
    1.921FB6P+1 1P-23
    e
    3.141593e+00 1.192093e-07
    E
    3.141593E+00 1.192093E-07
    f / F
    3.141593 0.000000
    g
    3.14159 1.192093e-07
    G
    3.14159 1.192093E-07

    這邊可以看到,針對 pi 來說,gf 的顯示基本上是一樣的,只有小數點預設的位數不同;但是針對數值極小的 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:自定義型別處理

參考:

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *