如果你對 UTF-8 編碼不是非常瞭解,就不要試圖在 C 程式中徒手處理 UTF-8 文字。如果你對 UTF-8 非常瞭解,就更沒必要這樣做。找一個提供了 UTF-8 文字處理功能並且可以跨平臺執行的 C 庫來做這件事吧!
GLib 就是這樣的庫。
從問題出發
下面的這段文字是 UTF-8 編碼的(我之所以如此確定,是因為我用的是 Linux 系統,系統預設的文字編碼是 UTF-8):
我的 C81 每天都在口袋裡
@
我需要在 C 程式中讀入這些文字。在讀到 `@`
字元時,我需要判定 `@`
左側與之處於同一行的文字是否都是空白字元。
簡單起見,我忽略了檔案讀取的過程,將上述文字表示為 C 字串:
gchar *demo_text =
"我的 C81 每天都在口袋裡
"
" @";
注:在 GLib 中,
gchar
就是char
,即typedef char gchar;
下文,當我說『demo_text
字串』時,指的是以 demo_text
指標的值為基地址的 strlen(demo_text) + 1
個位元組的記憶體空間,這是 C 語言字串的基本常識。
UTF-8 文字長度與字元定位
為了模擬程式讀到 `@`
字元這一時刻,我需要用一個 char *
型別的指標對 demo_text
字串中的 `@`
字元進行定位。
`@`
字元在 demo_text
的末尾。我需要一個偏移距離,而這個偏移距離就是 demo_text
字串在 UTF-8 編碼層次上的長度,通過這個偏移距離,我可以從 demo_text
字串的基地址跳到 `@`
字元的基地址。
GLib 提供了 g_utf8_strlen
函式計算 UTF-8 字串長度,因此我可以得到從 demo_text
字串的基地址到 `@`
字元基地址的偏移距離:
glong offset = g_utf8_strlen(demo_text, -1);
結果是 38
,恰好是 demo_text
字串在 UTF-8 編碼層次上的長度(不含字串結尾的 null
字元,亦即 ` `
字元)。
g_utf8_strlen
的原型如下:
glong g_utf8_strlen(const gchar *p, gssize max);
注:
glong
即long
,而gssize
即signed long
。
g_utf8_strlen
第二個引數 max
的設定規則如下:
-
如果它是負數,那麼就假定字串是以
null
結尾的(這是 C 字串常識),然後統計 UTF-8 字元的個數。 -
如果它為 0,就是不檢測字串長度……這個值純粹是出來打醬油的。
-
如果它為正數,表示的是位元組數。
g_utf8_strlen
會按照位元組數從字串中擷取位元組,然後再統計所擷取的位元組對應的 UTF-8 字元的個數。
有了偏移距離,就可以在 demo_text
中定位 `@`
字元了,即:
gchar *tail = g_utf8_offset_to_pointer(demo_text, offset - 1);
此時 tail
的值便是 `@`
字元的基地址。
在 UTF-8 文字中游走
現在已經獲得了 `@`
的位置,接下來就是從這個位置開始向左(也就是逆序)遍歷 demo_text
字串的其它字元。GLib 為此提供了 g_utf8_prev_char
函式:
gchar * g_utf8_prev_char(const gchar *str, const gchar *p);
藉助 g_utf8_prev_char
函式可以從 str
中獲得 p
之前的一個 UTF-8 字元的基地址(p 是當前 UTF-8 字元的基地址)。如果 p
與 str
相同,即 p
已經指向了字串的基地址,那麼 g_utf8_find_prev_char
會返回 NULL
。
對於本文要解決的問題而言,利用這個函式,可以寫出從 demo_text
中的 `@`
字元所在位置開始逆序遍歷 `@`
之前的所有 UTF-8 字元的過程:
glong offset = g_utf8_strlen(demo_text, -1);
gchar *viewer = g_utf8_offset_to_pointer(demo_text, offset - 1);
while (1) {
viewer = g_utf8_prev_char(viewer);
if (viewer != demo_text) {
/* do somthing here */
} else {
break;
}
}
GLib 還提供了一個 g_utf8_next_char
,它可以返回當前位置的下一個 UTF-8 字元的基地址。
提取 UTF-8 字元
雖然藉助 g_utf8_prev_char
與 g_utf8_next_char
可以讓指標在 UTF-8 文字中走動,但是隻能將一個指標定位到某個 UTF-8 字元的基地址,如果我們想得到這個 UTF-8 字元,就不是那麼容易了。
例如
viewer = g_utf8_prev_char(viewer);
此時,雖然可以將 viewer
向前移動一個 UTF-8 字元寬度的距離,到達了一個新的 UTF-8 字元的基地址,但是如果我想將這個新的 UTF-8 字元列印出來,像下面這樣做肯定是不行的:
g_print("%s", viewer);
注:
g_print
函式與 C 標準庫中的printf
函式功能基本等價,只不過g_print
可以藉助g_set_print_handler
函式實現輸出的『重定向』。
因為 g_print
要通過 viewer
列印單個 UTF-8 字元,前提是這個 UTF-8 字元之後需要有個 ` `
,這樣就是將一個 UTF-8 字元作為一個普通的 C 字串列印了出來。這個 UTF-8 字元後面不可能有 ` `
,除非它是 demo_text
字串中的最後一個字元。
要解決這個問題,只能是將 viewer
所指向的 UTF-8 字元相應的位元組資料提取出來,放到一個字元陣列或在堆中為其建立儲存空間,然後再列印這個字元陣列或堆空間中的資料。例如:
gchar *new_viewer = g_utf8_next_char(viewer);
sizt_t n = new_viewer - viewer;
gchar *utf8_char = malloc(n + 1);
memcpy(utf8_char, viewer, n);
utf8_char[n] = ` `;
g_print("%s", utf8_char);
free(utf8_char);
這樣顯然太繁瑣了。不過,這意味著我們應該寫一個函式專門做這件事。這個函式可取名為 get_utf8_char
,定義如下:
static gchar * get_utf8_char(const gchar *base) {
gchar *new_base = g_utf8_next_char(base);
gsize n = new_base - base;
gchar *utf8_char = g_memdup(base, (n + 1));
utf8_char[n] = ` `;
return utf8_char;
}
藉助這個函式,就可以實現從 demo_text
的 `@`
所在位置開始,逆序列印 `@`
之前的所有 UTF-8 字元:
glong offset = g_utf8_strlen(demo_text, -1);
gchar *viewer = g_utf8_offset_to_pointer(demo_text, offset - 1);
while (1) {
gchar outbuf[7] = {` `};
viewer = g_utf8_prev_char(viewer);
if (viewer != demo_text) {
gchar *utf8_char = get_utf8_char(viewer);
g_print("%s", utf8_char);
g_free(utf8_char);
} else {
break;
}
}
g_print("
");
注:
g_memdup
等價於 C 標準庫中的malloc
+memcpy
,而g_free
則等價與 C 標準庫中的free
。
空白字元比較
現在,假設給定一個 UTF-8 字元 x
,怎麼判斷它與某個 UTF-8 字元相等?
不要忘記,所謂的一個 UTF-8 字元,本質上只不過是 char *
型別的指標引用的一段記憶體空間。基於這一事實,利用 C 標準庫提供的 strcmp
函式即可實現 UTF-8 字元的比較。
下面,我定義了函式 is_space
,用它判斷一個 UTF-8 字元是否為空白字元。
static gboolean is_space(const gchar *s) {
gboolean ret = FALSE;
char *space_chars_set[] = {" ", " ", " "};
size_t n = sizeof(space_chars_set) / sizeof(space_chars_set[0]);
for (size_t i = 0; i < n; i++) {
if (!strcmp(s, space_chars_set[i])) {
ret = TRUE;
break;
}
}
return ret;
}
注:
gboolean
是 GLib 定義的布林型別,其值要麼是TRUE
,要麼是FALSE
。
在 is_space
函式中,我只是判斷了三種空白字元型別——英文空格、中文全形空格以及製表符。
雖然回車符與換行符也是空白字元,但是為了解決這篇文章開始時提出的問題,我需要單獨為換行符定義一個判斷函式:
static gboolean is_line_break(const gchar *s) {
return (!strcmp(s, "
") ? TRUE : FALSE);
}
解決問題
現在萬事俱備,只欠東風,我們應該著手解決問題了。如果讀到此處已經忘記了問題是什麼,那麼請回顧第一節。
儘管下面這段程式碼看上去挺醜,但是它能夠解決問題。
gboolean is_right_at_sign = TRUE;
glong offset = g_utf8_strlen(demo_text, -1);
gchar *viewer = g_utf8_offset_to_pointer(demo_text, offset - 1);
while (viewer != demo_text) {
viewer = g_utf8_prev_char(viewer);
gchar *utf8_char = get_utf8_char(viewer);
if (!is_space(utf8_char)) {
if (!is_line_break(utf8_char)) {
is_right_at_sign = FALSE;
g_free(utf8_char);
break;
} else {
g_free(utf8_char);
break;
}
}
g_free(utf8_char);
}
if (is_right_at_sign) g_print("Right @ !
");
對上述程式碼略做簡化,可得:
gboolean is_right_at_sign = TRUE;
glong offset = g_utf8_strlen(demo_text, -1);
gchar *viewer = g_utf8_offset_to_pointer(demo_text, offset - 1);
while (viewer != demo_text) {
viewer = g_utf8_prev_char(viewer);
gchar *utf8_char = get_utf8_char(viewer);
if (!is_space(utf8_char)) {
if (!is_line_break(utf8_char)) is_right_at_sign = FALSE;
g_free(utf8_char);
break;
}
g_free(utf8_char);
}
if (is_right_at_sign) g_print("Right @ !
");
其實,如果將 UTF-8 字元的提取與記憶體釋放過程置入 is_space
與 is_line_break
函式,即:
static gboolean is_space(const gchar *c) {
gboolean ret = FALSE;
gchar *utf8_char = get_utf8_char(c);
char *space_chars_set[] = {" ", " ", " "};
size_t n = sizeof(space_chars_set) / sizeof(space_chars_set[0]);
for (size_t i = 0; i < n; i++) {
if (!strcmp(utf8_char, space_chars_set[i])) {
ret = TRUE;
break;
}
}
g_free(utf8_char);
return ret;
}
static gboolean is_line_break(const gchar *c) {
gboolean ret = FALSE;
gchar *utf8_char = get_utf8_char(c);
if (!strcmp(utf8_char, "
")) ret = TRUE;
g_free(utf8_char);
return ret;
}
可以得到進一步的簡化結果:
gboolean is_right_at_sign = TRUE;
glong offset = g_utf8_strlen(demo_text, -1);
gchar *viewer = g_utf8_offset_to_pointer(demo_text, offset - 1);
while (viewer != demo_text) {
viewer = g_utf8_prev_char(viewer);
if (!is_space(viewer)) {
if (!is_line_break(viewer)) is_right_at_sign = FALSE;
break;
}
}
if (is_right_at_sign) g_print("Right @ !
");
附:完整的程式碼
#include <string.h>
#include <glib.h>
gchar *demo_text =
"我的 C81 每天都在口袋裡
"
" @";
static gchar * get_utf8_char(const gchar *base) {
gchar *new_base = g_utf8_next_char(base);
gsize n = new_base - base;
gchar *utf8_char = g_memdup(base, (n + 1));
utf8_char[n] = ` `;
return utf8_char;
}
static gboolean is_space(const gchar *c) {
gboolean ret = FALSE;
gchar *utf8_char = get_utf8_char(c);
char *space_chars_set[] = {" ", " ", " "};
size_t n = sizeof(space_chars_set) / sizeof(space_chars_set[0]);
for (size_t i = 0; i < n; i++) {
if (!strcmp(utf8_char, space_chars_set[i])) {
ret = TRUE;
break;
}
}
g_free(utf8_char);
return ret;
}
static gboolean is_line_break(const gchar *c) {
gboolean ret = FALSE;
gchar *utf8_char = get_utf8_char(c);
if (!strcmp(utf8_char, "
")) ret = TRUE;
g_free(utf8_char);
return ret;
}
int main(void) {
gboolean is_right_at_sign = TRUE;
glong offset = g_utf8_strlen(demo_text, -1);
gchar *viewer = g_utf8_offset_to_pointer(demo_text, offset - 1);
while (viewer != demo_text) {
viewer = g_utf8_prev_char(viewer);
if (!is_space(viewer)) {
if (!is_line_break(viewer)) is_right_at_sign = FALSE;
break;
}
}
if (is_right_at_sign) g_print("Right @ !
");
return 0;
}
若是在 Bash 中使用 gcc 編譯這份程式碼,可使用以下命令:
$ gcc `pkg-config --cflags --libs glib-2.0` utf8-demo.c -o utf8-demo