第 17 章 字符串与文本(2)

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}Whichhow 都是可选的,很多时候用 {} 就行。

which(哪个)值用于选择模板后面的哪个实参应该取代该形参的位置。可以按索引或名称选择实参。没有 which 值的形参只会简单地从左到右与实参配对。

how(如何)值表示应如何格式化参数:如何填补、精度如何、数值基数等。如果存在 how,则需要写上前面的冒号。

表 17-4 给出了一些示例。

表 17-4:格式化字符串示例

模板字符串

参数列表

结果

"number of {}: {}"

"elephants", 19

"number of elephants: 19"

"from to "

"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"

" "

"adc #42", 105, 42

"69 2a adc #42"

" "

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!("{} ⊂ {}"),
 " ⊂ ");

17.4.1 格式化文本值

当格式化像 &strString(将 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"), "th\u ");
assert_eq!(format!("{:4}", "the\u"), "the\u");

尽管 Unicode 规定这两个字符串都等效于 "thé",但 Rust 的格式化程序可不知道像 '\u' 这样的字符(组合重音符)需要做特殊处理。它正确地填补了第一个字符串,但假设第二个字符串是 4 列宽并且不需要填补。尽管很容易看出 Rust 该如何在这种特定情况下进行改进,但要支持所有 Unicode 脚本的真正多语言文本格式化是一项艰巨的任务。最好依靠所在平台的用户界面工具包来处理,或许也可以通过生成 HTML 和 CSS,让 Web 浏览器来处理。有一个流行的 crate( unicode-width)可以部分处理这个问题。

除了 &strString,你也可以直接向格式化宏传入带有文本型引用目标的智能指针类型,比如 Rc<String>Cow<'a, str>

由于文件名路径不一定是格式良好的 UTF-8,因此 std::path::Path 不完全是文本类型,不能将 std::path::Path 直接传给格式化宏。不过, Path 有个 display 方法会返回一个供格式化的 UTF-8 值,以适合所在平台的方式解决问题。

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

17.4.2 格式化数值

当格式化参数具有 usizef64 之类的数值类型时,参数的 how 值可以有如下几个部分,它们全是可选的。

  • 填补对齐,它们和对文本类型的含义一样。
  • + 字符,要求始终显示数值的符号,即使相应参数是正数。
  • # 字符,要求加显式基数前缀,比如 0x0b。参见稍后要讲的“进制符号”那一项。
  • 0 字符,要求通过在数值中包含前导零(而不是通常的填补方式)来满足最小字段宽度。
  • 最小字段宽度。如果格式化后的数值没有这么宽,那么 Rust 会在左侧(默认)用空格(默认)填补它以构成给定宽度的字段。
  • 浮点参数的 精度,指示 Rust 应在小数点后包含多少位数字。Rust 会根据需要进行舍入或零扩展以生成要求的小数位。如果省略精度,那么 Rust 会尝试使用尽可能少的数字来准确表示该值。对于整数类型的参数,精度会被忽略。
  • 进制符号。对于整数类型,二进制是 b,八进制是 o,十六进制是小写字母 x 或大写字母 X。如果包含 # 字符,则它们会包含显式的 Rust 风格的基数前缀 0b0o0x0X。对于浮点类型, eE 的基数需要科学记数法,具有归一化系数,使用 eE 作为指数。如果不指定任何进制符号,则 Rust 会将数值格式化为十进制。

表 17-6 展示了格式化 i321234 的一些示例。

表 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::IpAddrstd::net::SocketAddr 等互联网协议地址类型。
  • 布尔值 truefalse 也可以被格式化,虽然它们通常不是直接呈现给最终用户的最佳格式。

对上述类型来说,应该使用与字符串相同类型的格式参数。长度限制、字段宽度和对齐方式控制都会如预期般工作。

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 版本后就可能发生变化。

供调试用的格式化通常会以十进制打印数值,但可以在问号前放置一个 xX 以请求十六进制,并且会遵守前导零和字段宽度语法。例如,可以像下面这样写:

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 格式化指针以进行调试

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

{: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!(",,", "zeroth", "first", "second"),
 "first,zeroth,second");

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

assert_eq!(format!(",,", "first", 10, 100),
 "0x0064,1010,=====first");

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

assert_eq!(format!(" @ ",
 price=3.25,
 quantity=3,
 description="Maple Turmeric Latte"),
 "Maple Turmeric Latte..... 3 @ 3.25");

(这里的命名型参数类似于 Python 中的关键字参数,但它们只是这些格式化宏的独有特性,而不是 Rust 函数调用语法的一部分。)

可以在单个格式化宏中将索引型参数、命名型参数和位置型(没有索引或名称的)参数混用。位置型参数会从左到右与参数配对,就仿佛索引型参数和命名型参数不存在一样(不参与位置编号):

assert_eq!(format!(" {} {}",
 "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

``

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, ...>,因为将格式化后的文本追加到 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,它会返回 Option<regex::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<Target=Regex> 的由宏生成的类型,并公开了与 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',其中的 'e' 是纯 ASCII 字符,没有重音符号,而码点 0x301 是“结合性锐音符号”字符,它会为它前面的任意字符添加一个锐音符号。

Unicode 并不认为 é 的组合形式或分解形式是“正确的”形式,相反,它认为它们是同一抽象字符的等价表示。Unicode 规定这两种形式应该以相同的方式显示,并且允许文本输入法生成任何一种形式,因此用户通常不知道他们正在查看或输入的是哪种形式。(Rust 允许直接在字符串字面量中使用 Unicode 字符,因此如果不关心自己获得的是哪种编码,则可以简单地写成 "thé"。但为了清楚起见,这里我们会使用 \u 转义符。)

然而,作为 Rust 的 &str 值或 String 值, "th\u""the\u" 是完全不同的。它们具有不同的长度,比较起来不相等,具有不同的哈希值,并且相对于其他字符串会以不同的方式排序:

assert!("th\u" != "the\u");
assert!("th\u" > "the\u");

// 哈希器旨在累积求出一系列值的哈希值,因此仅哈希一个值有点儿大材小用
use std::hash::;
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"), 0x53e2d0734eb1dff3);
assert_eq!(hash("the\u"), 0x90d837f0a0928144);

显然,如果打算比较用户提供的文本或者将其用作哈希表或 B 树中的键,则需要先将每个字符串转换成某种规范形式。

幸运的是,Unicode 指定了字符串的 规范化 形式。每当根据 Unicode 规则应将两个字符串视为等同时,它们的规范化形式是逐字符全同的。当使用 UTF-8 编码时,它们是逐字节全同的。这意味着可以使用 == 来比较规范化后的字符串,可以将它们用作 HashMapHashSet 中的键,等等,这样就能获得 Unicode 规定的相等性概念了。

如果未做规范化,则甚至会产生安全隐患。如果你的网站对用户名在某些情况下做了规范化,但在其他情况下未做规范化,那么最终可能会出现两个名为 bananasflambé 的不同用户,你的一部分代码会将其视为同一用户,但另一部分代码会认为这是两个用户,导致一个人的权限被错误地扩展到另一个人身上。当然,有很多方法可以避开这种问题,但历史表明也有很多方法不能避开。

17.6.1 规范化形式

Unicode 定义了 4 种规范化形式,每一种都适用于不同的用途。这里要回答两个问题。

  • 第一个问题是:你更喜欢让字符尽可能 组合 还是尽可能 分解? ̛̛ 例如,越南语单词 Phở 最常用的组合表示是三字符字符串 "Ph\u",其中声调标记  ̉ 和元音标记 ̛  都应用于基本字符“o”上,而其单个 Unicode 字符是 '\u',Unicode 很质朴地将其命名为“带角和钩形的拉丁文小写字母 o”。

    最常用的分解表示是将基本字母及其两个标记拆分为 3 个单独的 Unicode 字符: 'o''\u'(组合角符)和 '\u'(组合上钩符),其结果就是 "Pho\u\u"。(每当组合标记作为单独的字符出现,而不是作为组合字符的一部分时,所有规范化形式都指定了它们必须以固定顺序出现,因此即使字符有多个重音符号,也能很好地进行规范化。)

    组合形式通常具有较少的兼容性问题,因为它更接近于在 Unicode 建立之前用于其文本的大多数语言的表示。它也可以更好地与简单的字符串格式化特性(如 Rust 的 format! 宏)协作。而分解形式可能更适合显示文本或搜索,因为它使文本的详细结构更加明确。

  • 第二个问题是:如果两个字符序列表示相同的基础文本,但文本的格式化方式不同,那么你是要将它们视为等同的还是坚持认为有差异?

    Unicode 对普通数字 5、上标数字 (或 '\u')和带圆圈的数字 ⑤(或 '\u')都有单独的字符,但声明这 3 个字符是 兼容性等效 的。类似地,Unicode 对连字 ffi( '\u')也有一个单字符,但声明这与三字符序列 ffi 兼容性等效。

    兼容性等效对搜索很有意义:搜索仅使用了 ASCII 字符的 "difficult",应该匹配使用了 ffi 连字符的字符串 "di\ucult"。对后一个字符串应用兼容性分解会将连字替换为 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::<String>(), "Pho\u\u");
assert_eq!("Phở".nfc().collect::<String>(), "Ph\u");

// 左侧使用了"ffi"连字符
assert_eq!("① Di\uculty".nfkc().collect::<String>(), "1 Difficulty");

接受规范化的字符串并以相同的形式再次对其进行规范化可以保证返回相同的文本。

尽管规范化字符串的任何子字符串本身也是规范化的,但两个规范化字符串拼接起来不一定是规范化的。例如,第二个字符串可能以组合字符开头,并且这个字符按规范应该排在第一个字符串末尾的组合字符之前。

只要文本在规范化时没有使用未分配的码点,Unicode 就承诺其规范化形式在标准的未来版本中不会改变。这意味着规范化形式通常可以安全地用于持久存储,即使 Unicode 标准在不断发展也不会受影响。