回上级页面

文本分割

2023 年 06 月 15 日


问题

假设存在一个字符串,其内容为

以下 Rust 程序
@
fn main() {
    println!("Hello world!");
}
@
可在终端打印「Hello world!」。

编写一个 Rust 程序,以 @ 所在行作为分割符,对该字符串进行分割。

字符串切片

Rust 有多种类型可表达字符串,以下示例给出了最直白的一种:

let text = "潜龙勿用";

上述代码中的变量 text,其类型是什么呢?可使用 Rust 标准库中的 std::any::type_name 函数打印其类型,例如:

fn print_type_of<T>(_: &T) {
    println!("{}", std::any::type_name::<T>());
}

fn main() {
    let x = "潜龙勿用";
    print_type_of(&x);
}

假设将上述代码保存至 foo.rs 文件,然后使用 rustc 编译该文件:

$ rustc foo.rs

运行编译所得程序 foo:

$ ./foo
&str

程序 foo 的输出结果 &str 便是变量 x 的类型。

上述代码中定义的函数 print_type_of 巧妙地利用了泛型以及类型推导功能,将其所接受的参数的类型「转发」给了 std::any::type_name 函数。需要注意的是,为了避免参数复制而带来的内存和时间消耗,print_type_of 的参数采用了变量的引用形式。关于泛型和引用,后文会对其进行更为全面且深入的介绍,在此不必为之惶恐。

现在已知变量 x 的类型是 &str,即 str 类型的实例(或数据)的引用。可将 str 类型的实例想象为一组字符构成的序列,而 &str 类型的变量仅记录该序列的首地址以及该序列的长度信息,因此所占用的内存长度通常远小于字符序列本身。

在 Rust 语言中,类型 &str 通常称为字符串切片,原因是基于该类型可引用一个字符串的片段。例如

fn print_type_of<T>(_: &T) {
    println!("{}", std::any::type_name::<T>());
}
fn main() {
    let x = "Hello world!";
    let y = &x[0..5];
    print_type_of(&y);
    println!("\"{}\" of \"{}\".", y, x);
}

输出结果为

&str
"Hello" of "Hello world!".

亦即 y 是对 x 所引用的字符序列的一个片段的引用。&x[0..5] 表达的是左闭右开区间,即 x 所引用的 str 实例的第 1 个字符(下标为 0)至第 5 个字符(下标为 4)。

向量

Rust 使用泛型容器 Vec<T>(向量)类型表达动态数组。下面的示例可将三个字符串切片存于 Vec<T> 的实例:

fn print_type_of<T>(_: &T) {
    println!("{}", std::any::type_name::<T>());
}
fn main() {
    let x = "Hello@world@!";
    let v = vec![&x[..5], &x[6..11], &x[12..]];
    print_type_of(&v);
    println!("[{}, {}, {}]", v[0], v[1], v[2]);
}

输出为

alloc::vec::Vec<&str>
[Hello, world, !]

vec! 是 Rust 语言用于简化向量实例初始化的宏。倘若不使用该宏,则上述向量的构造过程可基于 Vec 类型的 push 方法实现:

let mut v = vec![]; // 初始化空向量
v.push(&x[..5]);
v.push(&x[6..11]);
v.push(&x[12..]);

由于 v 是动态构建的,因此其必须冠以 mut 表示其值可变。此外,上述代码中字符串切片表达式中的[..5][0..5] 的简写,而 [12..][12..x.len()] 的简写,x.len() 方法可以获得 str 实例(即字符序列)的长度。

字符串分割

对于本章要解决的问题,上述两节其实已经解决了待实现的程序的输入和输出问题,即 &str 作为输入,Vec<&str> 类型作为输出。

Rust 标准库为 &str 类型提供了 split 方法,该方法正是以 &str 作为输入,且其输出能转化为 Vec<&str> 类型。例如

fn main() {
    let x = "abc@def@ghi";
    let v: Vec<&str> = x.split("@").collect();
    println!("[{}, {}, {}]", v[0], v[1], v[2]);
}

str 类型的 split 方法的返回值是 Split 类型,该类型的 collect 方法可将字符串分割结果转化为 Vec<&str> 类型。

迭代器

Split 类型实际上是一种迭代器类型——该类型实现了 Iterator 特性(Trait),若不使用它的 collect 方法,使用 for...in 语句也能够获得字符串分割结果,例如

fn print_type_of<T>(_: &T) {
    println!("{}", std::any::type_name::<T>());
}
fn main() {
    let x = "abc@def@ghi";
    let v = x.split("@");
    print_type_of(&v);
    for i in v {
        println!("{}", i);
    }
}

输出为

core::str::iter::Split<&str>
abc
def
ghi

关于迭代器类型的技术细节,目前知之甚浅,先行略过,随着 rzeo 项目的逐步推进,在合适的时机,再对其深入探究。

放手一试

对于本章开始提出的问题,以下程序可作为答案:

fn main() {
    // 注意:字符串中出现双引号需要用反斜线予以转义
    let x ="以下 Rust 程序
@
fn main() {
    println!(\"Hello world!\");
}
@
可在终端打印「Hello world!」。";
    let v = x.split("\n@\n");
    println!("----");
    for i in v {
        println!("{}", i);
        println!("----");
    }
}

其输出为

----
以下 Rust 程序
----
fn main() {
    println!("Hello world!");
}
----
可在终端打印「Hello world!」。
----

C 版本

下面采用 C 语言实现与上一节相同功能的程序,并且尝试模拟 Rust 的迭代器以获得 &strSplit 等类型更为深刻的理解。此外,我也想试图向自己证明 C 语言并非许多 Rust 爱好者们所宣扬的那样陈旧甚至糟糕。

C 语言标准库提供了函数 strtok,可用于字符串分割。例如

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

int main(void) {
        char x[] = "123 @ 456 @ 789";
        char *d = " @ ";
        char *v = strtok(x, d);
        printf("%s\n", v);
        while (v = strtok(NULL, d)) {
                printf("%s\n", v);
        }
        return 0;
}

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

int main(void) {
        char x[] = "123 @ 456 @ 789";
        for (char *s = x; ; s = NULL) {
                char *v = strtok(s, " @ ");
                if (!v) break;
                printf("%s\n", v);
        }
        return 0;
}

输出为

123
456
789

strtok 是不可重入的函数,故而不能用于多线程任务。对于上述示例,strtok 有以下副作用:

在 Rust 语言爱好者看来,strtok 不安全,绝不可容忍。不过,用 C 语言自行实现一个安全的字符串分割函数并不困难。C 标准库提供了 strstr 函数,可用于在字符串中查找目标字符串第一次出现的位置,基于该函数便可实现安全的字符串分割功能,下文给出了一种实现。

首先,需要引入以下 C标准库头文件:

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

定义一个结构体类型,用于表示字符串切片,并为该类型定义打印函数:

typedef struct {
        char *head;
        char *tail;
} StrSlice;

void str_slice_print(StrSlice *slice) {
        for (char *p = slice->head; p != slice->tail; p++) {
                putchar(*p);
        }
}

再定义一个结构体类型,用于记录分割符以及字符串每次分割后剩余内容,并为该类型定义构造函数:

typedef struct {
        char *remainder; /* 字符串剩余内容 */
        char *delimiter; /* 分割符 */
        size_t delimiter_length; /* 分割符长度 */
} StrSplitEnv;

StrSplitEnv *str_split_env(char *str, char *delimiter) {
        StrSplitEnv *env = malloc(sizeof(StrSplitEnv));
        env->remainder = str;
        env->delimiter = delimiter;
        env->delimiter_length = strlen(delimiter);
        return env;
}

定义字符串分割函数:

StrSlice *str_split(StrSplitEnv *env) {
        if (!env->remainder) return NULL;
        StrSlice *slice = malloc(sizeof(StrSlice));
        slice->head = env->remainder;
        slice->tail = strstr(env->remainder, env->delimiter);
        if (slice->tail) {
                env->remainder = slice->tail + env->delimiter_length;
        } else {
                env->remainder = NULL;
                slice->tail = slice->head + strlen(slice->head);
        }
        return slice;
}

字符串分割函数的测试代码如下:

int main(void) {
        char *x = "以下 Rust 程序\n@\n"
                "fn main() {\n"
                "    println!(\"Hello world!\");\n"
                "}\n@\n"
                "可在终端打印「Hello world!」。";
        StrSplitEnv *env = str_split_env(x, "\n@\n");
        StrSlice *v;
        printf("----\n");
        while (v = str_split(env)) {
                str_slice_print(v); putchar('\n');
                free(v);
                printf("----\n");
        }
        free(env);
        return 0;
}

Rust 语言以保证内存安全而著称。C 语言可借助带有内存错误检测工具的 C 编译器或辅以 Valgrind 工具保证内存安全。假设将上述 C 程序保存为 foo.c 文件,可使用以下命令编译并运行:

$ gcc -std=c2x -pedantic -g -fsanitize=address -o foo foo.c
$ ./foo

$ gcc -std=c2x -pedantic -g -o foo foo.c
$ valgrind --leak-check=full ./foo

若程序运行结束后,未出现错误信息,则通常可视为该程序是内存安全的,之后可重新编译程序,消除调试信息,并开启优化:

$ gcc -std=c2x -pedantic -O2 -o foo foo.c

小结

在对 Rust 语言较为熟悉的情况下,编写的 Rust 代码通常较实现同样功能的 C 代码更为简洁,一方面是因为 Rust 标准库的功能比 C 标准库更为丰富,另一方面 Rust 的语法掩盖了许多细节。对于后者,除了本章语焉不敢甚详的泛型, 特性以及迭代器等元素外,值得一提的是,一个 Rust 新手也许难以想象,为何以下 Rust 代码无法通过编译:

fn main() {
    let x = "abc@def@ghi";
    let v = x.split("@");
    for i in v {
        println!("{}", i);
    }
    for i in v {
        println!("{}", i);
    }
}

倘若将 Rust 语言视为比 C 甚至比 C++ 更好的语言,那心态上就不要乐观,更好往往意味着更难驾驭。汽车比摩托车更好,在驾照方面,汽车要比摩托车难得多,至少在国内是这样。