Rust程式語言的字串是使用UTF-8編碼的,這種編碼方式讓每個字元有四種不同的寬度,例如4會被編碼成[52]ß會被編碼成[195, 159]會被編碼成[228, 184, 173]𩸽(ㄌㄨㄥˇ)會被編碼成[240, 169, 184, 189]。再加上Rust是標榜安全的程式語言,因此在切割字串的時候會去做索引位置的檢查,避免取到不正確的資料範圍,不過這也因此讓Rust的字串處理多了不小的開支(overhead)。



先看看下面這個程式:

fn main() {
    let text = "abc";

    println!("{}", &text[0..2]);
}

以上程式的text變數是一個字串切片(&str),&text[0..2]可以取得一個新的字串切片,資料為text變數的第一個字元和第二個字元。所以最終會輸出ab

不過事實上,&text[0..2]中的切片範圍(0..2),是指儲存abc這個字串經UTF-8編碼後的位元組陣列(u8切片)之切片範圍。把abc換成中文字試試看就知道了。

fn main() {
    let text = "中文字";

    println!("{}", &text[0..2]);
}

以上程式在執行的時候會於第4行發生panic,因為中文字轉成位元組陣列後的前兩個位元組並不是有效的UTF-8資料範圍。

所以如果我們想要取得中文這個子字串,切片的範圍要怎麼決定呢?……就只能根據字元的UTF-8寬度來決定啦!因為這兩個字都有3個位元組,所以要將程式改寫成:

fn main() {
    let text = "中文字";

    println!("{}", &text[0..6]);
}

如此一來程式才可以成功執行並印出中文這個子字串。

不過在編譯階段無法確定被切片的字串到底是什麼的情況下,當然就無法預知要抓取的切片範圍。此時可以使用字串的chars方法來產生一個迭代器(iterator),一邊將UTF-8資料解碼,一邊走訪整個字串。如下:

fn main() {
    let text = "中文字";
    
    for c in text.chars().take(2) {
        print!("{c}");
    }

    println!();
}

以上程式可以印出中文字的前兩個字元,

chars方法雖然感覺上挺方便的,但它卻是效能殺手。例如我們要寫一個程式,用來找出任意字串中第一個全形句點,並將句點前,包含這個句點的字串取出來處理。比較直覺的程式實作方式如下:

fn main() {
    let text = "昔人已乘黃鶴去,此地空餘黃鶴樓。黃鶴一去不復返,白雲千載空悠悠。";

    let mut first_sentence = String::new();

    for c in text.chars() {
        first_sentence.push(c);

        if c == '。' {
            break;
        }
    }

    println!("{first_sentence}");
}

以上程式碼看起來簡潔易懂,但經不起推敲。因為first_sentence變數內的資料完全就是text變數內的資料之前面的部份,實在沒有必要再另外複製一份出來。而且這個寫法會讓String結構體的push被呼叫數次,資料插入效率很糟,有點像是在實作一個檔案複製功能,每次只複製一個位元組那樣。

不需要複製字串的實作方式如下:

fn main() {
    let text = "昔人已乘黃鶴去,此地空餘黃鶴樓。黃鶴一去不復返,白雲千載空悠悠。";

    let mut length = 0;

    for c in text.chars() {
        length += c.len_utf8();

        if c == '。' {
            break;
        }
    }

    println!("{}", &text[..length]);
}

利用一個變數來儲存已經看過的位元組數量,最後再進行切片處理。字元的len_utf8方法可以用來計算這個字元在經過UTF-8編碼後的位元組數量。

雖然改寫成這樣讓效能有了很大的提升,但chars方法產生的迭代器其實也會去計算字元寬度。而且它會回傳字元,表示它有去進行UTF-8的解碼,但在這個例子中顯然不太需要。程式第14行,去取字串切片的方式也是有點問題,那就是我們雖然能夠100%確定..length這個範圍是UTF-8的正確資料範圍,但程式在執行的時候還是需要從頭再去驗證一次……

最後那個問題很好解決,可以將字串切片轉成u8切片後再重新切片,最後再把新範圍的u8切片轉回&str。程式碼如下:

use std::str::from_utf8_unchecked;

fn main() {
    let text = "昔人已乘黃鶴去,此地空餘黃鶴樓。黃鶴一去不復返,白雲千載空悠悠。";

    let mut length = 0;

    for c in text.chars() {
        length += c.len_utf8();

        if c == '。' {
            break;
        }
    }

    println!("{}", unsafe { from_utf8_unchecked(&text.as_bytes()[..length]) });
}

當然,也是可以直接使用切片提供的get_unchecked方法,但這個方法會連最基本的記憶體位址邊界檢查都捨棄掉,危險性十足,也不會讓程式變得快多少(事實上有些案例顯示會變得更慢),不是很建議使用。

fn main() {
    let text = "昔人已乘黃鶴去,此地空餘黃鶴樓。黃鶴一去不復返,白雲千載空悠悠。";

    let mut length = 0;

    for c in text.chars() {
        length += c.len_utf8();

        if c == '。' {
            break;
        }
    }

    println!("{}", unsafe { text.get_unchecked(..length) });
}

底下就來介紹能夠以比較低階的方式替代chars方法的套件。

UTF-8 Width

「UTF-8 Width」是筆者開發的套件,可以利用字元經UTF-8編碼後的第一個位元組,來計算這個字元經UTF-8編碼後的寬度。

Crates.io

Cargo.toml

utf8-width = "*"

使用方法

get_width函數可以傳入一個UTF-8編碼後的字元之第一個位元組(u8)的數值,並回傳該字元的寬度。如果第一個位元組不是有效的數值,會回傳0

assert_eq!(1, utf8_width::get_width(b'1'));
assert_eq!(3, utf8_width::get_width("中".as_bytes()[0]));

如果可以確定傳入的第一個位元組一定是有效的數值(例如是從Rust字串中取出來的位元組資料。因為透過非unsafe的方式產生的Rust字串,已經能確定它是一個正確的UTF-8資料),可以改用get_width_assume_valid函數,會算得更快。

另外這個套件還提供了第一個位元組的寬度範圍之常數,如果有需要的話還可以利用這些常數直接進行範圍判斷,連寬度都不用算。

改寫上面的「第一句」程式

利用這個套件,可以將上面的「第一句」程式改寫如下:

use std::str::from_utf8_unchecked;

fn main() {
    let text = "昔人已乘黃鶴去,此地空餘黃鶴樓。黃鶴一去不復返,白雲千載空悠悠。";
    let text_bytes = text.as_bytes();
    let text_length = text_bytes.len();

    let mut p = 0;

    while p < text_length {

        let e = text_bytes[p];

        let width = unsafe { utf8_width::get_width_assume_valid(e) };

        p += width;

        if e == 227 && text_bytes[p - 2] == 128 && text_bytes[p - 1] == 130 {
            break;
        }
    }

    println!("{}", unsafe { from_utf8_unchecked(&text_bytes[..p]) });
}

補充:字元的UTF-8編碼結果是[227, 128, 130]

改寫之後,這個「第一句」程式僅會去計算一次UTF-8字元的寬度,而且不會去進行UTF-8字元的解碼。

以上程式並不需要去判斷width是否等於3,因為不同寬度的UTF-8字元其第一個位元組一定是不同的。事實上,在這個例子中,我們甚至也不一定要去計算寬度,因為輸入的Rust字串一定是一個正確的UTF-8資料,而UTF-8還有一個特性就是非第一個位元組的數值範圍並不會和第一個位元組重疊。

所以「第一句」程式其實還可以有如下的實作方式:

use std::str::from_utf8_unchecked;

fn main() {
    let text = "昔人已乘黃鶴去,此地空餘黃鶴樓。黃鶴一去不復返,白雲千載空悠悠。";

    let length = text.find("。").map(|length| length + 3).unwrap_or(text.len());

    println!("{}", unsafe { from_utf8_unchecked(&text.as_bytes()[..length]) });
}

Rust字串內建的find方法不會浪費效能去進行UTF-8相關的處理,可以安心使用。

利用第一個位元組取得UTF-8字元寬度的方式

利用第一個位元組取得UTF-8字元寬度的方式大致上有三種。

第一種是利用遮罩去進行計算。寬度為1的字元,其第一個位元組之最大位元(MSB, Most Significant Bit)一定是0;寬度為2的字元,其第一個位元組最大的三個位元一定是110;寬度為3的字元,其第一個位元組最大的四個位元一定是1110;寬度為4的字元,其第一個位元組最大的五個位元一定是11110

第二種是利用第一個位元組的範圍來判斷。寬度為1的字元,其第一個位元組的範圍是0x00~0x7F;寬度為2的字元,其第一個位元組的範圍是0xC2~0xDF;寬度為3的字元,其第一個位元組的範圍是0xE0~0xEF;寬度為4的字元,其第一個位元組的範圍是0xF0~0xF4。這種方式是上面介紹的「UTF-8 Width」套件選擇使用的方式。

第三種是查表,表格如下:

static UTF8_CHAR_WIDTH: [usize; 256] = [
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x1F
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x3F
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x5F
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x7F
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x9F
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xBF
    0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, // 0xDF
    3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, // 0xEF
    4, 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xFF
];

這三種方式的效能差不多,但第三種通常不會比較快,而且會佔用額外的記憶體空間。