在 C 程式中處理 UTF-8 文字

garfileo發表於2019-05-09

如果你對 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);

注:glonglong,而 gssizesigned 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 字元的基地址)。如果 pstr 相同,即 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_charg_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_spaceis_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

相關文章