字元陣列與字串


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

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

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

printf("%s\n", text);

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

char text[] = "hello";

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

#include <stdio.h>
#include <string.h>

int main(void) {
    char text[] = "hello";
    int length = sizeof(text) / sizeof(text[0]);

    for(int i = 0; i < length; i++) {
        if(text[i] == '\0') {
            puts("null");
        } else {
            printf("%c ", text[i]);
        }
    }
    printf("陣列長度 %d\n", length);
    printf("字串長度 %d", strlen(text));

    return 0;
}

執行結果:

h e l l o null
陣列長度 6
字串長度 5

字串是字元陣列,可以用陣列存取方式取出每個字元,在指定 "hello" 時表面上雖然只有 5 個字元, 但是最後會加上一個空字元 '\0',因此 text 就陣列長度而言會是 6,不過就字串長度而言會是 5,strlen 可以取得字串長度,定義在 string.h。

由使用者輸入取得字串值時,需注意不要超過字串(字元陣列)的長度;使用 scanf 從使用者輸入取得字串值,並儲存至字元陣列,只要這麼作就可以了:

char buf[80];
printf("輸入字串:");
scanf("%s", buf);
printf("你輸入的字串為 %s\n", buf);

這個程式片段可以取得使用者的字串輸入,輸入的字串長度不得超過 80 個字元,80 個字元的上限包括空字元,因此實際上可以輸入 79 個字元;如果輸入的字元超出所宣告的上限,會發生不可預期的結果,甚至成為安全弱點,如〈printf 與 scanf〉中最後談到的,預防的方法之一是,限定 scanf 每次執行可以接受的最大字元數,或者是使用 fgets

在使用 scanf 取得使用者輸入的字串時,格式指定字是使用 %s,而變數前不用再加上 &,因為實際上,字串(字元陣列)變數名稱本身,即表示記憶體位址資訊。

要指定新的字串值給它時,不能像下面的方式指定:

char name[80]; 
name = "Justin";

而必須要一個字元一個字元的指定至陣列中,並在最後加上空白字元,例如:

char name[80] = {'\0'}; 
name[0] = 'J'; 
name[1] = 'u'; 
name[2] = 's'; 
name[3] = 't'; 
name[4] = 'i';
name[5] = 'n';
name[6] = '\0'; 
puts(str);

這樣的字元指定方式當然相當的不方便,所以 C 提供了字串處理的相關函式,可以協助字串處理,在之後的主題還會說明。

字串的宣告還有指標(Pointer)的宣告方式,這個留待談到指標時再來說明。

前面談到的都是僅包含英文字母的字串,那麼包含中文的字串呢?在談到這個問題前,得先探討一下,是否有辨法指定 '林'char 變數?這會引發編譯警訊:

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

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

char text[] = "林"; 

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

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

現代程式設計鼓勵使用 UTF-8,如果使用 UTF-8 撰寫原始碼,單純使用 gcc 編譯,不加上任何引數的話,若是在 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 編碼字串,C11 可以 UTF-8 撰寫原始碼,並在 "" 前置 u8,指定字串使用 UTF-8 編碼:

char text[] = u8"林";
printf("字串長度 %d", strlen(text)); // 顯示 3

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

char text[] = u8"\u6797";
printf("字串長度 %d", strlen(text)); // 顯示 3

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

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

wchar_t ch = L'林'; // 也可以寫 L'\u6797'
printf("%d", ch);   // 顯示 26519

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

#include <stdio.h>
#include <string.h>

int main(void) {
    wchar_t text[] = L"良葛格"; 
    printf("字串長度 %d", wcslen(text)); // 顯示 3

    return 0;
}

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

wchar_t 並沒有規定大小,只要求必須容納系統中可以使用的字元,C11 在 uchar.h 中定義了 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"良葛格";

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

#include <stdio.h>
#include <string.h>

void toUTF8(int cp, char* str);
int toCodePoint(char* str, int len);

int main(void) {
    char str1[5] = {0x00};

    toUTF8(L'林', str1);
    printf("%s\n", str1); // 在 UTF-8 終端機下會顯示「林」

    char str2[] = u8"林";                 // 「林」會使用三個位元組
    printf("%d\n", toCodePoint(str2, 3)); // 顯示 26519

    return 0;
}

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

int toCodePoint(char* u, int len) {
    if(len < 1) {
        return -1; 
    }

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

    if(len < 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(len < 3) {
        return -1; 
    }

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

    if (len < 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;
}