Rust | 秒懂 println! 的超实用格式化技巧

初学 Rust 时,即便是一个简单的 “Hello World” 程序中的打印语句也让我非常困扰。

新手在编写各种演示程序时肯定希望能够将变量打印出来以便观察。然而,总是对以下几点感到迷惑:

为什么 println! 后面会跟一个感叹号?格式化语法应当如何使用?问号运算符又是什么意思?为什么 Rust 的打印语句看起来这么复杂?总之,刚开始用起来感到不尽如人意。

因此,这一节我们将重点介绍 Rust 的格式化打印的基本用法,并会简单解释为什么 Rust 宏和函数不直接支持可变参数(varargs)以及其他相关特性。

01. 格式参数

在 Rust 中,格式化参数是通过一系列占位符和相应的参数值来实现的。让我们先看一个简单的例子:

println!("{}天有{}小时", 1, 24);

这里,字符串 "{}天有{}小时" 可以被视为一个模板,模板中的 {} 称为格式参数。从第二个参数开始,后续所有参数构成了被格式化的参数列表——在上述例子中,1 和 24 是参数列表的成员。参数列表的长度没有限制:它可以包含任意数量的参数,而参数在列表中的索引从 0 开始。

一个格式参数的完整形式是 {which:how},其中的 which 和 how 都是可选的。因此,{} 表示既没有指定 which 也没有指定 how,即使用默认设置。

which 用于指定要格式化的特定参数,可以通过索引下标或者名称来选择。如果没有指定which,则默认按照参数列表的顺序,从 0 到 n 自动选择参数。例如:

println!("{0} {1} {} {}", 66, 77);// 等同于println!("{0} {1} {2} {3}", 66, 77, 66, 77);

可以使用命名参数来个性化参数列表,但是如果同时使用索引和命名参数,那么命名参数必须放在最后。下面的例子演示了使用命名参数:

println!("{days}天有{hours}小时", days=1, hours=24);

至于 how,它定义了参数的格式化方式,例如对齐方式、浮点数的精度、数值基数等。若 how 部分存在,那么 : 是必须的。

02. 文本类型的格式化

在 Rust 中,对字符串相关类型(如 String 或 &str)进行格式化时,how 部分的格式可能包括以下几个方面:

  • fill(可选):填充字符,用于当内容的宽度小于所需的最小宽度时进行填充,默认是空格。

  • align(可选):对齐方式,其中 < 代表左对齐,> 代表右对齐,^ 代表居中对齐,默认是左对齐。

  • width(可选):指定最小宽度,如果内容的长度小于此宽度,将根据设置的对齐方式用填充字符补充。

  • precision(可选,通常用于数字和字符串):对于字符串,它指定了最大输出宽度。如果字符串的长度超出这个值,会根据最大宽度截断字符串。

在使用对齐、填充、宽度和精度这些选项时,必须遵循它们的特定顺序。一个格式化占位符的典型结构如下所示:

{:[fill][align][width][.precision]}

重要的是要注意,fill 字符位于 align 字符之前,且两者之间不应有空格或其他分隔符。如果省略了 align 字符,默认会使用右对齐,并且填充字符默认是空格。

以下是一个展示这些元素如何被使用的简单示例:

println!("{:>8}", "foo"); // 输出 "     foo",默认使用空格填充以及右对齐
println!("{:*>8}", "foo"); // 输出 "*****foo",使用 '*' 填充并右对齐
println!("{:*<8}", "foo"); // 输出 "foo*****",使用 '*' 填充并左对齐
println!("{:*^8}", "foo"); // 输出 "**foo***",使用 '*' 填充并居中对齐

03. 数值类型的格式化

对于数值类型的格式化,Rust 采用的语法类似于文本类型,但具体内容会根据数值的特性有所不同。以下是数值类型格式化语法的概括:

{:[fill][align][sign][#][0][width][.precision][type]}
  • fill:(可选)一个填充字符,用于当文本表示的字符数不足宽度(width)时进行填充。

  • align:(可选)对齐标志,<表示左对齐,^表示居中对齐,>表示右对齐(默认)。如果设置了0标志来填充零,则默认为右对齐。

  • sign:(可选)符号,可用的选项有+或-或 (空格)。+会强制为正数显示加号,负数显示减号;- 只有负数时显示符号(这是默认行为);空格会在正数前面留一个空格。

  • #:(可选)替换标志,用于对不同类型的格式增加特殊前缀,例如对于十六进制加0x,八进制加0o。

  • 0:(可选)用于在数值的左边填充0,直到达到指定宽度时停止。

  • width:(可选)指明最小宽度,如果数值的字符串表示不足这个宽度,将会使用fill进行填充。

  • .precision:(可选)对于整数表示最小位数;对于浮点数表示小数点后的位数;对于字符串表示最大宽度。

  • type:指定数值的进制或者是格式化的类型。例如:b 表示二进制,x 表示小写十六进制,X 表示大写十六进制,o 表示八进制,e/E 表示指数形式(科学计数法),p 表示指针地址。

以下是一些常见的数值格式化指令:

格式说明符 功能说明
{} 使用 Display 类型进行默认格式化
以二进制形式格式化整数
以八进制形式格式化整数
以十六进制形式格式化整数(小写字母)
以十六进制形式格式化整数(大写字母)
以科学计数法(小写 e)格式化浮点数
以科学计数法(大写 E)格式化浮点数
使用 Debug Trait 进行格式化
设定最小宽度,未达到宽度时使用空格填充
设定最小宽度,未达到宽度时使用0填充
一起设置最小宽度和精度,.precision$ 部分用来格式化浮点数
左对齐,使用空格填充到指定宽度
居中对齐,使用空格填充到指定宽度
右对齐,使用空格填充到指定宽度
显示数值的正负号
对于正数留空格,对于负数显示负号
实现数值的替代格式(例如在十六进制前加 0x)

注意:并不是所有的组合都有效,某些说明符仅对某些类型的数值有效。例如,precision通常不适用于整数。需要注意的是,整数类型(例如 i32, u32 等)不支持使用.precision设置精度,该设置通常用于浮点数。

以下是一些数值类型格式化的示例:

fn main() {
    // 格式化整数
    println!("{:04}", 42); // 输出:0042
    println!("{:+}", 42); // 输出:+42
    println!("{:#x}", 255); // 输出:0xff
    println!("{:#b}", 5); // 输出:0b101
    println!("{:0>5}", 14); // 输出:00014

    // 格式化浮点数
    println!("{:.*}", 2, 1.234567); // 输出:1.23
    println!("{:+.2}", 3.141592); // 输出:+3.14
    println!("{:.2}", 3.141592); // 输出:3.14
    println!("{:10.4}", 1234.56); // 输出:"   1234.5600",宽度为10,小数点后4位
    println!("{:0>10.4}", 1234.56); // 输出:"0001234.5600",填充0,宽度为10,小数点后4位

    // 格式化时使用特定的填充字符
    println!("{:*>10}", 42); // 输出:********42
    println!("{:.*}", 2, 1.234567); // 输出:1.23
}

04. 参数化宽度和精度

格式化字符串时可以动态指定 width 和 precision。这可以通过命名参数或位置参数来实现,并且需要在参数名后加上 $ 符号作为后缀。例如:

let my_string = "Rust";
println!("{:>width$}", my_string, width = 10); // 命名参数指定宽度
println!("{:>1$}", my_string, 10);             // 位置参数指定宽度

在这里,width$ 或 1$ 表明 width 的值将被取自相对应的参数。在上面的代码中,my_string 将被右对齐并打印在一个至少10字符宽的字段中。

打印内存地址

当涉及到打印一个内存地址时,对于引用、Box、以及其他实现了 Pointer trait 的类型,{:p} 格式说明符可用于输出这些类型的内存地址,这个特性通常用于调试和研究目的。下面的代码展示了如何打印一个变量的内存地址:

fn main() {
    let my_var = 10;
    println!("The memory address of my_var is: {:p}", &my_var);
}

在这个例子中,操作 &my_var 获取了变量 my_var 的引用,其类型是 &i32。输出的地址是 my_var 储存值的内存地址。请注意,程序每次运行时,或者在不同的系统上,输出的具体内存地址可能会有所不同。这是因为操作系统根据当前的内存使用情况、地址空间布局随机化等多种因素来分配变量的具体内存位置。

06. 格式符号和特质

在 Rust 中,每个格式符号背后其实对应着一个特质(Trait),其中最经常用到的是 Display 和 Debug。目前,您不必深入了解特质的具体含义;可以简单地将其视作类似于接口的功能。

格式符号 特质 说明
{} Display 用于用户友好的输出,通常用于打印给终端用户看的信息。
Debug 用于开发人员调试的输出,输出可能包含更多详细信息,不保证一致性或美观。
Debug 用于开发人员调试的输出,输出可能包含更多详细信息,保证美观。
Octal 用于按八进制形式输出整数。
LowerHex 用于按小写十六进制形式输出整数。
UpperHex 用于按大写十六进制形式输出整数。
Binary 用于按二进制形式输出整数。
{:e}, LowerExp, UpperExp 用于按科学计数法输出浮点数,e 用小写字符,E 用大写字符。

Display 特质在某种程度上类似于 Java 中每个对象的 toString 方法,它定义了如何将类型以一种用户友好的方式进行格式化输出。另一方面,Debug 特质提供了一个用于调试目的的 “字符串化” 表示,它被很多容器类型所实现,以便在调试时能够方便地输出变量和数据结构的内容。

Rust 之所以特意区分用于普通展示的 toString 功能(Display 特质)和用于调试目的的输出(Debug 特质),是因为这样做在实际应用中非常实用,并且符合人体工程学原则。

在大多数情况下,如果单独使用 {} 导致编译错误,那么通常可以通过使用 {:?} 来替代,并使其能够成功编译并输出。{:?} 格式说明符表示使用类型所实现的 Debug 特质来进行格式化,从而在标准输出中呈现变量的内部状态,这通常是在开发过程中检查值的快速且不那么正式的方法。

07. 为什么打印语句是宏而不是函数

在 Rust 中,println! 后面的感叹号表明它是一个宏调用。宏是 Rust 中的一项核心功能,与函数相比,它们提供了一些独特的优势。下面,我们将探讨为什么在 Rust 中使用宏来实现打印功能:

  1. 可变长度的参数:Rust 的函数不支持可变数量的参数(varargs),而宏可以接受任意数量的参数。

  2. 编译时字符串检查:宏在编译时展开,这允许进行格式字符串的分析,包括检查提供的参数数量是否正确以及参数类型是否与格式说明符相匹配。

  3. 性能优化:由于宏会在编译时被展开,编译器能够对结果代码进行优化。相比于运行时构建字符串并打印的函数,使用宏能够带来更好的性能。

  4. 类型安全性:宏在编译时进行类型检查,而不是在运行时。这保证了类型安全,增加了灵活性,有助于预防一些安全风险,比如格式化字符串攻击。

  5. 参数传递引用:宏传递的参数是引用,不会因为实际上没有执行而导致性能损失。

总结来说,Rust 中使用宏实现打印语句是因为宏在编译时能提供灵活性、类型安全与性能的独特结合,这样的特质很难通过普通函数调用来实现。这同样解释了为什么 Rust 标准库在许多其他地方也广泛地使用宏。

posted @ 2024-04-19 08:44  RioTian  阅读(943)  评论(0编辑  收藏  举报