字符串是一个光秃秃的数据结构,其途经之地会出现很多重复的处理。它简直是隐藏重要信息的“完美”手段。 ——Alan Perlis,警句 #34

本书一直在使用 Rust 的主要文本类型 String、str 和 char。 3.7 节曾讲解过字符和字符串字面量的语法,也展示过字符串在内存中的表示方式。在本章中,我们将更详细地介绍文本处理技术。本章包括如下内容。 提供一些 Unicode 背景知识来帮助你理解标准库的设计。 讲解表示单个 Unicode 码点的 char 类型。 讲解 String 类型和 str 类型,二者是表示拥有和借用的 Unicode 字符序列。它们有各种各样的方法来构建、搜索、修改和迭代其内容。 介绍 Rust 的字符串格式化工具,比如 println! 宏和 format! 宏。你可以编写自己的宏来处理格式化字符串,并扩展它们以支持自己的类型。 概述 Rust 对正则表达式的支持。 讨论为什么 Unicode 的规范化很重要,并展示如何在 Rust 中对其进行规范化。

17.1 一些 Unicode 背景知识

本书是关于 Rust 而不是 Unicode 的,后者已经有专门的书介绍它了。但是,Rust 的字符类型和字符串类型都是围绕 Unicode 设计的。此处介绍一些 Unicode 的背景知识有助于更好地理解 Rust。

17.1.1 ASCII、Latin-1 和 Unicode

Unicode 和 ASCII 对于从 0 到 0x7f 的所有 ASCII 码点是一一对应的,比如,它们都为字符 * 分配了码点 42。同样,Unicode 也将 0 到 0xff 分配给了与 ISO/IEC 8859-1 字符集相同的字符,这是 ASCII 字符集用于西欧语言的 8 位超集。Unicode 将此码点范围称为 Latin-1 码块,因此我们也将使用耳熟能详的名称 Latin-1 来指代 ISO/IEC 8859-1。 由于 Unicode 是 Latin-1 的超集,因此将 Latin-1 转换为 Unicode 甚至不需要查表:

fn latin1_to_char(latin1: u8) -> char {
    latin1 as char
}

反向转换也很简单,假设码点落在了 Latin-1 范围内。

fn char_to_latin1(c: char) -> Option<u8> {
    if c as u32 <= 0xff {
        Some(c as u8)
    } else {
        None
    }
}

17.1.2 UTF-8 编码

Rust 的 String 类型和 str 类型表示使用了 UTF-8 编码形式的文本。UTF-8 会将字符编码为 1~4 字节的序列,如图 17-1 所示。

image.png

图 17-1:UTF-8 编码 格式良好的 UTF-8 序列有两个限制。首先,只有任何给定码点的最短编码才被认为是格式良好的,你不能花费 4 字节来编码原本只需要 3 字节的码点。此规则确保了每个码点只会有唯一一个 UTF-8 编码。其次,格式良好的 UTF-8 不得对从 0xd800 到 0xdfff 或超过 0x10ffff 的数值进行编码:这些数值要么保留用作非字符目的,要么完全超出了 Unicode 的范围。 图 17-2 展示了一些示例。

image.png

图 17-2:UTF-8 示例 请注意,虽然在螃蟹表情符号的编码中其前导字节对码点只贡献了一串 0,但是它仍然需要用 4 字节来编码:3 字节的 UTF-8 编码只能表达 16 位码点,而 0x1f980 有 17 位长。 下面是一个包含具有不同编码长度字符的字符串的简单示例:

assert_eq!("うどん: udon".as_bytes(),            &[0xe3, 0x81, 0x86, // う 
             0xe3, 0x81, 0xa9, // ど 
             0xe3, 0x82, 0x93, // ん 
             0x3a, 0x20, 0x75, 0x64, 0x6f, 0x6e // : udon            ]);

图 17-2 还展示了 UTF-8 一些非常有用的属性。 由于 UTF-8 会把码点 0 ~ 0x7f 编码为字节 0 ~ 0x7f,因此一段 ASCII 文本必然是有效的 UTF-8 字符串。反过来,如果 UTF-8 字符串中只包含 ASCII 字符,则它也必然是有效的 ASCII 字符串。 对于 Latin-1 则不是这样的,比如,Latin-1 会将 é 编码为字节 0xe9,而 UTF-8 会将其解释为三字节编码中的第一字节。

通过查看任何字节的高位,就能立刻判断出它是某个字符的 UTF- 8 编码的起始字节还是中间字节。 编码的第一字节会单独通过其前导位告诉你编码的全长。 由于不会有任何编码超过 4 字节,因此 UTF-8 在处理时从不需要无限循环,这在处理不受信任的数据时非常有用。 在格式良好的 UTF-8 中,即使从字节中间的任意点开始,你也始终可以明确地找出该字符编码的起始位置和结束位置。UTF-8 的第一字节和后面的字节一定不同,所以一段编码不可能从另一段编码的中间开始。第一字节会确定编码的总长度,因此任何一段编码都不可能是另一段编码的前缀。这很有用。例如,要在 UTF8 字符串中搜索 ASCII 分隔符只需对分隔符的字节进行简单扫描即可。这个分隔符永远不会作为多字节编码的任何部分出现,因此根本不需要跟踪 UTF-8 的结构。类似地,在一个字节串中搜索 另一个字节串的算法无须针对 UTF-8 字符串做修改即可正常工作,甚至连那些根本不会检查待搜文本中每字节的算法也没问题。 尽管可变宽度编码比固定宽度编码更复杂,但以上特征让 UTF-8 比预想的更容易使用。标准库会帮你处理绝大部分问题。

17.1.3 文本方向性

拉丁文、西里尔文、泰文等文字是从左向右书写的,而希伯来文、阿拉伯文等文字则是从右向左书写的。Unicode 以写入或读取字符的常规顺序存储字符,因此在这种情况下字符串(如希伯来语文本)中保存的首字节是对写在最右端的字符的编码。 assert_eq!(“ערב טוב”.chars().next(), Some(‘ע’));

17.2 字符(char)

Rust 的 char 类型是一个包含 Unicode 码点的 32 位值。char 保证会落在 0~ 0xd7ff 或 0xe000~ 0x10ffff 范围内,所有用于创建和操作 char 值的方法都会确保此规则永远成立。char 类型实现了 Copy 和 Clone,以及用于比较、哈希和格式化的所有常用特型。 字符串切片可以使用 slice.chars() 生成针对其字符的迭代器: assert_eq!(“カニ”.chars().next(), Some(‘カ’)); 接下来的讲解中出现的变量 ch 全都是 char 类型的。

17.2.1 字符分类

char 类型的一些方法可以将字符分入几个常见类别,如表 17-1 所示。这些都是从 Unicode 中提取的定义。表 17-1:char 类型的分类方法

一组仅限于 ASCII 的方法,对任何非 ASCII char 都会返回 false,如表 172 所示。 表 17-2:char 的 ASCII 分类方法 方法 描述 例子

方法 描述 例子 ch.is_ascii() ASCII 字符:码点介于 0 和 127 之间的字符 ‘n’.is_ascii() !‘ñ’.is_ascii() ch.is_ascii_alphabetic() 大写或小写 ASCII 字母,在 ‘A’..=‘Z’ 或 ‘a’..=‘z’ 范围内 ‘n’.is_ascii_alphabetic()

!'1'.is_ascii_alphabetic()

!‘ñ’.is_ascii_alphabetic() ch.is_ascii_digit() ASCII 数字,在 ‘0’..=‘9’ 范围内 ‘8’.is_ascii_digit()

!'-'.is_ascii_digit()

!‘⑧’.is_ascii_digit() ch.is_ascii_hexdigit() ‘0’..=‘9’、’A’..=‘F’ 或 ‘a’..=‘f’ 范围内的 任何字符 ch.is_ascii_alphanumeric() ASCII 数字或者大写字母或小写字母 ‘q’.is_ascii_alphanumeric()

'0'.is_ascii_alphanumeric()

ch.is_ascii_control() ASCII 控制字符,包括

DEL	'\n'.is_ascii_control() 
'\x7f'.is_ascii_control()

ch.is_ascii_graphic() 会在页面上留下墨迹的任 何 ASCII 字符:既不是空白字符也不是控制字符 ‘Q’.is_ascii_graphic()

'~'.is_ascii_graphic() 
!' '.is_ascii_graphic()

ch.is_ascii_uppercase(), ch.is_ascii_lowercase() ASCII 大写字母和小写字母 ‘z’.is_ascii_lowercase()

'Z'.is_ascii_uppercase()

ch.is_ascii_punctuation() 既不是字母也不是数字的 1 任何 ASCII 图形字符 ch.is_ascii_whitespace() ASCII 空白字符:空格、水平制表符、换行符、换页符或回车符 ‘ ‘.is_ascii_whitespace()

'\n'.is_ascii_whitespace() 
!'\u{A0}'.is_ascii_whitespace()
1

也就是广义标点。——译者注 所有 isascii… 方法也可用于 u8 字节类型:

assert!(32u8.is_ascii_whitespace());
assert!(b'9'.is_ascii_digit());

在使用这些函数来实现现有规范(如编程语言标准或文件格式)时一定要小心,因为这些分类可能存在某些令人吃惊的差异。例如,注意 is_whitespace 和 is_ascii_whitespace 对某些字符的处理不同:

let line_tab = '\u{000b}'; //“行间制表符”,也叫“垂直制表符” 
assert_eq!(line_tab.is_whitespace(), true);
assert_eq!(line_tab.is_ascii_whitespace(), false);

这是因为 char::is_ascii_whitespace 函数实现了许多 Web 标准中通用的空白字符定义,而 char::is_whitespace 遵循的是 Unicode 标准。

17.2.2 处理数字对于数字的处理,可以使用以下方法。

ch.to_digit(radix)(转数字)   判断 ch 是不是以 radix 为基数的数字。如果是,就返回 Some(num),其中 num 是 u32;否则,返回 None。此方法只会识别 ASCII 数字,而不包括 char::is_numeric 涵盖的更广泛的字符类别。radix 参数的范围可以从 2 到 36。对于大于 10 的基数,会用 ASCII 字母(不分大小写)表示值为 10 到 35 的数字。 std::char::from_digit(num, radix)(来自数字)   自由函数,只要有可能,就可以把 u32 数字值 num 转换为 char。如果 num 可以表示为 radix 中的单个数字,那么 from_digit 就会返回 Some(ch),其中 ch 是数字。当 radix 大于 10 时,ch 可以是小写字母。否则,它会返回 None。   这是 to_digit 的逆函数。如果 std::char::from_digit(num, radix) 等于 Some(ch),则 ch.to_digit(radix) 等于 Some(num)。如果 ch 是 ASCII 数字或小写字母,则反之亦成立。 ch.is_digit(radix)(是数字?)   如果 ch 可以表示以 radix 为基数的 ASCII 数字,就返回 true。此方法等效于 ch.to_digit(radix) != None。 关于上述方法,举例如下。

assert_eq!('F'.to_digit(16), Some(15));
assert_eq!(std::char::from_digit(15, 16), Some('f'));
assert!(char::is_digit('f', 16));

17.2.3 字符大小写转换处理字符大小写的方法如下。

ch.is_lowercase()(是小写?)和 ch.is_uppercase()(是大 写?)   指出 ch 是小写字母字符还是大写字母字符。这两个方法遵循 Unicode 的派生属性 Lowercase(小写字母)和 Uppercase(大写字母),因此它们涵盖了非拉丁字母表(如希腊字母和西里尔字母),并给出了和 ASCII 一样的预期结果。 ch.to_lowercase()(转小写)和 ch.to_uppercase()(转大写)   根据 Unicode 的默认大小写转换算法,返回生成 ch 的小写和大写对应字符的迭代器:

let mut upper = 's'.to_uppercase();
assert_eq!(upper.next(), Some('S'));
assert_eq!(upper.next(), None);

  这两个方法会返回迭代器而不是单个字符,因为 Unicode 中的大小写转换并不总是一对一的过程:

// 德文字母"ß"的大写形式是"SS":

let mut upper = ‘ß’.to_uppercase(); assert_eq!(upper.next(), Some(’S’)); assert_eq!(upper.next(), Some(’S’)); assert_eq!(upper.next(), None);

// Unicode规定在将带点的土耳其大写字母'İ'变为小写时要转成'i'后跟一个 // `'\u{307}'`,把点组合到字母上,以便在随后转换回大写字母时保留这个点
let ch = 'İ'; // `'\u{130}'` let mut lower = ch.to_lowercase(); assert_eq!(lower.next(), Some('i')); assert_eq!(lower.next(), Some('\u{307}')); assert_eq!(lower.next(), None);

为便于使用,这些迭代器都实现了 std::fmt::Display 特型,因此可以将它们直接传给 println! 或 write! 宏。

17.2.4 与整数之间的转换

Rust 的 as 运算符会将 char 转换为任何整数类型,并抹掉高位:

assert_eq!('B' as u32, 66);
assert_eq!('饂' as u8, 66); // 截断高位 assert_eq!('二' as i8, -116); // 同上

as 运算符会将任何 u8 值转换为 char,并且 char 也实现了 From。但是,更宽的整数类型可以表示无效码点,因此对于那部分整数,必须使用 std::char::from_u32 进行转换,它会返回 Option

assert_eq!(char::from(66), 'B');
assert_eq!(std::char::from_u32(0x9942), Some('饂'));
assert_eq!(std::char::from_u32(0xd800), None); // 为UTF-16保留的码点

17.3 String 与 str

Rust 的 String 类型和 str 类型会保证自己只包含格式良好的 UTF-8。标准库通过限制你创建 String 值和 str 值的方式以及可以对它们执行的操作来确保这一点。这样,当引入这些值时一定是格式良好的,而且在使用中也是如此。它们所有的方法都会坚守这个保证:对它们的任何安全操作都不会引入格式错误的 UTF-8。这就简化了处理文本的代码。 Rust 可以将文本处理方法关联到 str 或 String 上,具体关联到哪个取决于该方法是需要可调整大小的缓冲区还是仅满足于就地使用文本。由于 String 可以解引用成 &str,因此在 str 上定义的每 个方法都可以直接在 String 上使用。本节会介绍这两种类型的方法,并按其功能粗略分组。 文本处理方法会按字节偏移量索引文本并以字节而不是字符为单位测量其长度。实际上,考虑到 Unicode 的性质,按字符索引并不像看起来那么有用,按字节偏移量索引反而更快且更简单。如果试图使用位于某个字符的 UTF-8 编码中间的字节偏移量,则该方法会发生 panic,因此不能通过这种方式引入格式错误的 UTF-8。 String 通过封装 Vec 实现,并可以确保向量中的内容永远是格式良好的 UTF-8。Rust 永远不会把 String 改成更复杂的表示形式,因此你可以假设 String 的性能表现始终会和 Vec 保持一致。 在后面的讲解里,所有用到的变量都具有表 17-3 中给出的类型。 表 17-3:后面的讲解里要用到的变量类型 变量 预设类型

string	String		

变量 预设类型 slice &str 或对某值(如 String 或 Rc)的解引用

ch	char

n usize,长度 i、j usize,字节偏移量 range 字节偏移量的 usize 范围,可以像 i..j 一样完全有界,也可以像 i..、..j 或 .. 一样部分有界 pattern 任何模式类型:char、String、&str、&[char] 或 FnMut(char)

-> bool

17.3.6 节会讲解模式类型。

17.3.1 创建字符串值

创建 String 值的常见方法有以下几种。 String::new()(新建)   返回一个新的空字符串。这时还没有在堆上分配缓冲区,但将来会按需分配。 String::with_capacity(n)(自带容量)   返回一个新的空字符串,其中预先分配了一个足以容纳至少 n 字节的缓冲区。如果事先知道要构建的字符串的长度,则此构造函数可以让你从一开始就正确设置缓冲区大小,而不是等构建字符串时再进行调整。如果字符串的长度超过 n 字节,则该字符串仍会根据需要增加其缓冲区。与向量一样,字符串也有 capacity 方法、reserve 方法和 shrink_to_fit 方法,但一般来说默认的分配逻辑就很好。 str_slice.to_string()(转字符串)   分配一个新的 String,其内容是 str_slice 的副本。本书一直在使用诸如 “literal text”.to_string() 之类的表达式来从字符串字面量生成 String。 iter.collect()(收集)   通过串联迭代器的各个条目构造出字符串,迭代器的条目可以是 char 值、&str 值或 String 值。例如,要从字符串中移除所有空格,可以这样写:

let spacey = "man hat tan";
let spaceless: String = spacey.chars().filter(|c| !c.is_whitespace()).collect();
assert_eq!(spaceless, "manhattan");

  以这种方式使用 collect 可以充分利用 String 对 std::iter::FromIterator 特型的实现。 slice.to_owned()(转自有)   将 slice 的副本作为新分配的 String 返回。str 类型无法实现 Clone:该特型需要在 &str 上进行 clone 以返回 str 值,但 str 是无固定大小类型。不过,&str 实现了 ToOwned,这能让实现者指定其自有(Owned)版本的等效类型。

17.3.2 简单探查下面这些方法可以从字符串切片中获取基本信息。

slice.len()(长度)   slice 的长度,以字节为单位。 slice.is_empty()(为空?)   如果 slice.len() == 0,就返回 True。 slice[range](范围内切片)   返回借用了 slice 给定部分的切片。有界的范围、部分有界的范围和无界的范围都可以。  例如:

let full = "bookkeeping";
assert_eq!(&full[..4], "book");
assert_eq!(&full[5..], "eeping");
assert_eq!(&full[2..4], "ok");
assert_eq!(full[..].len(), 11);
assert_eq!(full[5..].contains("boo"), false);

  请注意,不能索引具有单个位置的字符串切片,比如 slice[i]。要想在给定的字节偏移处获取单个字符有点儿笨拙:必须在切片上生成一个 chars 迭代器,并要求它解析成单个字符的 UTF- 8: let parenthesized = “Rust (饂)”; assert_eq!(parenthesized[6..].chars().next(), Some(‘饂’));   不过,你很少需要这样做。Rust 有更好的方法来迭代切片, 17.3.8 节会对此进行讲解。 slice.split_at(i)(拆分于)   返回从 slice 借来的两个共享切片的元组:一个是字节偏移量 i 之前的部分,另一个是字节偏移量 i 之后的部分。换句话说,这会返回 (slice[..i], slice[i..])。 slice.is_char_boundary(i)(是字符边界?)   如果字节偏移量 i 恰好落在字符边界之间并且适合作为 slice 的偏移量,就返回 True。 自然,也可以对切片做相等性比较、排序和哈希。有序比较只是将字符串视为一系列 Unicode 码点,并按字典顺序进行比较。

17.3.3 追加文本与插入文本

以下方法会将文本添加到 String 中。 string.push(ch)(压入)   将字符 ch 追加到 string 的末尾。 string.push_str(slice)(压入字符串)  追加 slice 的全部内容。 string.extend(iter)(以 iter 扩展)   将迭代器 iter 生成的条目追加到字符串中。迭代器可以生成 char 值、str 值或 String 值。这是 String 对 std::iter::Extend 特型的实现。

let mut also_spaceless = "con".to_string();
also_spaceless.extend("tri but ion".split_whitespace());
assert_eq!(also_spaceless, "contribution");

string.insert(i, ch)(插入于)   在 string 内的字节偏移量 i 处插入单个字符 ch。这需要平移 i 之后的所有字符以便为 ch 腾出空间,因此用这种方式构建字符串的时间复杂度是 O(n)2。

2

不考虑重新分配内存的情况。——译者注 string.insert_str(i, slice)(插入字符串于)  这会在 string 内插入 slice,但同样需要注意性能问题。 String 实现了 std::fmt::Write,这意味着 write! 宏和 writeln! 宏可以将格式化后的文本追加到 String 上:

use std::fmt::Write; 
 
let mut letter = String::new(); writeln!(letter, "Whose {} these are I think I know", 
"rutabagas")?; 
writeln!(letter, "His house is in the village though;")?; assert_eq!(letter, "Whose rutabagas these are I think I know\n\ 
                    His house is in the village though;\n");

由于 write! 和 writeln! 是专为写入输出流而设计的,因此它们会返回一个 Result,如果你忽略 Result,则 Rust 会报错。上述代码使用了 ? 运算符来处理错误,但实际上写入 String 是肯定不会出错的,因此这种情况下也可以调用 .unwrap()。 因为 String 实现了 Add<&str> 和 AddAssign<&str>,所以你可以编写如下代码:

let left = "partners".to_string();
let mut right = "crime".to_string();
assert_eq!(left + " in " + &right, "partners in crime");

right += " doesn't pay";
assert_eq!(right, "crime doesn't pay");

当应用于字符串时,+ 运算符会按值获取其左操作数,所以实际上它可以重用该 String 的缓冲区作为加法的结果。因此,如果左操作数的缓冲区足够容纳结果,那么就不需要分配内存。 遗憾的是,此运算不是对称的,+ 的左操作数不能是 &str,所以不能写成:

let parenthetical = "(" + string + ")";

只能改成:

let parenthetical = "(".to_string() + &string + ")";

不过,此限制确实妨碍了从末尾向开头反向构建字符串的方式。这种方式性能不佳,因为必须反复把文本平移到缓冲区的末尾。 然而,通过向末尾追加小片段的方式从头到尾构建字符串是高效的。 String 的行为方式与向量是一样的,当它需要更多容量时,总是至少将其缓冲区大小加倍。这就令再次复制的开销与字符串的 终大小成正比。不过,使用 String::with_capacity 创建具有正确缓冲区大小的字符串可以完全避免调整大小,并且可以减少对堆分配器的调用次数。

17.3.4 移除文本与替换文本

String 有以下几个移除文本的方法。(这些方法不会影响字符串的容量,如果需要释放内存,请使用 shrink_to_fit。) string.clear()(清空)   将 string 重置为空字符串。 string.truncate(n)(截断为 n 个)   丢弃字节偏移量 n 之后的所有字符,留下长度 多为 n 的 string。如果 string 短于 n 字节,则毫无效果。 string.pop()(弹出)   从 string 中移除 后一个字符(如果有的话),并将其作为 Option 返回。 string.remove(i)(移除)   从 string 中移除字节偏移量 i 处的字符并返回该字符,将后面的所有字符平移到前面。这个操作所花费的时间与后续字符的数量呈线性关系。 string.drain(range)(抽取)   返回给定字节索引范围内的迭代器,并在迭代器被丢弃后移除字符。范围之后的所有字符都会向前平移:

let mut choco = "chocolate".to_string();
assert_eq!(choco.drain(3..6).collect::<String>(), "col");
assert_eq!(choco, "choate");

  如果只是想移除这个范围,则可以立即丢弃此迭代器,而不从中提取任何条目。

let mut winston = "Churchill".to_string();
winston.drain(2..6);
assert_eq!(winston, "Chill");

string.replace_range(range, replacement)(替换范围)   用给定的替代字符串切片替换 string 中的给定范围。切片不必与要替换的范围长度相同,但除非要替换的范围已到达 string 的末尾,否则将需要移动范围末尾之后的所有字节。

let mut beverage = "a piña colada".to_string();
beverage.replace_range(2..7, "kahlua"); // 'ñ' 是两字节的! assert_eq!(beverage, "a kahlua colada");

17.3.5 搜索与迭代的约定

Rust 用于搜索文本和迭代文本的标准库函数遵循了一些命名约定,以便于记忆。

r

  大多数操作会从头到尾处理文本,但名称以 r 开头的操作会从尾到头处理。例如,rsplit 是 split 的从尾到头版本。在某些情况下,改变处理方向不仅会影响值生成的顺序,还会影响值本身。具体示例请参见图 17-3。

n

  名称以 n 结尾的迭代器会将自己限定为只取给定数量的匹配项。

3
_indices

3index 的复数形式。——译者注   名称以 _indices 结尾的迭代器会生成通常的迭代值和在此 slice 中的字节偏移量组成的值对。 标准库并不会提供每个操作的所有组合。例如,许多操作并不需要 n 变体,因为很容易简单地提前结束迭代。 17.3.6 搜索文本的模式 当标准库函数需要搜索、匹配、拆分或修剪文本时,它能接受如下几种类型来表示要查找的内容:

let haystack = "One fine day, in the middle of the night";

assert_eq!(haystack.find(','), Some(12));
assert_eq!(haystack.find("night"), Some(35));
assert_eq!(haystack.find(char::is_whitespace), Some(3));

这些类型称为模式,大多数操作支持它们。

assert_eq!(
    "## Elephants".trim_start_matches(|ch: char| ch == '#' || ch.is_whitespace()),
    "Elephants"
);

标准库支持 4 种主要的模式。 以 char 作为模式意味着要匹配该字符。 以 String、&str 或 &&str 作为模式,意味着要匹配等于该模式的子串。 以 FnMut(char) -> bool 闭包作为模式,意味着要匹配该闭包返回 true 的单个字符。 以 &[char](注意并不是 &str,而是 char 的切片)作为模式,意味着要匹配该列表中出现的任何单个字符。请注意,如果将此列表写成数组字面量,那么可能要调用 as_ref() 来获得正确的类型。

let code = "\t    function noodle() { ";
assert_eq!(
    code.trim_start_matches([' ', '\t'].as_ref()),
    "function noodle() { "
);
// 更短的等效形式:&[' ', '\t'][..]4

4从 Rust 1.51.0 开始,通常可以使用更简短的形式,即 & [’ ‘, ‘\t’]。——译者注 如果不这么做,则 Rust 会误以为这是固定大小数组类型 & [char; 2]。遗憾的是,&[char; 2] 不是有效的模式类型。 在标准库本身的代码中,模式就是实现了 std::str::Pattern 特型的任意类型。Pattern 的细节还不稳定,所以你不能在稳定版的 Rust 中为自己的类型实现它。但是,将来要支持正则表达式和其他复杂模式也很容易。Rust 可以保证现在支持的模式类型将来仍会继续有效。

17.3.7 搜索与替换

Rust 提供了一些可以在切片中搜索某些模式并可能将其替换成新文本的方法。 slice.contains(pattern)(包含)   如果 slice 包含 pattern 的匹配项,就返回 true。 slice.starts_with(pattern)(以 pattern 开头)和 slice.ends_with(pattern)(以 pattern 结尾)   如果 slice 的起始文本或结尾文本与 pattern 相匹配,就返回 true。

assert!("2017".starts_with(char::is_numeric));

slice.find(pattern)(查找)和 slice.rfind(pattern)(右起查找)   如果 slice 包含 pattern 的匹配项,就返回 Some(i),其中的 i 是模式出现的字节偏移量。find 方法会返回第一个匹配项, rfind 方法则返回 后一个。

let quip = "We also know there are known unknowns";
assert_eq!(quip.find("know"), Some(8));
assert_eq!(quip.rfind("know"), Some(31));
assert_eq!(quip.find("ya know"), None);
assert_eq!(quip.rfind(char::is_uppercase), Some(0));

slice.replace(pattern, replacement)(替换)   返回新的 String,它是通过用 replacement 急性5替换 pattern 的所有匹配项而形成的: 5急性(eagerly)是惰性(lazily)的反义词。——译者注

assert_eq!(
    "The only thing we have to fear is fear itself".replace("fear", "spin"),
    "The only thing we have to spin is spin itself"
);

assert_eq!(
    "`Borrow` and `BorrowMut`".replace(|ch: char| !ch.is_alphanumeric(), ""),
    "BorrowandBorrowMut"
);

  因为替换是急性完成的,所以 .replace() 在彼此重叠的几个匹配段上的行为可能令人惊讶。这里有 4 个匹配 “aba” 模式的实例,但在替换了第一个和第三个之后,第二个和第四个就不再匹配了。

assert_eq!("cabababababbage".replace("aba", "***"), "c***b***babbage")

slice.replacen(pattern, replacement, n)(替换 n 次)   与上一个方法类似,但 多替换前 n 个匹配项。

17.3.8 遍历文本

标准库提供了几种对切片的文本进行迭代的方法。图 17-3 展示了一些示例。

image.png

图 17-3:迭代切片的一些方法 split(拆分)和 match(匹配)系列方法是互补的:拆分取的是匹配项之间的范围。 这些方法中大多数会返回可逆的迭代器(也就是说,它们实现了 DoubleEndedIterator):调用它们的 .rev() 适配器方法会为你提供一个迭代器,该迭代器会生成相同的条目,只是顺序相反。 slice.chars()(字符迭代器)   返回访问 slice 中各个字符的迭代器。 slice.char_indices()(字符及其偏移量迭代器)   返回访问 slice 中各个字符及其字节偏移量的迭代器:

assert_eq!(
    "élan".char_indices().collect::<Vec<_>>(),
    vec![
        (0, 'é'), // 有一个双字节UTF-8编码
        (2, 'l'),
        (3, 'a'),
        (4, 'n')
    ]
);

  请注意,这并不等同于 .chars().enumerate(),因为本方法提供的是每个字符在切片中的字节偏移量,而不仅仅是字符的序号。 slice.bytes()(字节迭代器)   返回访问 slice 中各字节的迭代器,对外暴露 UTF-8 编码细节。 asserteq!(“élan”.bytes().collect::>>(), vec![195, 169, b’l’, b’a’, b’n’]); slice.lines()(文本行迭代器)   返回访问 slice 中各行的迭代器。各行以 “\n” 或 “\r\n” 结尾。生成的每个条目都是从 slice 中借入的 &str。这些条目不包括行的终止字符。 slice.split(pattern)(拆分)   返回一个迭代器,该迭代器会迭代 slice 中由 pattern 匹配项分隔开的各个部分。这会在紧邻的两个匹配项之间、位于 slice 开头的匹配项与头部之间,以及结尾的匹配项与尾部之间生成空字符串。   如果 pattern 是 &str,则返回的迭代器不可逆,因为这类模式会根据不同的扫描方向生成不同的匹配序列,但可逆迭代器不允许这种行为。可以改用接下来要讲的 rsplit 方法。 slice.rsplit(pattern)(右起拆分)   与上一个方法类似,但此方法会从尾到头扫描 slice,并按该顺序生成匹配项。 slice.split_terminator(pattern)(终结符拆分)和 slice.rsplit_terminator(pattern)(右起终结符拆分)   与刚刚讲过的拆分方法类似,但这两个方法会把模式视为终结 符,而不是分隔符:如果 pattern 在 slice 的末尾匹配上了,则 迭代器不会像 split 和 rsplit 那样生成表示匹配项和切片末尾之间空字符串的空切片。例如:

// 这里把':'字符视为分隔符。注意结尾的""(空串) 
assert_eq!("jimb:1000:Jim Blandy:".split(':').collect::<Vec<_>>(),            vec!["jimb", "1000", "Jim Blandy", ""]); 
 
// 这里把'\n'字符视为终结符 
assert_eq!("127.0.0.1  localhost\n\ 
            127.0.0.1  www.reddit.com\n" 
           .split_terminator('\n').collect::<Vec<_>>(),            vec!["127.0.0.1  localhost", 
                "127.0.0.1  www.reddit.com"]); 
                // 注意,没有结尾的""!

slice.splitn(n, pattern)(拆分为 n 片)和 slice.rsplitn(n, pattern)(右起拆分为 n 片)  与 split 和 rsplit 类似,但这两个方法会把字符串分成 多 n 个切片,拆分位置位于 pattern 的第 n-1 个(split)或倒数第 n-1 个(rsplit)匹配项处。 slice.split_whitespace()(按空白字符拆分)和 slice.split_ascii_whitespace()(按 ASCII 空白字符拆 分)   返回访问 slice 中以空白字符分隔的各部分的迭代器。这两个方法会把连续多个空白字符视为单个分隔符。忽略尾部空白字符。   split_whitespace 方法会使用 Unicode 的空白字符定义,由 char 上的 is_whitespace 方法实现。 split_ascii_whitespace 方法则会使用只识别 ASCII 空白字符的 char::is_ascii_whitespace。

let poem = "This  is  just  to say\n\             I have eaten\n\             the plums\n\             again\n"; 
 
assert_eq!(poem.split_whitespace().collect::<Vec<_>>(),            vec!["This", "is", "just", "to", "say", 
                "I", "have", "eaten", "the", "plums",                 "again"]);

slice.matches(pattern)(匹配项)   返回访问 slice 中 pattern 匹配项的迭代器。 slice.rmatches(pattern) 也一样,但会从尾到头迭代。 slice.match_indices(pattern)(匹配项及其偏移量)和 slice.rmatch_indices(pattern)(右起匹配项及其偏移量)   和上一个方法很像,但这两个方法生成的条目是 (offset, match) 值对,其中 offset 是匹配的起始字节的偏移量,而 match 是匹配到的切片。

17.3.9 修剪

修剪字符串就是从字符串的开头或结尾移除文本(通常是空白字符)。修剪常用于清理从文件中读取的输入,在此文件中,用户可能为了易读性而添加了文本缩进,或者不小心在一行中留下了尾随空白字符。 slice.trim()(修剪)   返回略去了任何前导空白字符和尾随空白字符的 slice 的子切片。slice.trim_start() 只会略去前导空白字符, slice.trim_end() 只会略去尾随空白字符。

assert_eq!("\t*.rs  ".trim(), "*.rs");
assert_eq!("\t*.rs  ".trim_start(), "*.rs  ");
assert_eq!("\t*.rs  ".trim_end(), "\t*.rs");

slice.trim_matches(pattern)(按匹配修剪)   返回 slice 的子切片,该子切片从开头和结尾略去了 pattern 的所有匹配项。trim_start_matches 方法和 trim_end_matches 方法只会对匹配的前导内容或尾随内容执行修剪操作。

assert_eq!("001990".trim_start_matches('0'), "1990");

slice.strip_prefix(pattern)(剥离前缀)和 slice.strip_suffix(pattern)(剥离后缀)   如果 slice 以 pattern 开头,则 strip_prefix 会返回一个 Some,其中携带了移除匹配文本之后的切片。否则,它会返回 None。strip_suffix 方法与此类似,但会检查字符串末尾的匹配项。   与 trim_start_matches 和 trim_end_matches 类似,但这里的两个方法会返回 Option,并且只会移除一个匹配 pattern 的副本。

let slice = "banana";
assert_eq!(slice.strip_suffix("na"), Some("bana"))

17.3.10 字符串的大小写转换

slice.to_uppercase() 方法和 slice.to_lowercase() 方法会返回一个新分配的字符串,其中包含已转为大写或小写的 slice 文本。结果的长度可能与 slice 不同,有关详细信息,请参阅

17.2.3 节。

17.3.11 从字符串中解析出其他类型

Rust 为“从字符串解析出值”和“生成值的文本表示”提供了一些标准特型。 如果一个类型实现了 std::str::FromStr 特型,那它就提供了一种从字符串切片中解析出值的标准方法:

pub trait FromStr: Sized {
    type Err;
    fn from_str(s: &str) -> Result<Self, Self::Err>;
}

所有常见的机器类型都实现了 FromStr:

use std::str::FromStr;

assert_eq!(usize::from_str("3628800"), Ok(3628800));
assert_eq!(f64::from_str("128.5625"), Ok(128.5625));
assert_eq!(bool::from_str("true"), Ok(true));

assert!(f64::from_str("not a float at all").is_err());
assert!(bool::from_str("TRUE").is_err());

char 类型也实现了 FromStr,用于解析只有一个字符的字符串: assert_eq!(char::from_str(“é”), Ok(‘é’)); assert!(char::from_str(“abcdefg”).is_err()); std::net::IpAddr 类型,即包含 IPv4 或 IPv6 互联网地址的 enum,同样实现了 FromStr:

use std::net::IpAddr;

let address = IpAddr::from_str("fe80::0000:3ea9:f4ff:fe34:7a50")?;
assert_eq!(
    address,
    IpAddr::from([0xfe80, 0, 0, 0, 0x3ea9, 0xf4ff, 0xfe34, 0x7a50])
);

字符串切片有一个 parse 方法,该方法可以将切片解析为你想要的任何类型——只要它实现了 FromStr。与 Iterator::collect 一样,有时需要明确写出想要的类型,因此用 parse 不一定比直接调用 from_str 可读性强。

let address = "fe80::0000:3ea9:f4ff:fe34:7a50".parse::<IpAddr>()?;

17.3.12 将其他类型转换为字符串将非文本值转换为字符串的方法主要有以下 3 种。

那些具有人类可读的自然打印形式的类型可以实现 std::fmt::Display 特型,该特型允许在 format! 宏的格式中使用 {} 格式说明符:

assert_eq!(format!("{}, wow", "doge"), "doge, wow");
assert_eq!(format!("{}", true), "true");
assert_eq!(
    format!("({:.3}, {:.3})", 0.5, f64::sqrt(3.0) / 2.0),
    "(0.500, 0.866)"
);

// 使用上一个例子中的`address`
let formatted_addr: String = format!("{}", address);
assert_eq!(formatted_addr, "fe80::3ea9:f4ff:fe34:7a50");

Rust 的所有机器数值类型都实现了 Display,字符、字符串和切片也是如此。智能指针类型 Box、Rc 和 Arc 也 实现了 Display(只要 T 本身实现了 Display):它们的显示形式就只是其引用目标的显示形式而已。而像 Vec 和 HashMap 这样的容器则没有实现 Display,因为这些类型没有人类可读的单一自然形式。
如果一个类型实现了 Display,那么标准库就会自动为它实现 std::str::ToString 特型,当你不需要 format! 的灵活性时,使用此特型的唯一方法 to_string 会更方便:

// 接续前面的例子
assert_eq!(address.to_string(), "fe80::3ea9:f4ff:fe34:7a50");

Rust 在引入 Display 之前就已经引入 ToString 特型了,但该特型不太灵活。对于自己的类型,你通常应该实现 Display 而非 ToString。

标准库中的每个公共类型都实现了 std::fmt::Debug,这个特型会接受一个值并将其格式化为对程序员有用的字符串。用 Debug 生成字符串的 简单方法是使用 format! 宏的 {:?} 格式说明符:

// 接续前面的例子
let addresses = vec![address, IpAddr::from_str("192.168.0.1")?];
assert_eq!(
    format!("{:?}", addresses),
    "[fe80::3ea9:f4ff:fe34:7a50, 192.168.0.1]"
);

对于本身实现了 Debug 的任何类型 T,这里利用了 Vec 对 Debug 的通用实现。Rust 的所有集合类型都有这样的实现。你也应该为自己的类型实现 Debug。通常, 好让 Rust 派生一个实现,就像我们在第 12 章中对 Complex 类型所做的那样: #[derive(Copy, Clone, Debug)]

struct Complex {
    re: f64,
    im: f64,
}

format! 及其相关宏在把值格式化为文本时用到了很多格式化特型, Display 和 Debug 只是其中的两个。17.4 节会介绍其他特型,并解释如何实现它们。

17.3.13 借用其他类似文本的类型

可以通过以下两种方式借用切片的内容。 切片和 String 都实现了 AsRef、AsRef<[u8]>、 AsRef 和 AsRef。许多标准库函数会使用这些特型作为参数类型的限界,因此可以直接将切片和字符串传给它们,即便它们真正想要的是其他类型。有关详细解释,请参阅 13.7 节。 切片和字符串还实现了 std::borrow::Borrow 特型。 HashMap 和 BTreeMap 会借助 Borrow 令 String 很好地用作表中的键。有关详细信息,请参阅 13 .8 节。

17.3.14 以 UTF-8 格式访问文本

获取表示文本的那些字节有两个主要方法,具体取决于你是想获取字节的所有权还是只想借用它们。 slice.as_bytes()(用作字节切片)   把 slice 的字节借入为 &[u8]。由于这不是可变引用,因此 slice 可以假定其字节将保持为格式良好的 UTF-8。 string.into_bytes()(转为字节切片)   获取 string 的所有权并按值返回字符串字节的 Vec。这是一个开销极低的转换,因为它只是移动了字符串一直用作缓冲区的 Vec。由于 string 已经不复存在,因此这些字节无须继续保持为格式良好的 UTF-8,而调用者可以随意修改 Vec。 17.3.15 从 UTF-8 数据生成文本 如果你有一个包含 UTF-8 数据的字节块,那么有几个方法可以将其转换为 String 或切片,但具体用哪个取决于你希望如何处理错误。 str::from_utf8(byte_slice)(来自 utf8 切片)   接受 &[u8] 字节切片并返回 Result:如果 byte_slice 包含格式良好的 UTF-8,就返回 Ok(&str),否则,返回错误。 String::from_utf8(vec)(来自 utf8 向量)   尝试从按值传递的 Vec 中构造字符串。如果 vec 持有格 式良好的 UTF-8,那么 from_utf8 就会返回 Ok(string),其中 string 会取得 vec 的所有权并将其用作缓冲区。此过程不会发生堆分配或文本复制。   如果这些字节不是有效的 UTF-8,则返回 Err(e),其中 e 是 FromUtf8Error 型的错误值。调用 e.into_bytes() 会返回原始向量 vec,因此当转换失败时它并不会丢失:

let good_utf8: Vec<u8> = vec![0xe9, 0x8c, 0x86]; assert_eq!(String::from_utf8(good_utf8).ok(), 

Some(“錆”.to_string()));

let bad_utf8: Vec<u8> = vec![0x9f, 0xf0, 0xa6, 0x80];
let result = String::from_utf8(bad_utf8);
assert!(result.is_err());
// 由于String::from_utf8失败了,因此它不会消耗原始向量,
// 而是通过错误值把原始向量原原本本地还给了我们
assert_eq!(
    result.unwrap_err().into_bytes(),
    vec![0x9f, 0xf0, 0xa6, 0x80]
);

String::from_utf8_lossy(byte_slice)(来自 utf8, 宽松版)   尝试从 &[u8] 共享字节切片构造一个 String 或 &str。此转换总会成功,任何格式错误的 UTF-8 都会被 Unicode 代用字符替换。返回值是一个 Cow,如果它包含格式良好的 UTF-8,就会直接从 byte_slice 借用 &str,否则会拥有一个新分配的 String,其中格式错误的字节会被代用字符替换。因此,当 byte_slice 是格式良好的 UTF-8 时,不会发生堆分配或复制。

17.3.16 节会更详细地讨论 Cow

String::from_utf8_unchecked(vec)(来自 utf8,不检 查版)   如果你确信此 Vec 包含格式良好的 UTF-8,那就可以调用这个不安全的函数。此方法只是将 vec 包装为一个 String 并返回它,根本不检查字节。你有责任确保没有将格式错误的 UTF-8 引入系统,这就是此函数被标记为 unsafe 的原因。 str::from_utf8_unchecked(byte_slice)(来自 utf8,不检查版)   与上一个方法类似,但此方法会接受 &[u8] 并将其作为 &str 返回,而不检查它是否包含格式良好的 UTF-8。与 String::from_utf8_unchecked 一样,你有责任确保 byte_slice 是安全的。

17.3.16 推迟分配假设你想让程序向用户打招呼。在 Unix 上,可以这样写:

fn get_name() -> String {
    std::env::var("USER") // 在Windows上要改成"USERNAME"
        .unwrap_or("whoever you are".to_string())
}
println!("Greetings, {}!", get_name());

对于 Unix 用户,这个程序会根据用户名向他们问好。对于 Windows 用户和无名用户,它提供了备用文本。 std::env::var 函数会返回一个 String——并且有充分的理由这样做,所以我们不会在这里讨论。但这意味着备用文本也必须作为 String 返回。这不太理想:当 get_name 返回静态字符串时,根本没必要分配内存。 问题的关键在于,get_name 的返回值有时应该是拥有型 String, 有时则应该是 &‘static str,并且在运行程序之前我们无法知道会是哪一个。这种动态的特点预示着应该考虑使用 std::borrow::Cow,这个写入时克隆类型既可以持有拥有型数据也可以持有借入的数据。 正如 13.12 节所述,Cow<‘a, T> 是一个具有 Owned 和 Borrowed 两个变体的枚举。Borrowed 持有一个引用 &‘a T,而 Owned 持有 &T 的拥有型版本:对于 &str 是 String,对于 & [i32] 是 Vec,等等。无论是 Owned 还是 Borrowed, Cow<‘a, T> 总能生成一个 &T 供你使用。事实上,Cow<‘a, T> 可以解引用为 &T,其行为类似于一种智能指针。更改 get_name 以返回 Cow,结果如下所示:

use std::borrow::Cow;

fn get_name() -> Cow<'static, str> {
    std::env::var("USER")
        .map(|v| Cow::Owned(v))
        .unwrap_or(Cow::Borrowed("whoever you are"))
}

如果读取 “USER” 环境变量成功,那么 map 就会将结果 String 作为 Cow::Owned 返回。如果失败,则 unwrap_or 会将其静态 &str 作为 Cow::Borrowed 返回。调用者可以保持不变:

println!("Greetings, {}!", get_name());

只要 T 实现了 std::fmt::Display 特型,显示 Cow<‘a, T> 的结果就和显示 T 的结果是一样的。 当你可能需要也可能不需要修改借用的某些文本时,Cow 也很有用。不需要修改时,可以继续借用。但是 Cow 名副其实的写入时克隆行为 可以根据需要为你提供一个拥有型的、可变的值副本。Cow 的 to_mut 方法会确保 Cow 是 Cow::Owned,必要时会应用该值的 ToOwned 实现,然后返回对该值的可变引用。 因此,如果你发现某些用户(但不是全部)拥有他们更想使用的头衔,就可以这样写:

fn get_title() -> Option<&'static str> {}

let mut name = get_name();
if let Some(title) = get_title() {
    name.to_mut().push_str(", ");
    name.to_mut().push_str(title);
}
println!("Greetings, {}!", name);

这可能会生成如下输出:

$ cargo run 
Greetings, jimb, Esq.! 
$

这样做的好处是,如果 get_name() 返回一个静态字符串并且 get_title 返回 None,那么 Cow 只是将静态字符串透传到 println!。你已经设法把内存分配推迟到了确有必要的时候,并且代码仍然一目了然。 由于 Cow 经常用于字符串,因此标准库对 Cow<‘a, str> 有一些 特殊支持。它提供了来自 String 和 &str 的 From 和 Into 这两个转换特型,这样就可以更简洁地编写 get_name 了:

fn get_name() -> Cow<'static, str> {
    std::env::var("USER")
        .map(|v| v.into())
        .unwrap_or("whoever you are".into())
}

Cow<‘a, str> 还实现了 std::ops::Add 和 std::ops::AddAssign,因此要将标题添加到名称中,可以这样写:

if let Some(title) = get_title() {
    name += ", ";
    name += title;
}

或者,因为 String 可以作为 write! 宏的目标,所以也可以这样写:

use std::fmt::Write;

if let Some(title) = get_title() {
    write!(name.to_mut(), ", {}", title).unwrap();
}

和以前一样,在尝试修改 Cow 之前不会发生内存分配。 请记住,并非每个 Cow<…, str> 都必须是 ‘static:可以使用 Cow 借用以前计算好的文本,直到需要复制为止。

17.3.17 把字符串当作泛型集合

String 同时实现了 std::default::Default 和 std::iter::Extend:default 返回空字符串,而 extend 可以把字符、字符串切片、Cow<…, str> 或字符串追加到一个字符串尾部。这与 Rust 的其他集合类型(如 Vec 和 HashMap)为其泛型构造模式(如 collect 和 partition)实现的特型组合是一样的。 &str 类型也实现了 Default,返回一个空切片。这在某些极端情况下很方便,比如,这样可以让包含字符串切片的结构派生于 Default (#[derive(Default))。

17.4 格式化各种值

本书一直在使用像 println! 这样的文本格式化宏: println!(“{:.3}μs: relocated {} at {:#x} to {:#x}, {} bytes”,

         0.84391, "object", 
         140737488346304_usize, 6299664_usize, 64);

上述调用会生成如下输出: 0.844μs: relocated object at 0x7fffffffdcc0 to 0x602010, 64 bytes 字符串字面量可以用作输出模板:模板中的每个 {…} 都会被其后跟随的某个参数的格式化形式替换。模板字符串必须是常量,以便 Rust 在编译期根据参数的类型检查它。每个参数在检查时必须都用到,否则 Rust 就会报告编译期错误。以下几个标准库特性中都有这种用于格式化字符串的小型语言。 format! 宏会用它来构建 String。 println! 宏和 print! 宏会将格式化后的文本写入标准输出流。 writeln! 宏和 write! 宏会将格式化后的文本写入指定的输出流。 panic! 宏会使用它构建一个信息丰富的异常终止描述。 Rust 格式化工具的设计是开放式的。你可以通过实现 std::fmt 模块的格式化特型来扩展这些宏以支持自己的类型。也可以使用 format_args! 宏和 std::fmt::Arguments 类型来让自己的函数和宏支持格式化语言。 格式化宏总会借入对其参数的共享引用,但永远不会拥有或修改它们。 模板的 {…} 形式称为格式参数,具体形式为 {which:how}。 Which 和 how 都是可选的,很多时候用 {} 就行。 which(哪个)值用于选择模板后面的哪个实参应该取代该形参的位置。可以按索引或名称选择实参。没有 which 值的形参只会简单地从左到右与实参配对。 how(如何)值表示应如何格式化参数:如何填补、精度如何、数值基数等。如果存在 how,则需要写上前面的冒号。 表 17-4 给出了一些示例。 表 17-4:格式化字符串示例 模板字符串 参数列表 结果

"number of {}:
{}"	"elephants", 19	"number of elephants: 19"
"from {1} to {0}"	"the grave", "the cradle"	"from the cradle to the grave"
"v = {:?}"	vec![0,1,2,5,12,29]	"v = [0, 1, 2, 5,
12, 29]"
"name = {:?}"	"Nemo"	"name = \"Nemo\""
"{:8.2} km/s"	11.186	"   11.19 km/s"
"{:20} {:02x}
{:02x}"	"adc #42", 105, 42	"adc

#42 69

2a"

模板字符串 参数列表 结果

"{1:02x} {2:02x}
{0}"	"adc #42", 105, 42	"69 2a adc #42"
"{lsb:02x}
{msb:02x} {insn}"	insn="adc #42", lsb=105, msb=42	"69 2a adc #42"
"{:02?}"	[110, 11, 9]	"[110, 11, 09]"
"{:02x?}"	[110, 11, 9]	"[6e, 0b, 09]"

如果要在输出中包含 { 或 } 字符,可将模板中的这些字符连写两个。

assert_eq!(format!("{{a, c}}  {{a, b, c}}"), "{a, c}  {a, b, c}");

17.4.1 格式化文本值

当格式化像 &str 或 String(将 char 视为单字符字符串)这样的文本类型时,参数的 how 值有几个部分,都是可选的。 文本长度限制。如果参数比这个值长,Rust 就会截断它。如果未指定限制,Rust 就使用全文。 最小字段宽度。在完成所有截断之后,如果参数比这个值短, Rust 就会在右边(默认)用空格(默认)填补它以让字段达到这个宽度。如果省略,Rust 则不会填补参数。 对齐方式。如果参数需要填补空白以满足 小字段宽度,那么这个值表示应将文本放置在字段中的什么位置。<、^ 和 > 分别会将文本放在开头、中间和结尾。 在此填补过程中使用的填补字符。如果省略,Rust 就会使用空格。如果指定了填补字符,则必须同时指定对齐方式。 表 17-5 举例说明了如何编写这些格式字符串及其实际效果。所有这些示例都使用了相同的八字符参数 “bookends”。 表 17-5:文本的格式化字符串指令 使用的特性 模板字符串 结果 默认 “{}” “bookends” 小字段宽度 “{:4}”

"{:12}"	"bookends"
"bookends    "

文本长度限制 “{:.4}”

"{:.12}"	"book"
"bookends"

字段宽度、长度限制 “{:12.20}”

"{:4.20}"
"{:4.6}"
"{:6.4}"	"bookends    " 
"bookends"
"booken"
"book  "

左对齐,宽度 “{:<12}" "bookends " 居中,宽度 "{:^12}" " bookends " 右对齐,宽度 "{:>12}” “ bookends” 用 ‘=’ 填补,居中,宽度 “{:=^12}” “==bookends==”

使用的特性 模板字符串 结果 用 ‘’ 填补,右对齐,宽度,限制 “{:>12.4}” “********book” Rust 的格式化程序对宽度的处理方式比较“简陋”:它假设每个字符 占据一列,而不会考虑组合字符、半角片假名、零宽度空格或 Unicode 的其他乱七八糟的情况。例如:

assert_eq!(format!("{:4}", "th\u{e9}"), "th\u{e9} ");
assert_eq!(format!("{:4}", "the\u{301}"), "the\u{301}");

尽管 Unicode 规定这两个字符串都等效于 “thé”,但 Rust 的格式化程序可不知道像 ‘\u{301}’ 这样的字符(组合重音符)需要做特殊处理。它正确地填补了第一个字符串,但假设第二个字符串是 4 列宽并且不需要填补。尽管很容易看出 Rust 该如何在这种特定情况下进行改进,但要支持所有 Unicode 脚本的真正多语言文本格式化是一项艰巨的任务。 好依靠所在平台的用户界面工具包来处理,或许也可以通过生成 HTML 和 CSS,让 Web 浏览器来处理。有一个流行的 crate(unicode-width)可以部分处理这个问题。 除了 &str 和 String,你也可以直接向格式化宏传入带有文本型引用目标的智能指针类型,比如 Rc 或 Cow<‘a, str>。 由于文件名路径不一定是格式良好的 UTF-8,因此 std::path::Path 不完全是文本类型,不能将 std::path::Path 直接传给格式化宏。不过,Path 有个 display 方法会返回一个供格式化的 UTF-8 值,以适合所在平台的方式解决问题。

println!("processing file: {}", path.display());

17.4.2 格式化数值

当格式化参数具有 usize 或 f64 之类的数值类型时,参数的 how 值可以有如下几个部分,它们全是可选的。 填补与对齐,它们和对文本类型的含义一样。 + 字符,要求始终显示数值的符号,即使相应参数是正数。

字符,要求加显式基数前缀,比如 0x 或 0b。参见稍后要讲的“进制符号”那一项。

0 字符,要求通过在数值中包含前导零(而不是通常的填补方式)来满足 小字段宽度。 最小字段宽度。如果格式化后的数值没有这么宽,那么 Rust 会在左侧(默认)用空格(默认)填补它以构成给定宽度的字段。 浮点参数的精度,指示 Rust 应在小数点后包含多少位数字。 Rust 会根据需要进行舍入或零扩展以生成要求的小数位。如果省略精度,那么 Rust 会尝试使用尽可能少的数字来准确表示该值。对于整数类型的参数,精度会被忽略。 进制符号。对于整数类型,二进制是 b,八进制是 o,十六进制是小写字母 x 或大写字母 X。如果包含 # 字符,则它们会包含显式的 Rust 风格的基数前缀 0b、0o、0x 或 0X。对于浮点类型,e 或 E 的基数需要科学记数法,具有归一化系数,使用 e 或 E 作为指数。如果不指定任何进制符号,则 Rust 会将数值格式化为十进制。 表 17-6 展示了格式化 i32 值 1234 的一些示例。 表 17-6:格式化整数的字符串指令 使用的特性 模板字符串 结果 默认 “{}” “1234” 强制正负号 “{:+}” “+1234”

使用的特性 模板字符串 结果 小字段宽度 “{:12}”

"{:2}"	"        1234" 
"1234"

正负号,宽度 “{:+12}” “ +1234” 前导零,宽度 “{:012}” “000000001234” 正负号,前导零,宽度 “{:+012}” “+00000001234” 左对齐,宽度 “{:<12}" "1234 " 居中,宽度 "{:^12}" " 1234 " 右对齐,宽度 "{:>12}” “ 1234” 左对齐,正负号,宽度 “{:<+12}” “+1234 ” 居中,正负号,宽度 “{:^+12}” “ +1234 ” 右对齐,正负号,宽度 “{:>+12}” “ +1234” 用 ‘=’ 填补,居中,宽度 “{:=^12}” “====1234====” 二进制表示法 “{:b}” “10011010010” 宽度,八进制表示法 “{:12o}” “ 2322”

使用的特性 模板字符串 结果 正负号,宽度,十六进制表示法 “{:+12x}” “ +4d2” 正负号,宽度,用大写数字的十六进制 “{:+12X}” “ +4D2” 正负号,显式基数前缀,宽度,十六进制 “{:+#12x}” “ +0x4d2” 正负号,基数,前导零,宽度,十六进制 “{:+#012x}”

"{:+#06x}"	"+0x0000004d2" 
"+0x4d2"

如 后两个例子所示, 小字段宽度适用于整个数值、正负号、基数前缀等。 负数总是包含它们的符号。结果和“强制正负号”例子中展示的一样。 当你要求加前导零时,就会忽略对齐和填补字符,因为要用零扩展数值以填补整个字段。 使用参数 1234.5678,可以展示对浮点类型的格式化效果,如表 17-7 所示。 表 17-7:格式化浮点数的字符串指令 使用的特性 模板字符串 结果 默认 “{}” “1234.5678” 精度 “{:.2}”

"{:.6}"	"1234.57"
"1234.567800"

使用的特性 模板字符串 结果 小字段宽度 “{:12}” “ 1234.5678” 小宽度,精度 “{:12.2}”

"{:12.6}"	"     1234.57" 
" 1234.567800"

前导零, 小宽度,精度 “{:012.6}” “01234.567800” 科学记数法 “{:e}” “1.2345678e3” 科学记数法,精度 “{:.3e}” “1.235e3” 科学记数法, 小宽度,精度 “{:12.3e}”

"{:12.3E}"	"     1.235e3" 
"     1.235E3"

17.4.3 格式化其他类型

除了字符串和数值,还可以格式化标准库中的其他几种类型。 错误类型全都可以直接格式化,从而很容易地将它们包含在错误消息中。每种错误类型都应该实现 std::error::Error 特型,该特型扩展了默认格式化特型 std::fmt::Display。因此,任何实现了 Error 的类型都可以格式化。 可以格式化 std::net::IpAddr、std::net::SocketAddr 等互联网协议地址类型。 布尔值 true 和 false 也可以被格式化,虽然它们通常不是直接呈现给 终用户的 佳格式。 对上述类型来说,应该使用与字符串相同类型的格式参数。长度限制、字段宽度和对齐方式控制都会如预期般工作。

17.4.4 格式化值以进行调试

为了帮助调试和记录日志,{:?} 参数能以对程序员有帮助的方式格式化 Rust 标准库中的任何公共类型。你可以使用它来检查向量、切片、元组、哈希表、线程和其他数百种类型。例如,你可以编写如下代码:

use std::collections::HashMap;
let mut map = HashMap::new();
map.insert("Portland", (45.5237606, -122.6819273));
map.insert("Shanghai", (31.230416, 121.473701));
println!("{:?}", map);

这会打印出如下内容:

{"Shanghai": (31.230416, 121.473701), "Portland": (45.5237606, -122.6819273)}

HashMap 和 (f64, f64) 类型都知道该如何格式化自身,你无须额外做什么。 如果你在格式参数中包含了 # 字符,Rust 就会优美地打印出该值。将上面那行代码改成 println!(“{:#?}”, map) 会输出如下内容:

{ 
    "Shanghai": ( 
        31.230416,         121.473701 
    ), 
    "Portland": ( 
        45.5237606, 
        -122.6819273 
    ) }

这些输出的精确格式并不能保证始终如一,比如升级 Rust 版本后就可能发生变化。供调试用的格式化通常会以十进制打印数值,但可以在问号前放置一个 x 或 X 以请求十六进制,并且会遵守前导零和字段宽度语法。例如,可以像下面这样写:

println!("ordinary: {:02?}", [9, 15, 240]);
println!("hex:      {:02x?}", [9, 15, 240]);

这会打印出如下内容:

ordinary: [09, 15, 240] hex:      [09, 0f, f0]

如前所述,你可以用 #[derive(Debug)] 语法让自己的类型支持 {:?}: #[derive(Copy, Clone, Debug)] struct Complex { re: f64, im: f64 } 有了这个定义,就可以使用 {:?} 格式来打印 Complex 值了:

let third = Complex {
    re: -0.5,
    im: f64::sqrt(0.75),
};
println!("{:?}", third);

这会打印出如下内容:

Complex {
    re: -0.5,
    im: 0.8660254037844386,
}

这对调试来说已经很好了,但如果能用 {} 以更传统的形式(如 -0.5 + 0.8660254037844386i)打印它们就更好了。17.4.8 会展示如何做到这一点。

17.4.5 格式化指针以进行调试

正常情况下,如果将任何种类的指针传给格式化宏(引用、Box 或 Rc),宏都会简单地追踪指针并格式化它的引用目标,指针本身并不重要。但是在调试时,查看指针有时很有帮助:地址可以用作单个值的粗略“名称”,这在检查含有循环或共享指针的结构体时可能很有帮助。 {:p} 表示法会将引用、Box 和其他类似指针的类型格式化为地址:

use std::rc::Rc;
let original = Rc::new("mazurka".to_string());
let cloned = original.clone();
let impostor = Rc::new("mazurka".to_string());
println!("text:     {}, {}, {}", original, cloned, impostor);
println!("pointers: {:p}, {:p}, {:p}", original, cloned, impostor);

这会打印出如下内容:

text:     mazurka, mazurka, mazurka 
pointers: 0x7f99af80e000, 0x7f99af80e000, 0x7f99af80e030

当然,具体的指针值每次运行时可能都不一样,但即便如此,比较这些地址也能清晰地看出前两个是对同一个 String 的引用,而第三个指向了不同的值。 地址确实看起来是可读性很差的十六进制,因此更精致的展现形式可能会更有用,但 {:p} 样式仍然是一种有效的快速解决方案。 17.4.6 按索引或名称引用参数格式参数可以明确选择它要使用的参数。例如:

assert_eq!(
    format!("{1},{0},{2}", "zeroth", "first", "second"),
    "first,zeroth,second"
);

可以在冒号后包含格式参数:

assert_eq!(
    format!("{2:#06x},{1:b},{0:=>10}", "first", 10, 100),
    "0x0064,1010,=====first"
);

还可以按名称选择参数。这能让有许多参数的复杂模板更加清晰易读。例如:

assert_eq!(
    format!(
        "{description:.<25}{quantity:2} @ {price:5.2}",
        price = 3.25,
        quantity = 3,
        description = "Maple Turmeric Latte"
    ),
    "Maple Turmeric Latte .. 3 @  3.25"
);

(这里的命名型参数类似于 Python 中的关键字参数,但它们只是这些格式化宏的独有特性,而不是 Rust 函数调用语法的一部分。) 可以在单个格式化宏中将索引型参数、命名型参数和位置型(没有索引或名称的)参数混用。位置型参数会从左到右与参数配对,就仿佛索引型参数和命名型参数不存在一样(不参与位置编号):

assert_eq!(
    format!(
        "{mode} {2} {} {}",
        "people",
        "eater",
        "purple",
        mode = "flying"
    ),
    "flying purple people eater"
);

命名型参数必须出现在列表的末尾。

17.4.7 动态宽度与动态精度

参数的 小字段宽度、文本长度限制和数值精度不必总是固定值,也可以在运行期进行选择。 我们一直在研究类似于下面这个表达式的情况,它会生成在 20 个字符宽的字段中右对齐的字符串 content:

format!("{:>20}", content)

但是,如果想在运行期选择字段宽度,则可以这样写:

format!("{:>1$}", content, get_width())

将 小字段宽度写成 1$ 就是在告诉 format! 使用第二个参数的值作为宽度。它引用的参数必须是 usize。还可以按名称引用参数:

format!("{:>width$}", content, width = get_width())

同样的方法也适用于文本长度限制:

format!(
    "{:>width$.limit$}",
    content,
    width = get_width(),
    limit = get_limit()
)

要代替文本长度限制或浮点精度,还可以写成 *,表示将下一个位置参数作为精度。下面的代码会把 content 裁剪成 多 get_limit() 个字符:

format!("{:.*}", get_limit(), content)

用作精度的参数必须是 usize。字段宽度没有对应的语法。 17.4.8 格式化自己的类型 格式化宏会使用 std::fmt 模块中定义的一组特型将值转换为文本。通过自行实现这些特型中的一个或多个,就可以让 Rust 的格式化宏来格式化你的类型。 格式参数中的符号指示了其参数类型必须实现的特型,如表 17-8 所示。 表 17-8:格式化字符串指令符号 符号 例子 特型 目的 无 {} std::fmt::Display 文本、数值、错误:通用特型 b {bits:#b} std::fmt::Binary 二进制中的数值 符号 例子 特型 目的 o {:#5o} std::fmt::Octal 八进制中的数值 x {:4x} std::fmt::LowerHex 十六进制中的数值,小写数字 X {:016X} std::fmt::UpperHex 十六进制中的数值,大写数字 e {:.3e} std::fmt::LowerExp 科学记数法中的浮点数值 E {:.3E} std::fmt::UpperExp 同上,但大写 E ? {:#?} std::fmt::Debug 调试视图,适用于开发人员 p {:p} std::fmt::Pointer 将指针作为地址,适用于开发人员 当你将 #[derive(Debug)] 属性放在类型定义上,以期支持 {:?} 格式参数时,其实只是在要求 Rust 替你实现 std::fmt::Debug 特型。 这些格式化特型都具有相同的结构,只是名称不同而已。我们将以 std::fmt::Display 为代表来讲解:

trait Display {
    fn fmt(&self, dest: &mut std::fmt::Formatter) -> std::fmt::Result;
}

fmt 方法的任务是为 self 生成格式良好的表达形式并将其字符写入 dest。除了用作输出流,dest 参数还携带着从格式参数解析出的详细信息,比如对齐方式和 小字段宽度。例如,本章前面曾建议,如果 Complex 值能以通常的 a + bi 形式打印自己则会更好。下面是执行本操作的 Display 实现:

use std::fmt;

impl fmt::Display for Complex {
    fn fmt(&self, dest: &mut fmt::Formatter) -> fmt::Result {
        let im_sign = if self.im < 0.0 { '-' } else { '+' };
        write!(dest, "{} {} {}i", self.re, im_sign, f64::abs(self.im))
    }
}

这利用了 Formatter 本身就是一个输出流的事实,所以 write! 宏可以帮我们完成大部分工作。有了这个实现,就可以写出如下代码了:

let one_twenty = Complex {
    re: -0.5,
    im: 0.866,
};
assert_eq!(format!("{}", one_twenty), "-0.5 + 0.866i");

let two_forty = Complex {
    re: -0.5,
    im: -0.866,
};
assert_eq!(format!("{}", two_forty), "-0.5 - 0.866i");

有时以极坐标形式显示复数会很有帮助:想象在复平面上画一条从原点到数值的线,极坐标形式会给出线的长度,以及线与正向 x 轴之间的顺时针夹角。格式参数中的 # 字符通常会选择某种替代的显示形式,Display 实现可以将其视为要求使用极坐标形式:

impl fmt::Display for Complex {     fn fmt(&self, dest: &mut fmt::Formatter) -> fmt::Result {         let (re, im) = (self.re, self.im);         if dest.alternate() {             let abs = f64::sqrt(re * re + im * im); 
            let angle = f64::atan2(im, re) / std::f64::consts::PI 
* 180.0; 
        write!(dest, "{} ∠ {}°", abs, angle) 
        } else { 
            let im_sign = if im < 0.0 { '-' } else { '+' };             write!(dest, "{} {} {}i", re, im_sign, f64::abs(im))         } 
    } }

使用此实现的代码如下所示:

let ninety = Complex { re: 0.0, im: 2.0 };
assert_eq!(format!("{}", ninety), "0 + 2i");

assert_eq!(format!(“{:#}”, ninety), “2 ∠ 90°”); 尽管格式化特型的 fmt 方法会返回一个 fmt::Result 值(典型的模块专属的 Result 类型),但你只能从 Formatter 的操作中开 始传播错误,就像刚才 fmt::Display 的实现中调用 write! 时的做法那样。你的格式化函数自身不应该引发错误。这样像 format! 这样的宏就可以简单地返回一个 String 而非 Result,因为将格式化后的文本追加到 String 上永远不会出错。这还会确保你从 write! 或 writeln! 上抛出的错误总能正确地反映出底层 I/O 流的实际问题,而不是某种格式问题。 Formatter 还有许多其他的有用的方法,包括一些用于处理结构化数据(如映射、列表等)的方法,本书并没有介绍它们,有关详细信息,请参阅在线文档。

17.4.9 在自己的代码中使用格式化语言

使用 Rust 的 format_args! 宏和 std::fmt::Arguments 类 型,你可以编写能接受格式模板和参数的自定义函数和宏。假设你的程序需要在运行期记录状态消息,并且你想使用 Rust 的文本格式化语言来生成这些消息,那么可以参考以下代码:

fn logging_enabled() -> bool {}

use std::fs::OpenOptions;
use std::io::Write;

fn write_log_entry(entry: std::fmt::Arguments) {
    if logging_enabled() {
        // 尽量保持简单,所以每次只是打开文件
        let mut log_file = OpenOptions::new()
            .append(true)
            .create(true)
            .open("log-file-name")
            .expect("failed to open log file");

        log_file.write_fmt(entry).expect("failed to write to log");
    }
}

可以像这样调用 write_log_entry:

write_log_entry(format_args!("Hark! {:?}\n", mysterious_value));

在编译期,format_args! 宏会解析模板字符串并据此检查参数的类型,如果有任何问题则报告错误。在运行期,它会对参数求值并构建一个 Arguments 值,其中包含格式化文本时需要的所有信息:模板的预解析形式,以及对参数值的共享引用。 构造一个 Arguments 值的代价很低:只是收集一些指针而已。这时尚未进行任何格式化工作,仅收集稍后要用到的信息。这很重要,否则如果未启用日志,那么像把数值转换为十进制、填补值之类的任何开销都会白白浪费。 File 类型实现了 std::io::Write 特型,该特型的 write_fmt 方法会接受一个 Argument 并进行格式化,然后会将结果写入底层流。 对 write_log_entry 的调用并不漂亮。这时宏就可以大显身手了:

macro_rules! log { // 在宏定义中的宏名后不需要叹号(!)
($format:tt, $($arg:expr),*) => (
    write_log_entry(format_args!($format, $($arg),*))
)
}

第 21 章会详细介绍宏。现在,你只需知道这定义了一个新 log! 宏并将其参数传给 format_args!,然后在生成的 Arguments 值上调用 write_log_entry 函数即可。诸如 println!,writeln! 和 format! 之类的格式化宏都采用了大致相同的思路。可以像这样使用 log!:

log!(
    "O day and night, but this is wondrous strange! {:?}\n",
    mysterious_value
);

理论上,这会好看一点儿。

17.5 正则表达式

外部的 regex crate 是 Rust 的官方正则表达式库,它提供了通常的搜索函数和匹配函数。该库对 Unicode 有很好的支持,但它也可以搜索字节串。尽管不支持其他正则表达式包中的某些特性(比如反向引用和环视模式),但这些简化允许 regex 确保搜索时间始终与表达式的大小、表达式的长度和待搜文本的长度呈线性关系。此外,这些保证还让 regex 即使在搜索不可信文本的不可信表达式时也能安全地使用。 本书将只提供 regex 的概述。有关详细信息,可以查阅其在线文档。 尽管 regex crate 不在 std 中,但它是由 Rust 库团队维护的,该团队也负责维护标准库 std。要使用 regex,请将下面这行代码放在 crate 的 Cargo.toml 文件的 [dependencies] 部分:

regex = "1"

在以下内容中,我们将假设你已做完了此项更改。

17.5.1 Regex 的基本用法

Regex 值表示已经解析好的正则表达式。Regex::new 构造函数会尝试将 &str 解析为正则表达式,并返回一个 Result:

use regex::Regex; 
 
// 语义化版本号,比如0.2.1 
// 可以包含预发行版本后缀,比如0.2.1-alpha 
// (为简洁起见,没有“构建编号”元信息后缀) 
// 
// 注意,使用原始字符串语法r"..."是为了避免一大堆反斜杠 let semver = Regex::new(r"(\d+)\.(\d+)\.(\d+)(-[-.
[:alnum:]]*)?")?; 
简单搜索	布尔型结
// 简单搜索,返回布尔型结果
let haystack = r#"regex = "0.2.5""#;
assert!(semver.is_match(haystack));

Regex::captures 方法会在字符串中搜索第一个匹配项并返回一个 regex::Captures 值,其中包含表达式中每个组的匹配信息:

// 可以检索各个捕获组:
let captures = semver
    .captures(haystack)
    .ok_or("semver regex should have matched")?;
assert_eq!(&captures[0], "0.2.5");
assert_eq!(&captures[1], "0");
assert_eq!(&captures[2], "2");
assert_eq!(&captures[3], "5");

如果所请求的组不匹配,则对 Captures 值进行索引就会出现 panic。要测试特定组是否匹配,可以调用 Captures::get,它会 返回 Optionregex::Match,其中的 Match 值会记录单个组的匹配信息:

assert_eq!(captures.get(4), None);
assert_eq!(captures.get(3).unwrap().start(), 13);
assert_eq!(captures.get(3).unwrap().end(), 14);
assert_eq!(captures.get(3).unwrap().as_str(), "5");

可以遍历字符串中的所有匹配项:

let haystack = "In the beginning, there was 1.0.0. \                 For a while, we used 1.0.1-beta, \                 but in the end, we settled on 1.2.4."; 
 
let matches: Vec<&str> = semver.find_iter(haystack) 
    .map(|match_| match_.as_str()) 
    .collect(); 
assert_eq!(matches, vec!["1.0.0", "1.0.1-beta", "1.2.4"]);

find_iter 迭代器会为表达式的每个非重叠匹配生成一个 Match 值,从字符串的开头走到结尾。captures_iter 方法也类似,但会生成记录了所有捕获组的 captures 值。当必须报告出捕获组时搜索速度会变慢,因此如果并不实际需要捕获组,那么最好使用某个不返回它们的方法。

17.5.2 惰性构建正则表达式值

Regex::new 构造函数的开销可能很高:在速度较快的开发机器上为 1200 个字符的正则表达式构造一个 Regex 会花费差不多 1 毫秒时间,即使是一个微不足道的表达式也要花费几微秒时间。最好让 Regex 构造远离繁重的计算循环,这就意味着应该只构建一次 Regex,然后重复使用它。 lazy_static crate 提供了一种在首次使用时惰性构造静态值的好办法。首先,请注意 Cargo.toml 文件中的依赖项:

[dependencies] 
lazy_static = "1"

这个 crate 提供了一个宏来声明这样的变量:

use lazy_static::lazy_static;

lazy_static! {
    static ref SEMVER: Regex =
        Regex::new(r"(\d+)\.(\d+)\.(\d+)(-[-.[:alnum:]]*)?").expect("error parsing regex");
}

该宏会扩展成名为 SEMVER 的静态变量的声明,但其类型不完全是 Regex,而是一个实现了 Deref 的由宏生成的类型,并公开了与 Regex 相同的全部方法。第一次解引用 SEMVER 时,会执行初始化程序,并保存该值供以后使用。由于 SEMVER 是一个静态变量,而不仅仅是局部变量,因此每次执行程序时初始化器都最多运行一次。 有了这个声明,使用 SEMVER 就很简单了:

use std::io::BufRead;

let stdin = std::io::stdin();
for line_result in stdin.lock().lines() {
    let line = line_result?;
    if let Some(match_) = SEMVER.find(&line) {
        println!("{}", match_.as_str());
    }
}

可以把 lazy_static! 声明放在模块中,甚至可以放在使用 Regex 的函数内部(如果这就是最合适的作用域的话)。无论采用哪种方式,每当程序执行时,正则表达式都只会编译一次。

17.6 规范化

大多数用户误以为法语单词 thé(意为“茶”)的长度是 3 个字符。 然而,Unicode 实际上有两种方式来表示这个单词。 在组合形式中,“thé”包含 3 个字符,即 ’t’、’h’ 和 ‘é’,其中 ‘é’ 是码点为 0xe9 的单个 Unicode 字符。 在分解形式中,“thé”包含 4 个字符,即 ’t’、’h’、’e’ 和 ‘\u{301}‘,其中的 ‘e’ 是纯 ASCII 字符,没有重音符号,而码点 0x301 是“结合性锐音符号”字符,它会为它前面的任意字符添加一个锐音符号。 Unicode 并不认为 é 的组合形式或分解形式是“正确的”形式,相反,它认为它们是同一抽象字符的等价表示。Unicode 规定这两种形式应该以相同的方式显示,并且允许文本输入法生成任何一种形式,因此用户通常不知道他们正在查看或输入的是哪种形式。(Rust 允许直接在字符串字面量中使用 Unicode 字符,因此如果不关心自己获得的是哪种编码,则可以简单地写成 “thé”。但为了清楚起见,这里我们会使用 \u 转义符。) 然而,作为 Rust 的 &str 值或 String 值,”th\u{e9}” 和 “the\u{301}” 是完全不同的。它们具有不同的长度,比较起来不相等,具有不同的哈希值,并且相对于其他字符串会以不同的方式排序:

assert!("th\u{e9}" != "the\u{301}"); assert!("th\u{e9}" >  "the\u{301}"); 
 
// 哈希器旨在累积求出一系列值的哈希值,因此仅哈希一个值有点儿大材小用 use std::hash::{Hash, Hasher}; use std::collections::hash_map::DefaultHasher; fn hash<T: ?Sized + Hash>(t: &T) -> u64 {     let mut s = DefaultHasher::new(); 
    t.hash(&mut s); 
    s.finish() 
} 

这 值 能会在将来的 版本中发生变化

// 这些值可能会在将来的Rust版本中发生变化
assert_eq!(hash("th\u{e9}"), 0x53e2d0734eb1dff3);
assert_eq!(hash("the\u{301}"), 0x90d837f0a0928144);

显然,如果打算比较用户提供的文本或者将其用作哈希表或 B 树中的键,则需要先将每个字符串转换成某种规范形式。 幸运的是,Unicode 指定了字符串的规范化形式。每当根据 Unicode 规则应将两个字符串视为等同时,它们的规范化形式是逐字符全同的。当使用 UTF-8 编码时,它们是逐字节全同的。这意味着可以使用 == 来比较规范化后的字符串,可以将它们用作 HashMap 或 HashSet 中的键,等等,这样就能获得 Unicode 规定的相等性概念了。 如果未做规范化,则甚至会产生安全隐患。如果你的网站对用户名在某些情况下做了规范化,但在其他情况下未做规范化,那么 终可能会出现两个名为 bananasflambé 的不同用户,你的一部分代码会将其视为同一用户,但另一部分代码会认为这是两个用户,导致一个人的权限被错误地扩展到另一个人身上。当然,有很多方法可以避开这种问题,但历史表明也有很多方法不能避开。

17.6.1 规范化形式

Unicode 定义了 4 种规范化形式,每一种都适用于不同的用途。这里要回答两个问题。 第一个问题是:你更喜欢让字符尽可能组合还是尽可能分解? ̛ 例如,越南语单词 Phở 常用的组合表示是三字符字符串 “Ph\u{1edf}“,其中声调标记 ̉ 和元音标记 都应用于基本̛ 字符“o”上,而其单个 Unicode 字符是 ‘\u{1edf}‘, Unicode 很质朴地将其命名为“带角和钩形的拉丁文小写字母 o”。 常用的分解表示是将基本字母及其两个标记拆分为 3 个单独的 Unicode 字符:’o’、’\u{31b}‘(组合角符)和 ‘\u{309}‘(组合上钩符),其结果就是 “Pho\u{31b}\u{309}“。(每当组合标记作为单独的字符出现,而不是作为组合字符的一部分时,所有规范化形式都指定了它们必须以固定顺序出现,因此即使字符有多个重音符号,也能很好地进行规范化。) 组合形式通常具有较少的兼容性问题,因为它更接近于在 Unicode 建立之前用于其文本的大多数语言的表示。它也可以更好地与简单的字符串格式化特性(如 Rust 的 format! 宏)协作。而分解形式可能更适合显示文本或搜索,因为它使文本的详细结构更加明确。

第二个问题是:如果两个字符序列表示相同的基础文本,但文本的格式化方式不同,那么你是要将它们视为等同的还是坚持认为有差异? Unicode 对普通数字 5、上标数字 ⁵(或 ‘\u{2075}‘)和带圆圈的数字 ⑤(或 ‘\u{2464}‘)都有单独的字符,但声明这 3 个字符是兼容性等效的。类似地,Unicode 对连字 ffi (’\u{fb03}‘)也有一个单字符,但声明这与三字符序列 ffi 兼容性等效。 兼容性等效对搜索很有意义:搜索仅使用了 ASCII 字符的 “difficult”,应该匹配使用了 ffi 连字符的字符串 “di\u{fb03}cult”。对后一个字符串应用兼容性分解会将连字替换为 3 个纯字母 “ffi”,从而让搜索更容易。但是将文本规范化为其兼容的等效形式可能会丢失重要信息,因此不应草率应用。例如,在大多数情况下将 “2⁵” 存储为 “25” 是不正确的。 Unicode 规范化形式 C(NFC)和规范化形式 D(NFD)会使用每个字符的 大组合形式和 大分解形式,但不会试图统一兼容性等价序 列。NFKC 规范化形式和 NFKD 规范化形式类似于 NFC 和 NFD,但它们会将所有兼容性等效序列规范化为各自的一些简单表示法。万维网联盟的“WWW 字符模型”建议对所有内容都使用 NFC。Unicode 标识符和模式语法附件则建议使用 NFKC 作为编程语言中的标识符,并提供了在必要时适配此形式的原则。

17.6.2 unicode-normalization crate

Rust 的 unicode-normalization crate 提供了一个特型,可以将方法添加到 &str 中,以便将文本转成四种规范化形式中的任何一种。要使用这个 crate,请将下面这行代码添加到 Cargo.toml 文件的 [dependencies] 部分:

unicode - normalization = "0.1.17"

有了这个声明,&str 就有了 4 个新方法,它们会返回字符串的特定规范化形式的迭代器:

use unicode_normalization::UnicodeNormalization;

// 不管左边的字符串使用哪种表示形式(无法仅仅通过观察得知),这些断言都成立

assert_eq!(“Phở”.nfd().collect::(), “Pho\u{31b}\u{309}”); assert_eq!(“Phở”.nfc().collect::(), “Ph\u{1edf}”);

// 左侧使用了”ffi”连字符 assert_eq!(“① Di\u{fb03}culty”.nfkc().collect::(), “1 Difficulty”); 接受规范化的字符串并以相同的形式再次对其进行规范化可以保证返回相同的文本。 尽管规范化字符串的任何子字符串本身也是规范化的,但两个规范化字符串拼接起来不一定是规范化的。例如,第二个字符串可能以组合字符开头,并且这个字符按规范应该排在第一个字符串末尾的组合字符之前。 只要文本在规范化时没有使用未分配的码点,Unicode 就承诺其规范化形式在标准的未来版本中不会改变。这意味着规范化形式通常可以安全地用于持久存储,即使 Unicode 标准在不断发展也不会受影响。