字元陣列與字串


字串就是一串文字,在 C++ 談到字串的話,一個意義是指字元組成的陣列,最後加上一個空(null)字元 '\0',例如底下是個 "hello" 字串:

char text[] = {'h', 'e', 'l', 'l', 'o', '\0'};

之後可以直接使用 text 來代表 "hello" 文字,例如:

cout << text << endl; // 顯示 hello

也可以使用 "" 來包含文字,例如:

char text[] = "hello";

"hello" 是字串字面常量,在這個例子中,雖然沒有指定空字元 '\0',但是會自動加上空字元,來看看底下這個程式就可以得知:

#include <iostream> 
using namespace std; 

int main() { 
    char text[] = "hello"; 

    for(auto ch : text) { 
            if(ch == '\0') {
            cout << "null"; 
        } 
        else { 
            cout << ch; 
        }
    } 
    cout << endl; 

    return 0; 
}

text 基本上還是字元陣列,空字元用來識別字元陣列單純就只是字元陣列,或者是表示字串。

執行結果:

hellonull

這種字串,其實是延續自 C 風格的字串,因此更多細節可參考我寫的 C 語言文件中〈字串(字元陣列)〉,C 標準函式庫中有一些函式可以協助此類字串的處理,像是〈字串長度、複製、串接〉、〈字串比較、搜尋〉與〈字串轉換、字元測試〉,只不過在 C++ 中,標頭檔必須引用 cstring 而不是 string.h

在 C++ 中並不鼓勵使用 C 風格的字串,不過在這邊倒是想藉此探討一些字元細節。例如,有辨法指定 '林'char 變數嗎?這會引發編譯警訊:

// warning: multi-character character constant
// warning: overflow in conversion from 'int' to 'char' changes value
char t = '林'; 

文字「林」不會是一個位元組就可以儲存的資料,因此引發警訊,你需要使用以下的方式:

char text[] = "林"; 

若使用 strlen(text) 的話,會得到什麼數字呢?若單純使用 g++ 編譯,不加上任何引數的話,答案是看你的原始碼編碼是什麼,如果使用 Big5 撰寫原始碼的話,答案會是 2,如果使用 UTF-8 撰寫原始碼的話,答案會是 3。

記得在〈資料型態〉中談過嗎?char 用來儲存字元資料,但沒有規定什麼是字元資料,若單純使用 g++ 編譯,不加上任何引數的話,原始碼中字串怎麼儲存,執行時期 text 就怎麼儲存,當原始碼是 Big5 時,因為 "林" 會用上兩個位元組,strlen(text) 會是 2,當原始碼是 UTF-8 時,"林" 會用上三個位元組,因此 strlen(text) 會是是 3。

現代程式設計鼓勵使用 UTF-8,如果使用 UTF-8 撰寫原始碼,單純使用 g++ 編譯,不加上任何引數的話,若是在 Windows 的文字模式執行程式,就會出現亂碼,因為 Windows 的文字模式預設採用 Big5(MS950),為了可以看到正確的文字,編譯時可以加上 -fexec-charset=BIG5,執行時期字串使用 Big5 編碼,這時 strlen(text) 又會是 2 了。

這就要問到一個問題了,字元是什麼呢?C++ 中的 char 又是什麼呢?C++ 是個歷史悠久的語言,早期用 char 儲存的文字僅需單一位元組,例如 ASCII 的文字,使用 char 代表字元是沒問題,因為 ASCII 既定義了字元集,也定義了字元編碼,在表示 ASCII 的文字時,char 確實就代表字元,然而後來為了支援更多的文字,char 就不再是代表字元了。

char 是用來儲存字元資料,至於存什麼沒有規定,對於 char text[] = "林" 的情況,應該將 text 中每個索引位置當成是碼元(code unit),而不是字元了,因為必須以多個位元組來儲存「林」,因此這類字元在 C++ 被稱為多位元組字元(multibyte character),技術上來說,是用數個 char 組成的一個字元,如何組成就要看採用哪種編碼了。

如果採用 Big5 編碼,那 "林" 是個 Big5 字元,如果採用 UTF-8 編碼,那 "林" 是個 Unicode 字元,現代程式設計鼓勵用 UTF-8,若要固定使用 UTF-8 編碼字串,C++ 17 可以 UTF-8 撰寫原始碼,並在 "" 前置 u8,指定字串使用 UTF-8 編碼:

char text[] = u8"林";
cout << strlen(text) << endl; // 顯示 3

若不使用 UTF-8 編碼的原始碼,可以使用碼點指定:

char text[] = u8"\u6797";
cout << strlen(text) << endl; // 顯示 3

如果處理中文字串時,想知道有幾個中文字怎麼辦?這要知道 wchar_t 型態,對應的字元常量是 L'林' 這樣的寫法稱為擴充字元字面常量(wide character literal),wchar_t 其實是個整數型態,用來儲存碼點,就現今來說,基本上是指 Unicode。

例如,若以 UTF-8 撰寫原始碼,底下的程式會顯示 Unicode 碼點號碼:

wchar_t ch = L'林'; // 也可以寫 L'\u6797'
cout << ch << endl; // 顯示碼點十進位 26519 或十六進位 6797(視平台而定)

對於字串,也可以使用 wchar_t 宣告陣列進行處理。例如:

#include <iostream> 
#include <cstring> 

using namespace std; 

int main() { 
    wchar_t text[] = L"良葛格"; 
    cout << wcslen(text);  // 顯示 3

    return 0; 
}

L"良葛格" 這種寫法,稱為擴充字元字串(wide-chararater string),C 風格的字串處理函式,都有對應 wchar_t 的版本,只要將函式名稱的 str 前置改為 wcs 前置就可以了,wcs 就是 wide-chararater string 的縮寫。

wchar_t 並沒有規定大小,只要求必須容納系統中可以使用的字元,C++ 11 制定了 char16_tchar32_t,這會讓人誤以為它們用來儲存編碼,其實它們依舊是儲存碼點。

char16_t 可儲存的碼點,必須能涵蓋 UTF-16 編碼可表現的全部字元,使用的字元常量或字串常量前要加上 u,例如:

char16_t ch = u'林'; 
char16_t text[] = u"良葛格";

char32_t 可儲存的碼點,必須能涵蓋 UTF-32 編碼可表現的全部字元,使用的字元常量或字串常量前要加上 U,例如:

char32_t ch = U'林'; 
char32_t text[] = U"良葛格";

C++ 20 制定了 char8_t,必須夠大到能容納 UTF-8 編碼可表現的全部字元,使用的字元常量或字串常量前要加上 u8

至於 char 之間與 wchar_tchar16_tchar32_t 間要怎麼轉換呢?這問題基本上涉及 Unicode 碼點要轉換至哪個編碼,若是 Unicode 碼點與 UTF-8 的轉換,可以參考底下的實作(修改自 C++ UTF-8 codepoint conversion):

#include <iostream>

using namespace std;
string toUTF8(int cp);
int toCodePoint(const string &u);

int main(int argc, char *argv[]) {
    // 在 UTF-8 終端機下會顯示「林」
    cout << toUTF8(L'林') << endl;  
    cout << toUTF8(u'林') << endl;
    cout << toUTF8(U'林') << endl;

    string utf8 = u8"林";
    // 顯示 26519
    cout << toCodePoint(utf8) << endl;

    return 0;
}

string toUTF8(int cp) {
    char ch[5] = {0x00};
    if(cp <= 0x7F) { 
        ch[0] = cp; 
    }
    else if(cp <= 0x7FF) { 
        ch[0] = (cp >> 6) + 192; 
        ch[1] = (cp & 63) + 128; 
    }
    else if(0xd800 <= cp && cp <= 0xdfff) {} // 無效區塊
    else if(cp <= 0xFFFF) { 
        ch[0] = (cp >> 12) + 224; 
        ch[1]= ((cp >> 6) & 63) + 128; 
        ch[2]= (cp & 63) + 128; 
    }
    else if(cp <= 0x10FFFF) { 
        ch[0] = (cp >> 18) + 240; 
        ch[1] = ((cp >> 12) & 63) + 128; 
        ch[2] = ((cp >> 6) & 63) + 128; 
        ch[3]= (cp & 63) + 128; 
    }
    return string(ch);
}

int toCodePoint(const string &u) {
    int l = u.length();
    if(l < 1) {
        return -1; 
    }

    unsigned char u0 = u[0]; 
    if(u0 >=0 && u0 <= 127) {
        return u0;
    }

    if(l < 2) {
        return -1;
    } 

    unsigned char u1 = u[1]; 
    if (u0 >= 192 && u0 <= 223) {
        return (u0 - 192) * 64 + (u1 - 128);
    }

    if(u[0] == 0xed && (u[1] & 0xa0) == 0xa0) {
        return -1; //code points, 0xd800 to 0xdfff
    }

    if(l < 3) {
        return -1; 
    }

    unsigned char u2 = u[2]; 
    if(u0 >= 224 && u0 <= 239) {
        return (u0 - 224) * 4096 + (u1 - 128) * 64 + (u2 - 128);
    }

    if (l < 4) {
        return -1;
    }

    unsigned char u3 = u[3]; 
    if(u0>=240 && u0<=247) {
        return (u0 - 240) * 262144 + (u1 - 128) * 4096 + (u2 - 128) * 64 + (u3 - 128);
    }

    return -1;
}

string 是 C++ 建議使用的字串型態,也有一些現有的程式庫,可以提供編碼轉換,這之後會介紹。

若字串中包含 \" 等字元,會需要轉義,例如:

char text[] = "c:\\workspace\\exercise";

C++ 11 後可以使用原始字串常量 R"(...)" 的寫法,在括號中的文字無需轉義,也可以直接撰寫 ",例如:

char text1[] = R"(c:\workspace\exercise)";
char text2[] = R"(This is a "test")";

也可以進行換行:

#include <iostream> 
using namespace std; 

int main() { 
    char text[] = R"(Your left brain has nothing right.
    Your right brain has nothing left.)";
    cout << text << endl;
    return 0; 
}

在原始字串中撰寫的內容都會保留,因此顯示結果會是:

Your left brain has nothing right.
    Your right brain has nothing left.

UL 等前置字,都可以與原始字串結合,例如 UR"(...)"LR"..." 等,不過要留意的是,若結合原始字串,\u1234 這類寫法,就會如實呈現,不會作為碼點表示法的轉義。