hifmt - 零代码段实现RUST的格式化输出功能
hifmt应用于RUST/C并存的极端受限的嵌入式环境,完全避免使用rust格式化输出功能,将RUST的格式化输出转换为C的格式化输出,最终的目标是减少二进制大小。
它把 Rust 的格式化宏直接展开成 C 的 dprintf / snprintf 调用:类型信息由格式串里的转换符给出,不依赖 Display/Debug,因此二进制里不会出现 core::fmt 的代码段。
dprintf / snprintf 不在 hifmt 里,由调用方自己在 crate 根声明(就是平时写 extern "C" 那样,不加任何包装)—— 宏展开出来的就是 crate::dprintf / crate::snprintf。两个缺省常量(容器分隔串、b 的缓冲容量)由 hifmt 自己定义,不用管。谁要用哪个:
dprintf—— 输出到 fd 的宏(print!/println!/eprint!/eprintln!及其c前缀变体);snprintf—— 写缓冲区的宏(bprint!/cbprint!/sprint!/csprint!),以及容器输出({:d*}等,元素先渲染进栈上缓冲再交给外层)。
所以只往 fd 打、且不用容器时可以只声明 dprintf;只要用到 bprint! 这类写缓冲区的宏或容器输出,snprintf 就必须声明。
各版本的变更记录见 CHANGELOG.md。
快速上手
rust
// 宏展开出来的是 `crate::dprintf` / `crate::snprintf`, 所以这两个名字要声明在
// **crate 根**上(和平时写 `extern "C"` 一样, 不加任何包装).
// 这个例子只往 fd 打, 声明 `dprintf` 就够.
extern "C" {
fn dprintf(fd: i32, format: *const u8, ...) -> i32;
}
fn main() {
hifmt::println!("hello {:s}, {:d} + {:d} = {:d}", "world", 1, 2, 3);
// hello world, 1 + 2 = 3
}
一、格式化语法: 格式串照 C99 写
格式串语法
text
format-spec = '{' [argref] [ ':' [flags] [width] [ '.' precision ] conv [items] ] '}'
argref = 名字 // 内联命名参数, 如 {n:x} 里的 n
| 空 // 取下一个位置实参, 如 {:d} / {}
flags = '-' | '+' | ' ' | '#' | '0' 的任意组合, 就按 C99 原样写在最前面
width = '*' // 宽度是实参(与 C99 一样, 先转成 c_int 再传下去)
| 十进制数字 // 字面宽度
precision = '.*' // 精度是实参: `s` / `rc` 与长度取小, `b` 与 `to_buf` 返回值取小; `cs` 原样交给 printf
| '.' 十进制数字 // 字面精度: `s` / `rc` 与长度取小, `cs` 直接用, `b` 是缓冲字节数
conv = s | cs | d | u | x | X | o | c | p | rc | b | e | E | f | F | g | G
items = '*' [容量数字] [分隔串] // 容器: 实参当一串元素引用, 逐元素渲染后拼成一段
- 完全遵循 Rust 的写法:
{与}之间先是可选的实参引用,转换符写在:后面。 没有:时整段就是一个实参引用,转换符取缺省的d—— 所以{d}引用变量d(不是转换符d);要写转换符就得写{:d}、{n:x}。空的{}等同于{:d}。 {}取下一个位置实参。数字下标不支持:{0}、{1:d}这类写法直接报错 —— 要乱序或复用同一个值,就先把它绑成变量,再写命名实参({n:})。- 位置实参必须全部被引用,多给的会报错(同 Rust 的
argument never used)。 - 需要预绑的实参(
s)在调用前按实参表顺序绑成let _hifmt_N = &(实参);,各求值一次,借出来的引用要活到语句块末尾;其余实参(*取到的那几个、数字、b、rc、cs、p,以及容器)整个搬进 C 实参表,按格式串里的先后求值 ——*取到的实参在调用点上就地转成c_int,借一层反而是把「这个数」变成「存这个数的地方」。s的实参里若有活不过语句的临时值((a + b).as_str()这种),编译会报E0716,处理办法见「报 E0716 怎么办」。 *的实参按位置取:width / precision 写*就是「再取一个实参」,与 C99 一模一样 ——{:*d}从下一个位置实参取宽度,{:.*d}取下一个当精度,两个都写就按「宽度、精度」的顺序各消耗一个,都排在被格式化的那个值前面;取到的实参先转成c_int再传下去。*消耗的位置实参与实参引用无关({n:*d}里的*照样从位置实参里取),同一个位置实参也不能既当*又当值。{:s}/{:rc}/{:b}的.*是宏自己生成的(前两个传切片长度,b传「用户写的精度」与to_buf返回值的较小者),不占你的实参(cs写了 precision 也照样原样交给 printf,与d一致)。- 不写 width 就不生成 width;不写 precision 就不生成 precision —— 例外是
{:s}/{:rc}(字符串不保证\0结尾,它们的 precision 必须始终传给 printf,省略时按长度补上)和{:b}(precision 是栈上缓冲的字节数,省略时用缺省常量hifmt::DEFAULT_LEN;写.*时缓冲也退回缺省容量,因为栈上数组的长度必须是编译期常量,而精度仍是「实参」与to_buf返回值的较小者)。 - flags 直接沿用 C99 的写法,没有分隔符:
{:#x}、{:-10s}、{:+d}、{: d}。 - width 不能以
0开头(C99 的规定),所以{:08d}里的0只可能是 flag ——{:08d}就是零填充到 8 列,与 C 完全一致,0不需要任何特殊规则。 - 不写转换符(
{}、{:}、{n})就按默认的d处理;结尾的?是 Rust 风格提示,忽略掉({:?}即{:d})。写了别的却不在下表里一律报错。 - 格式串本身必须是编译期字面量:字符串字面量,或
concat!(字面量, ...)拼出来的(编译期折成一个字面量,便于把长格式串分几行写);别的表达式(变量、format!的结果……)一律报错,不会退化成运行时拼接。concat!折出来的每一项各留各的出处:内联命名实参{n:}在写它那一段文本的作用域里解析,与macro_rules!自己的卫生一致——调用方原样传下来的那一段找调用方的变量,下游宏宏体里的那一段找那个宏的变量,两段里的同名变量各算各的。所以下游宏拿concat!(自己的文本, $fmt)包一层cformat!,不必担心{n:}找不到变量;补进去的文本也不占实参,位置实参照样按{}出现的顺序配。*println!那四个只能收普通字符串字面量:它们的\n是宏在格式串尾部接上去的,concat!只接受字面量,$fmt:literal也匹配不到宏调用;想在尾部补\n又要用concat!拼格式串,就用print!/cformat!把\n自己写进格式串。 *是容器标记,写在转换符后面:{:d*}把实参当一串元素引用,每个元素按{:d}渲染后拼成一段;*后面紧跟的数字是栈上缓冲区大小(省略时用缺省常量hifmt::DEFAULT_LEN),其余到}之前是分隔串(省略时用缺省常量hifmt::DEFAULT_SEP),所以{:d*, }是「逗号加空格分隔」、{:d*64-}是「64 字节缓冲、-分隔」。限制是分隔串不能以数字开头,也写不出空分隔串。详见二、容器输出。
转换符
| conv | trait | 传给 printf 的 C 类型 | 生成的格式串 | precision |
|---|---|---|---|---|
s rs rb |
ToStr |
*const c_char + 长度 |
%[flags][w].*s |
始终生成(一律 .*,取「字面精度」与「切片长度」的较小者) |
cs |
ToCStr(裸指针或 CStr) |
*const c_char(实参须 unsafe) |
%[flags][w][.p]s |
可选(与数字一样原样交给 printf;不写则 printf 读到 \0 为止) |
d |
ToNumber |
i64(as i64) |
%…lld |
可选 |
u |
ToNumber |
u64 |
%…llu |
可选 |
x / X |
ToNumber |
u64 |
%…llx / %…llX |
可选 |
o |
ToNumber |
u64 |
%…llo |
可选 |
c cc |
ToNumber |
c_int |
%…c |
禁止 |
p |
ToPointer |
*const c_void |
%…p |
禁止 |
rc |
ToChar |
*const c_char + 长度 |
%[flags][w].*s |
恒生成(取「字面精度」与 UTF-8 字节数的较小者) |
b |
ToBuffer(自定义类型自己渲染) |
*const c_char + 长度 |
%[flags][w].*s |
缓冲字节数(省略或写 * → hifmt::DEFAULT_LEN),* 时取「实参」与 to_buf 返回值的较小者 |
e E f F g G |
ToDouble |
f64 |
%…e / %…f / %…g(大小写随转换符) |
可选 |
rs/rb是s的别名,cc是c的别名,老格式串不用改就能继续编译。- width 上表每一行都支持(可选):写数字就是字面宽度,写
*就从实参取,不写就不生成 —— 规则对所有转换符一致,所以格式串一列统一记作[w],不再单列一列。precision 则逐行不同(禁止/可选/始终生成/固定),故单列。 - 浮点转换符一律收
double:C99 里%e/%E/%f/%F/%g/%G的实参都是double,没有%lld那样的长度修饰符(l对浮点无效果,L才是long double),变参里float也会自动提升为double—— 所以 C 类型一列写f64(就是double),格式串就是%…e这样,没有额外指示符。 - 只有不写转换符(
{}、{:}、{n})才按默认的d处理,结尾的?会被忽略({:?}={:d}、{:x?}={:x});写了别的却不在上表里直接报错。 {:c}是 C99 的%c,只输出一个字节,中文等多字节字符请用{:rc}。{:rc}把 Rustchar编码为 UTF-8 后按%.*s输出,与 locale 无关。ToNumber和ToDouble互不重叠:bool/整数/char只实现ToNumber,按{:d}/{:x}/…打;浮点f32/f64只实现ToDouble,按{:e}/{:E}/{:f}/{:F}/{:g}/{:G}打。整数不再自带浮点视图 —— 要按浮点打请自己as f64(取数值本身,所以(-1_i32) as f64打出来是-1.0)。&T/Option<T>/Result<T, E>的转发各按元素类型走对应的那一个。裸指针不在ToNumber里:地址只能按{:p}打,{:d}/{:x}收指针是编译错误。p只收指针类类型:裸指针(*const T/*mut T)、引用(&T/&mut T,取被引用者的地址)、切片([T],取首元素地址)。str/CStr没有单独实现 —— 它们是字符串,要地址就自己取引用或.as_ptr()。s的 precision 约定(安全性相关):&str/[u8]/[i8]/String都不是\0结尾(CStr是,但走{:s}时一样按切片长度传),必须靠.*传递真实长度,因此宏始终为s生成 precision,且只生成.*—— 你写的字面精度不会被原样放进格式串,而是和切片长度取小后作为实参传下去。所以{:.10s}作用在 2 字节的切片上只会输出 2 字节,printf 读不到切片外面。cs的 precision 约定:cs收裸指针(只有i8/u8两种元素类型)或CStr,交出来的指针必须\0结尾——宏一个字节都不读,长度由 C 自己数到\0(precision也一个字节都不读:写了就原样交给 printf)。想截断就在格式串里写{:.5cs}(效果是「不超过 5 字节」),或用{:.*cs}从实参给精度(负数等于没写,照样读到\0)。cs在这一项上和d、f这些一样,不走s/rc那套「与长度取小」。b是自定义类型的出口:给类型实现ToBuffer(to_buf(&self, buf: &mut [u8]) -> usize),它自己把渲染结果写进栈上缓冲,再按普通的%.*s输出 —— 机制和容器一样,全程不碰core::fmt。缓冲大小就是字面 precision({:.32b}给 32 字节),省略或写.*时用缺省常量hifmt::DEFAULT_LEN;写.*时精度照旧是「实参」与to_buf返回值的较小者,与{:.5b}同形,只是那个数字来自实参。容器形式({:b*})下,这一行的[flags][width].[precision]针对的是元素:{:.8b*, }是「每个元素 8 字节缓冲」,不是「整段 8 字节」—— 外层缓冲仍由*后面的容量数字(或缺省常量)决定,与别的转换符一样,写在*之前的修饰落在那条元素bprint!上。详见实现 trait 输出自定义类型。*容器标记可以跟在任何转换符后面(包括b:{:b*, }把每个元素按{:b}渲染),元素的渲染规则完全不变,只是多了一层「实参是一串元素引用」。width / precision / flags 写在*之前,修饰的是每个元素而不是拼好的整段 —— 写在那儿就等于给每个元素单独写一遍,含义完全由 C99 定:{:8d*, }是「每个元素对齐到 8 列」,{:.4d*, }是「每个元素至少 4 位数字」,{:.3s*, }是「每个元素最多 3 字节」。字面值与*一视同仁,取到的实参照 C 消费变参的顺序排在元素前面。外层只剩%.*s(把拼好的整段按长度输出),那里没有 width / precision / flags 的位置,也不需要有。
宏列表
text
// 每个入口拼好一个固定的 sink, 再转发给 cformat!.
// 输出到 stdout, 转发为 cformat!(crate::dprintf(1), ...)
cprint!(format: &'static str, ...);
print!(format: &'static str, ...);
// 在 cprint! 的格式串尾部补一个 \n, 写成 concat!($fmt, "\n")
cprintln!(format: &'static str, ...);
println!(format: &'static str, ...);
// 输出到 stderr, 转发为 cformat!(crate::dprintf(2), ...)
ceprint!(format: &'static str, ...);
eprint!(format: &'static str, ...);
// 在 ceprint! 的格式串尾部补一个 \n
ceprintln!(format: &'static str, ...);
eprintln!(format: &'static str, ...);
// 输出到 &mut str, 转发为 cformat!(crate::snprintf(buf.as_bytes_mut().as_mut_ptr(), buf.len()), ...)
csprint!(buf: &mut str, format: &'static str, ...);
sprint!(buf: &mut str, format: &'static str, ...);
// 输出到 &mut [u8], 转发为 cformat!(crate::snprintf(buf.as_mut_ptr(), buf.len()), ...)
cbprint!(buf: &mut [u8], format: &'static str, ...);
bprint!(buf: &mut [u8], format: &'static str, ...);
// 通用入口: 两个可选项都能省 —— sink 省了就打到 fd 1, 策略路径见[三、隐私数据的输出控制](#三隐私数据的输出控制)
cformat!([策略路径,] [sink(前置参数...),] format: &'static str, ...);
// 查表: 拿方括号里的名字去表里找注解文本, 由你自己写的策略宏转手交给 cformat!
// 缺省项 `_` 可写可不写: 写了就是「表里没列到的一律用它」, 不写就是不加注解.
policy! { 名字 => "文本", ..., [_ => "文本",] [要查的名字] }
带 c 前缀的(cprint!/cprintln!/ceprint!/ceprintln!/csprint!/cbprint!)与不带前缀的完全等价,只是入口名字不同。所有宏的返回值就是 dprintf/snprintf 的返回值(cformat! 则是 sink 自己的返回值)。
每个入口只是拼好一个固定的 sink 再转发给 cformat! —— print! 转发为 dprintf(1, ...),bprint! 转发为 snprintf(buf, buf.len(), ...)。要加一个自己的入口,照这个形状再写一条 macro_rules! 即可:
text
macro_rules! print {
($($arg:tt)*) => { $crate::cformat!(crate::dprintf(1), $($arg)*) };
}
macro_rules! bprint {
($buf:expr, $($arg:tt)*) => {{
let _hifmt_buf: &mut [u8] = $buf;
$crate::cformat!(crate::snprintf(_hifmt_buf.as_mut_ptr(), _hifmt_buf.len()), $($arg)*)
}};
}
cformat! 是给「fmt 之前还有前置参数」的接口用的通用入口,详见集成任意 C 接口。
完整示例
rust
// 宏展开出来的是 `crate::dprintf` / `crate::snprintf`, 声明在 **crate 根**上.
// 这个例子既往 fd 打、又写缓冲区、还用了容器, 所以两个函数都要声明.
extern "C" {
fn dprintf(fd: i32, format: *const u8, ...) -> i32;
fn snprintf(buf: *mut u8, len: usize, format: *const u8, ...) -> i32;
}
fn main() {
// ---- 标量: d / u / x / X / o / p ----
hifmt::println!("d = {:d}", -100);
hifmt::println!("u = {:u}", -1_i32); // 按 u64 重解释
hifmt::println!("x = {:x} X = {:X} o = {:o}", 255, 255, 8);
let v = 42_i32;
hifmt::println!("p = {:p}", &v);
// 不写转换符默认 `d`; 结尾的 `?` 忽略掉.
hifmt::println!("default = {} debug = {:?}", 7, 8);
// ---- 浮点: e / E / f / F / g / G ----
hifmt::println!("e = {:e} f = {:f} g = {:g}", 1.5, 1.5, 1234.5);
// 整数要按浮点打, 自己 `as f64`(取数值本身).
hifmt::println!("i32 as f64 = {:.1f}", -2_i32 as f64);
// ---- 字符串: s(别名 rs/rb)收切片, 长度自带, 不需要 unsafe ----
hifmt::println!("&str = {:s}", "hello");
hifmt::println!("&[u8] = {:s}", &b"hello"[..]);
let i8s: &[i8] = &[104, 101, 108, 108, 111];
hifmt::println!("&[i8] = {:s}", i8s);
hifmt::println!("legacy = {:rs} {:rb}", "hello", &b"hello"[..]);
// CStr 走安全的 `{:s}`: 长度已知, 不写 unsafe.
let cstr = std::ffi::CString::new("hello world").unwrap();
hifmt::println!("CStr via s = {:s}", cstr.as_c_str());
// 裸 C 字符串走 `{:cs}`: 实参自己写 unsafe, 宏不碰这个指针.
let raw: *const std::ffi::c_char = cstr.as_ptr();
hifmt::println!("raw ptr via cs = {:cs}", unsafe { raw });
// ---- 字符: c 是单字节, rc 是 Rust char(UTF-8) ----
hifmt::println!("c = {:c} cc = {:cc}", b'A', b'B');
hifmt::println!("rc = {:rc} {:rc}", 'A', '中');
// ---- width / precision / flags ----
hifmt::println!("pad = {:-10s}| hex = {:#x} zero = {:08d} plus = {:+d}", "hi", 255, 42, 7);
// {:s} 的精度与切片长度取小: {:.10s} 作用在 2 字节上只输出 2 字节.
hifmt::println!("cut = {:.10s}", &b"hi"[..]);
// ---- 命名参数 / 位置参数 ----
let n = 100;
hifmt::println!("named = {n:} {n:x}");
hifmt::println!("positional = {} {}", 7, 9);
// ---- 写缓冲区: bprint! / sprint! ----
let mut bytes = [0_u8; 128];
hifmt::bprint!(&mut bytes, "bprint {:s} = {:d}", "value", 42);
let mut text = String::from_utf8(vec![0_u8; 128]).unwrap();
hifmt::sprint!(text.as_mut_str(), "sprint {:s}", "ok");
// ---- 容器: 实参当一串元素引用, 逐元素按同一个转换符渲染 ----
// 实参只要能交出元素引用就行: 惯用的 `&v` 与显式的 `v.iter()` 都可以.
let v = [1_i32, 2, 3];
hifmt::println!("list = {:d*}, sep = {:d*, }, hex = {:x* }", &v, v.iter(), v.iter());
// ---- 自定义类型: 实现 ToBuffer 就能按 {:b} 输出, 机制和容器一样 ----
struct Point {
x: i32,
y: i32,
}
impl hifmt::ToBuffer for Point {
fn to_buf(&self, buf: &mut [u8]) -> usize {
// 自己怎么拼都行, 这里借 `bprint!` 拼出 "x,y".
hifmt::bprint!(buf, "{},{}", self.x, self.y) as usize
}
}
// 不写 precision 用缺省缓冲容量; `.16` 是 16 字节缓冲.
hifmt::println!("point = {:b}, padded = [{:16.8b}]", Point { x: 1, y: 2 }, Point { x: 1, y: 2 });
// ---- Option / Result 自动转发 ----
let some: Option<i32> = Some(5);
let none: Option<i32> = None;
hifmt::println!("some = {:d} none = {:d}", some, none);
}
上面只挑了一部分。examples/features.rs把全部宏入口、全部转换符、容器、自定义 trait 实现逐项跑一遍并打印结果,cargo run --example features 就能跑(写缓冲区那一节还带断言)。
集成任意 C 接口
上面那些宏各自对应固定的 sink(dprintf/snprintf)。实际项目里常遇到「fmt 之前还有若干前置参数」的接口——syslog(priority, fmt, ...)、__android_log_print(prio, tag, fmt, ...)——这类接口用cformat!接:sink 写成一条调用表达式,它的实参就是那些前置参数,有几个、什么类型都由你决定。
text
cformat!([策略路径,] [sink(前置参数...),] "格式串", 实参...)
// 展开为
sink(前置参数..., "翻译好的 C 格式串".as_bytes().as_ptr(), 转换后的实参...)
两个可选项都靠形状认(裸路径只可能是策略,调用表达式只可能是 sink),顺序固定——策略在最前,sink 在它后面——也都可以不写。不写 sink 就取缺省值crate::dprintf(1),也就是和print!一样打到 fd 1,dprintf照样由调用方声明在 crate 根上。
以syslog(3)为例——它比dprintf多一个priority:
rust
// 和 dprintf / snprintf 一样, 这些 C 函数由**调用方**声明在 crate 根上.
extern "C" {
fn syslog(priority: i32, format: *const u8, ...);
fn openlog(ident: *const u8, option: i32, facility: i32);
fn closelog();
// 容器把元素先渲染进栈上缓冲, 走的是 snprintf, 用到容器就得声明它.
fn snprintf(buf: *mut u8, len: usize, format: *const u8, ...) -> i32;
}
// 数值与 <syslog.h> 一致; 这里自己声明是为了不引入 libc 依赖.
const LOG_PID: i32 = 0x01;
const LOG_USER: i32 = 1 << 3;
const LOG_ERR: i32 = 3;
fn main() {
unsafe { openlog(b"myapp\0".as_ptr(), LOG_PID, LOG_USER) };
// crate::syslog(LOG_ERR) 的实参就是前置参数, 原样传给 C;
// 后面才是格式串和实参, 写法和 println! 一样.
hifmt::cformat!(crate::syslog(LOG_ERR), "open {:s} failed: errno = {:d}\n", "/etc/hosts", 2);
// 容器、{:b}、自定义 trait 实现照常可用 —— 它们只依赖 snprintf, 与 sink 无关.
hifmt::cformat!(crate::syslog(LOG_ERR), "seen {:d*, }\n", &[1, 2, 3]);
unsafe { closelog() };
}
要点:
-
前置参数任意:个数、类型都随接口而定;一个也没有就写空调用(
crate::printf())。 -
sink 可以不写:省掉就是
crate::dprintf(1),与print!等价——cformat!("{:d}", n)和print!("{:d}", n)一个意思。 -
sink 必须是一条调用表达式:
crate::syslog(LOG_ERR)、(*fp)(1)都收,方法调用self.log(...)不收——前置参数要从实参表里取。路径由宏原样吐回,在调用方作用域里解析,所以写成crate::logging::syslog(...)也行,不必放在 crate 根(容器和{:b}仍然要用根上的snprintf)。 -
换行自己写:没有
cformatln!,要换行就写在格式串里——syslog 这类接口本来也不该自动加换行。 -
返回值就是 sink 的返回值:
dprintf是i32,syslog是()。 -
下游宏可以往格式串里加自己的文本:
$fmt原样转给cformat!的宏什么都不用管;要在格式串前后补自己的字(比如统一加个 tag),写成concat!(自己的文本, $fmt)即可——折出来的每一项各留各的出处,{n:}仍在写它那一段的作用域里解析,调用方传下来的那一段找调用方的变量:textmacro_rules! log { ($fmt:expr $(, $($arg:expr),*)?) => { ::hifmt::cformat!(crate::dprintf(1), $fmt $(, $($arg),*)?) }; } // 下游再包一层: 前面加个固定 tag. `{n:d}` 是调用方写的, 仍找调用方的 `n`. macro_rules! tagged_log { ($fmt:literal $(, $($arg:expr),*)?) => { log!(concat!("[tag] ", $fmt) $(, $($arg),*)?) }; } let n = 7; let who = "/etc/hosts"; tagged_log!("n={n:d}\n"); // "[tag] n=7" // 补文本**不碰实参**: 位置实参照样按 `{}` 出现的顺序配, 命名实参不占位置. tagged_log!("{:s} n={n:d}\n", who); // "[tag] /etc/hosts n=7"反过来也一样:宏体里那一段写
{dur:d}引用的是宏自己的局部变量,与调用方的同名变量各算各的。 -
「长度由
buf派生」的接口照样能接:bprint!/sprint!自己就是这么实现的——先把缓冲绑成一个局部变量,再拿它同时派生指针和长度。直接把buf.as_mut_ptr()和buf.len()都塞进前置参数,会二次借用同一个buf:textmacro_rules! my_bprint { ($buf:expr, $($arg:tt)*) => {{ let buf: &mut [u8] = $buf; hifmt::cformat!(crate::snprintf(buf.as_mut_ptr(), buf.len()), $($arg)*) }}; } -
求值顺序:只有
{:s}的实参提前求值,在调用之前,位置在fmt之前那些参数之后;其余实参一律按格式串里的先后求值,*取到的那几个、rc的encode_utf8(...)、容器的render_with(...)也在其中。要固定先后,把想控制的那一侧先绑成局部变量再传进来。其余实参之间的顺序是实打实的:位置实参在格式串里各出现一次,
mark(1)是第 0 个实参、mark(2)是第 1 个,mark(1)先求值:rustextern "C" { fn dprintf(fd: i32, format: *const u8, ...) -> i32; } fn main() { use std::cell::Cell; let order = Cell::new(0); // 每求值一次, 就把自己是谁追加进 `order`. let mark = |n: i32| { order.set(order.get() * 10 + n); n }; // 第 0 个格式实参是 `mark(1)`, 第 1 个是 `mark(2)`. hifmt::cformat!(crate::dprintf(1), "{:d} {:d}\n", mark(1), mark(2)); assert_eq!(order.get(), 12); } -
前置参数是原样内联的:宏不绑定它们,表达式里的临时值活到整条语句结束,
crate::syslog(make_tag().as_ptr())这类写法是安全的。反过来,前置参数不会先于格式实参求值——那会让临时值在调用前析构,变成悬垂指针。 -
macOS 上
syslog的符号名是syslog$DARWIN_EXTSN,按平台改声明即可(或直接用 libc crate 的声明)。
报 E0716(临时值活不过语句)怎么办
看到 error[E0716]: temporary value dropped while borrowed,就把格式实参的接收者(或者整个实参)先绑成一条 let,再传进来。
{:s} 的实参是按let _hifmt_N = &(实参);绑的,借出来的引用要活到语句块末尾;而 Rust 的临时值生命期延长够不到方法调用的接收者——照这个形状直接绑的话,(a + b).as_str()里的a + b在语句结束时就析构了,编译报错:
text
let _hifmt_1 = &((a + b).as_str()); // 临时值 `a + b` 活不到这儿
宏会保守地把这种位置上「一眼看得出是新造出来的值」的接收者先提成局部变量(let mut _hifmt_1 = a + b;),所以这条能编过:
rust
// 宏展开出来的是 `crate::dprintf`, 声明在 **crate 根**上.
extern "C" {
fn dprintf(fd: i32, format: *const u8, ...) -> i32;
}
struct Id(String);
impl Id {
fn as_str(&self) -> &str {
&self.0
}
}
impl core::ops::Add<&str> for Id {
type Output = Id;
fn add(mut self, rhs: &str) -> Id {
self.0.push_str(rhs);
self
}
}
fn main() {
let a = Id("a".to_string());
// 接收者是运算: 宏替你把接收者提成局部变量, 临时值 `a + "cr3t"` 活到语句块末尾.
hifmt::cformat!(crate::dprintf(1), "{:s}\n", (a + "cr3t").as_str());
}
只有 s 的实参走这一手,因为只有它对应的 trait 方法交出借来的东西:实参要交给 to_bytes(),拿到的切片借在实参身上,而那条 let 借出来的引用要活到语句块末尾,接收者就得活得比它久。容器不同——宏把实参表达式本身交给 render_with,迭代器只活到那次调用,宏不替它写存引用的 let;实参就在实参表里,临时值撑得住一条语句,没有要活到块尾的引用(见二、容器输出)。其余转换符交出来的是值(b 的 to_buf() 返回写入长度,字符串指针是它自己写的那块栈上缓冲;rc 的 to_char() 返回 char;数字与 p 同理),而且它们的实参根本不预绑 —— 表达式直接留在调用点上,连 let 都没有,也就没得提。
宏认不出的写法(块、if / match 的值、闭包、unsafe 块……)原样放回去——拿不准就不动,于是这类写法该报的 E0716 照报:
compile_fail
extern "C" {
fn dprintf(fd: i32, format: *const u8, ...) -> i32;
}
fn main() {
let s = String::from("s3");
// error[E0716]: temporary value dropped while borrowed —— 块的值 `s + "cr3t"`
// 在语句结束时就析构了, 借出来的 `&str` 却要活到语句块末尾.
hifmt::cformat!(crate::dprintf(1), "{:s}\n", { s + "cr3t" }.as_str());
}
宏调用也归在这类里,理由不同:它展开成什么,语法上看不出来——id!(s) 展开出来就是个地方,提成局部变量反而成了移动。
临时值有了名字,就活得够久了——这条办法对任何写法都管用,不限于上面这几类:
rust
extern "C" {
fn dprintf(fd: i32, format: *const u8, ...) -> i32;
}
fn main() {
let s = String::from("s3");
let id = { s + "cr3t" };
hifmt::cformat!(crate::dprintf(1), "{:s}\n", id.as_str());
}
嫌crate::syslog(level), ...长,就在外面套一层macro_rules!——前置参数继续由宏调用方给,原样转发给cformat!:
rust
extern "C" {
fn syslog(priority: i32, format: *const u8, ...);
}
// 前置参数继续由宏调用方给, 原样转发给 cformat!; 路径里的 crate 仍是本 crate 的根.
macro_rules! mylog {
($level:expr, $($rest:tt)*) => {
hifmt::cformat!(crate::syslog($level), $($rest)*)
};
}
fn main() {
mylog!(3, "open {:s} failed\n", "/etc/hosts");
}
实现 trait 输出自定义类型
每个转换符对应一个 trait,给自己的类型实现对应的那一个就能直接参与格式化:
rust
// 宏展开出来的是 `crate::dprintf`, 声明在 **crate 根**上.
extern "C" {
fn dprintf(fd: i32, format: *const u8, ...) -> i32;
}
struct Padded(i32);
impl hifmt::ToNumber for Padded {
fn to_number(&self) -> u64 {
self.0 as u64
}
}
fn main() {
// width / precision 与内置类型一样: 写 `*` 就从实参取, 按位置排在值前面.
hifmt::println!("[{:*d}]", 8, Padded(123)); // "[ 123]"
hifmt::println!("[{:3d}]", Padded(123)); // "[123]" (值比宽度长, 撑满)
}
{:b} 是给「一个类型要拼出一整段」用的:实现 ToBuffer,类型自己把结果写进栈上缓冲,库不参与渲染 —— 和容器同一套机制。
rust
// `{:b}` 的外层是 `%.*s`, 但类型内部往往要自己拼串, 所以 `snprintf` 一般也得声明.
extern "C" {
fn dprintf(fd: i32, format: *const u8, ...) -> i32;
fn snprintf(buf: *mut u8, len: usize, format: *const u8, ...) -> i32;
}
struct Point {
x: i32,
y: i32,
}
impl hifmt::ToBuffer for Point {
fn to_buf(&self, buf: &mut [u8]) -> usize {
// 自己怎么拼都行, 这里借 `bprint!` 拼出 "x,y".
let n = hifmt::bprint!(buf, "{},{}", self.x, self.y) as usize;
// `bprint!` 返回的是本应写入的长度, 缓冲不够时按实际写入截断.
n.min(buf.len().saturating_sub(1))
}
}
fn main() {
// 不写 precision: 缓冲大小用 `hifmt::DEFAULT_LEN`.
hifmt::println!("point = {:b}", Point { x: 1, y: 2 }); // point = 1,2
// 字面 precision 就是缓冲字节数; width 照常支持.
hifmt::println!("[{:16.8b}]", Point { x: 1, y: 2 }); // "[ 1,2]"
}
to_buf 的约定:
- 返回写入的字节数,不能写超过
buf.len()字节。返回大于buf.len()的值不会越界(宏先在usize域里和buf.len()取小,再转成 printf 的 precision),但那样的实现本身已经写错了。 - 缓冲大小就是格式串里的字面 precision(
{:.32b}给 32 字节);省略时用缺省常量hifmt::DEFAULT_LEN。写.*时缓冲也退回缺省容量(栈上数组的长度必须是编译期常量),而 precision 的实际效果不变 —— 仍是「实参」与to_buf返回值的较小者,与{:.5b}是同一条%.*s,只是那个常量换成了实参。 - width 照常:字面值,或写
*从实参取(与所有转换符一个规矩)。 &T/Option<T>/Result<T, E>都有转发实现,元素实现即可;{:b*}也自动可用。
trait 一览:
| trait | 转换符 | 方法 |
|---|---|---|
ToStr |
s rs rb |
to_bytes() -> &[u8] |
ToCStr |
cs |
to_cstr() -> *const c_char |
ToNumber |
d u x X o c cc |
to_number() -> u64 |
ToDouble |
e E f F g G |
to_double() -> f64 |
ToPointer |
p |
to_pointer() -> *const c_void |
ToChar |
rc |
to_char() -> char |
ToBuffer |
b |
to_buf(&mut [u8]) -> usize |
每个 trait 只有上面这一个方法 —— 没有「从类型里读 width / precision」这套:与 C99 一样,* 的宽度、精度就是用户传的实参,宏先把它转成 c_int 再交给 printf。
bool/整数/char 实现 ToNumber,浮点实现 ToDouble,Option<T>/Result<T, E>/&T 按元素类型转发。
ToStr 的内置实现都是长度已知的字节来源:str、[u8]、[i8] 及其定长数组 [u8; N]、[i8; N]、CStr,以及开了 alloc 时的 String、CString。
ToStr 与 ToBuffer 是两条并行的字符串出口:ToStr 交回一个已经存在的切片(库不复制、不渲染),ToBuffer 把渲染工作交给类型自己。
不想手写 impl 的话,六个 trait 各有一个同名派生宏,见四、派生宏。
二、容器输出
转换符后面跟一个*,实参就按一串元素引用处理(实参得交出元素引用),每个元素按同一个转换符渲染,元素之间插入分隔串,整段拼好后再按普通的%.*s输出。
rust
// 容器输出靠 `snprintf` 把元素渲染进栈上缓冲, 所以它必须声明在 crate 根上.
extern "C" {
fn dprintf(fd: i32, format: *const u8, ...) -> i32;
fn snprintf(buf: *mut u8, len: usize, format: *const u8, ...) -> i32;
}
fn main() {
let v = [1_i32, 2, 3];
hifmt::println!("{:d*}", &v); // 元素间用缺省分隔符(见 hifmt::DEFAULT_SEP)
hifmt::println!("{:d*, }", v.iter()); // 1, 2, 3
hifmt::println!("{:d*64-}", &v); // 1-2-3
hifmt::println!("{:x* }", &[255_u32, 16]); // ff 10
hifmt::println!("{:u*}", [-1_i32].iter()); // 18446744073709551615
let words = ["a", "bb"];
hifmt::println!("{:s*-}", words.iter()); // a-bb
// 不止切片/数组/Vec: `&集合` 或它的 `.iter()` 都行.
use std::collections::BTreeSet;
let set: BTreeSet<i32> = [3, 1, 2].into_iter().collect();
hifmt::println!("{:d*, }", &set); // 1, 2, 3
// 自定义类型: 给 &Self 实现 IntoIterator, 于是 &pair 与 pair.into_iter() 都行.
let pair = Pair([4, 5]);
hifmt::println!("{:d*+}", &pair); // 4+5
}
struct Pair([i32; 2]);
impl<'a> IntoIterator for &'a Pair {
type Item = &'a i32;
type IntoIter = core::slice::Iter<'a, i32>;
fn into_iter(self) -> Self::IntoIter {
self.0.iter()
}
}
- 容量:
*后面紧跟的连续数字是栈上缓冲区的字节数,省略就是缺省常量hifmt::DEFAULT_LEN——宏生成的是对这个常量的引用,要别的就写字面值({:d*256})。写不下时按snprintf的返回值检测并截断,不会越界。缓冲区是栈上的[u8; N],不引入alloc。 - 分隔串:容量数字之后、
}之前的全部字符都是分隔串(可以多字节);一个字符都没写就用缺省常量hifmt::DEFAULT_SEP。限制是分隔串不能以数字开头({:d*1, }里的1一定是容量),而且写不出空分隔串 —— 元素之间至少隔一个字节。 - 元素渲染用的是同一个
bprint!:每个元素等于一次bprint!(剩余空间, "{:d}", item),C 格式串、实参绑定、trait 取用都走同一条路径,所以元素输出与宏的其它路径逐字节一致。{:d*}和{:rs*}这种老别名也照样能用。 - 实参:实参得交出元素引用 —— 集合写
&v或v.iter(),切片写&slice或slice.iter();元素本身已经是引用时(&str、String的切片)写words.iter()。宏把实参原样写进render_with的第一个实参位置,报错因此就是render_with的约束:交不出引用的写法会被拦下 ——&&v报`&&Vec<i32>` is not an iterator;按值迭代(v本身、v.into_iter())交出的是 owned 值,报「expectedIntoIter<i32>to be an iterator that yields&_」;v.iter_mut()交出来的是&mut T,同样报「yields&mut i32」。自定义类型给&Self实现IntoIterator<Item = &T>,&v与.into_iter()都行。元素类型必须满足对应转换符的 trait 约束,否则是编译错误。 - 临时值可以,跨语句的不行:实参就写在
render_with那次调用的实参表里,临时值活到本语句结尾就够 ——[1, 2, 3].iter()、&[1, 2, 3]、[get_point(), Point { .. }].iter()都能直接写。函数返回值这类活不过语句的东西仍得先绑成变量(let v = f(); println!("{:d*}", &v))。 - width / precision / flags 修饰的是每个元素:写法在
*之前,但落在元素那条bprint!的格式串上,含义完全由 C99 定 ——{:8d*, }是「每个元素右对齐到 8 列」,{:.4d*, }是「每个元素至少 4 位数字」,{:.3s*, }是「每个元素最多 3 字节」,{:08d*, }是「每个元素零填充到 8 列」。字面值与*一视同仁:{:*d*, }的宽度来自实参,{:*.*d*, }的宽度与精度各取一个实参,都按 C 消费变参的顺序(宽度、精度)排在元素前面 —— 它们只求值一次:宏先把它们绑成局部变量,元素那条bprint!引用的是变量,不是把表达式重写进每个元素。外层恒为%.*s,只负责把拼好的整段按长度输出 —— 那里没有 width / precision / flags 的位置,也不需要有。 snprintf由使用者提供:容器渲染依赖snprintf,而它和dprintf一样不在 hifmt 里,由调用方自己声明(见上面的示例)。提供不了就编译不过 —— 不用容器、也不写缓冲区时完全不需要它。{:b*}也能当容器:元素按{:b}渲染,每个元素就是一次to_buf()。自定义类型要按容器输出时不必额外做什么,实现了ToBuffer即可。
三、隐私数据的输出控制
华为 OHOS hilog 的%{public}s/%{private}.*s、Apple os_log 的%{private}.*s,要求在转换说明里标注数据敏感级。hifmt 自己不加——哪个实参算私密是各家的策略,不该由 hifmt 猜。
本章分两节:怎么把策略接进来(3.1),以及注解之外怎么真正管住出口(3.2)。
3.1 用策略宏加隐私注解
业务代码不直接调cformat!,而是走一层自己写的业务宏,策略路径与 sink 都写在它的宏体里。要加注解就把策略宏的路径写在cformat!最前面:
text
macro_rules! log {
($($t:tt)*) => { ::hifmt::cformat!($crate::SecPolicy, crate::dprintf(1), $($t)*) };
}
log!("open {:s} failed: {:s}\n", "/etc/hosts", id.to_private());
// C 格式串: "open %.*s failed: %{private}.*s\n"
于是宏把每个实参表达式最外层调用的名字记下来(secret.to_private()记to_private,cc::to_public(x)记to_public),在格式串里那个位置生成一次SecPolicy!(to_private)查表。变量/字段/字面量这些识别不出调用名的实参也查一次,用的是空键。属性文本由策略宏查出来,再被concat!在编译期接进 C 格式串,运行时不做任何拼接;注解落在%与其余修饰之间:
text
%{属性}<flags><width><precision><转换符> 例如 %{private}-10.*s
策略宏是你自己写的普通macro_rules!——名字、可见性、要不要导出都归你;表则交给hifmt::policy!查,方括号里装的就是cformat!传进来的名字:
text
// `$($t)*` 把 cformat! 传进来的名字原样转过去; #[macro_export] 是为了别的 crate 也能按路径
// 引用到它 —— 业务宏里写 `$crate::SecPolicy` 就行, 不用知道 crate 叫什么.
#[macro_export]
macro_rules! SecPolicy {
($($t:tt)*) => {
hifmt::policy! {
to_private => "{private}",
to_public => "{public}",
_ => "{private}",
[ $($t)* ]
}
};
}
policy! 拿方括号里那个名字去表里找,找到就展开成对应文本;表里没有就落到缺省项_ => "文本"上;连缺省项也没写,才是空串(等于不加注解)。识别不出调用名的实参传过来的是空的一对方括号,空键在表里当然找不到,于是直接落到缺省项上——「认不出名字」也是一种「不在表里」。名字怎么定、文本是什么全在策略这边,hifmt 一个属性名都不认识。
缺省项是通配的——宏认得出的调用名远比表里列的多,path.display()记的是display、s.trim()记的是trim,识别不出调用名的变量、字面量更是全都查到policy!这儿来。所以写不写_是在两种策略之间选:
- 不写:只有你明确列出的名字才加注解,其余一条不加,下游自己怎么定就怎么定;
- 写:给「没列到的一切」定一条兜底策略,比如
_ => "{private}"表示默认当私密、明确公开的才写to_public。
后一种更稳,但要注意兜底会盖住所有没识别出来的实参,所以缺省项要往严的那边写——见 3.2 里「只能识别最外层调用名」那一段。
二次开发方的 crate 要写的东西就上面这些,外加标注类型。examples/policy.rs里策略宏与业务宏都写全了,cargo run --example policy 就能跑;想看展开后的真实代码,用 cargo expand(需要先 cargo install cargo-expand):
text
cargo expand --example policy
rust
// 和别的宏一样, C 函数由**调用方**声明在 crate 根上.
extern "C" {
fn dprintf(fd: i32, format: *const u8, ...) -> i32;
}
// 隐私开关: 打开后 to_private 只交出占位符. 真项目里这个值来自构建配置(--cfg / feature),
// 编译期就定死; 写成 const 时分支会被折掉, 运行时零开销.
const PRIVACY_ON: bool = true;
// 隐私数据: **不实现任何可格式化输出的 trait**, 想输出只能走下面两个标注方法.
// 于是 `log!("{:s}", id)` 编译不过, 「该标而忘了标」在类型层面就不成立.
struct Uuid(&'static str);
impl Uuid {
// 返回的是普通 `&str`, 它实现 ToStr; 而 Uuid 自己一个格式化 trait 都不实现.
// 真值由这里决定: 开关一开就只交占位符.
fn to_private(&self) -> &str {
if PRIVACY_ON { "<private>" } else { self.0 }
}
fn to_public(&self) -> &str {
self.0
}
}
// 名字表: 最外层调用名 → C 转换说明里 `%{...}` 中间那段文本.
// 策略宏是你自己写的普通 macro_rules!, `$($t)*` 把 cformat! 传进来的名字原样转给
// policy! 去查 —— 查到就展开成对应文本; 表里没有就落到缺省项 `_` 上; 连 `_` 也没有才是空串.
// 识别不出调用名的实参传过来的是空方括号, 空键当然查不到, 于是也落到 `_` 上.
// 这里缺省项写 `{private}`: 没列到的一律当私密, 漏标注最多打不出东西, 不会反过来
// 把私密数据放出去.
#[macro_export]
macro_rules! SecPolicy {
($($t:tt)*) => {
hifmt::policy! {
to_private => "{private}",
to_public => "{public}",
_ => "{private}",
[ $($t)* ]
}
};
}
// 业务宏: 策略与 sink 都写在宏体里, 上层业务代码看到的就这一层.
// $crate::SecPolicy 而不是裸 SecPolicy: $crate 展开成定义本宏的 crate, 宏搬到别的 crate
// 也照样成立; 裸写则要求调用方也把它导进作用域, 且调用方自己有个同名宏时会优先匹配后者.
macro_rules! log {
($($t:tt)*) => {
::hifmt::cformat!($crate::SecPolicy, crate::dprintf(1), $($t)*)
};
}
// 面向不认注解的下游(如 libc)的入口: 不设策略, 走最普通的 `%.*s`.
macro_rules! plain_log {
($($t:tt)*) => {
::hifmt::cformat!(crate::dprintf(1), $($t)*)
};
}
fn main() {
let id = Uuid("123e4567-e89b-12d3-a456-426614174000");
// 展开出的 C 格式串: "open %.*s failed: %{private}.*s\n"
log!("open {:s} failed: {:s}\n", "/etc/hosts", id.to_private());
// 标注为公开的走同一层包装, 只是查表查到的属性不同.
// 展开出的 C 格式串: "%{public}.*s\n"
log!("{:s}\n", id.to_public());
// 注解之外的那一层: 不设策略的入口, 一个注解没有, 脱敏照样生效 ——
// 打出来是 "<private>", 真值没进过格式串.
plain_log!("{:s}\n", id.to_private());
// 表里没列到的一律落到缺省项上. 两个实参都算「没列到」: 字面量根本认不出最外层调用名
// (查的是空键), `trim` 认得出来但表里没有它 —— 都是 `_ => "{private}"`.
// 展开出的 C 格式串: "%{private}.*s %{private}.*s\n"
log!("{:s} {:s}\n", "/etc/hosts", "/etc/hosts".trim());
}
要点:
- 业务宏:策略路径和 sink 都写在宏体里,调用点就只剩格式串与实参。换 sink(比如 hilog 那种
fmt前面还有 level / tag 的接口)只改这一处——cformat!那两个可选项本来就是给这种包装留的口子。 - 注解文本不能含
%或 NUL:前者会被 printf 当成下一条转换说明的开头,后者会让 C 字符串提前截断。{/}是允许的——%{private}那对大括号就是要的。名字重复报错,缺省项写两条也报错,空表合法(一条注解都不加)。 - 策略宏就是个普通
macro_rules!:名字、可见性、要不要导出都归你。hifmt::policy!只管查表,不替你生成宏。别的 crate 要用就加#[macro_export],业务宏里引用它一律写**$crate::SecPolicy——$crate展开成定义宏的那个 crate,宏体搬到哪个 crate 就跟到哪个 crate。裸写SecPolicy只在「调用方也把它导进了作用域」时才成立:#[macro_export]的宏不会**自动跨 crate 可见,调用方自己再有个同名宏还会被优先匹配到。同一条道理,业务宏里的cformat!写::hifmt::cformat!(前导::走 extern prelude,调用方有个叫hifmt的模块也抢不走);而crate::dprintf不带$crate——macro_rules!里的crate::按调用方作用域解析,sink 由调用方声明。 - 策略表写成编译期常量,不要做成环境变量:环境变量的变化不参与增量依赖,会出现「配置改了、编译过了、二进制里还是旧展开」。要做成可配置的,用
rustflags传--cfg,构建系统会把它算进命令行。 - 策略宏放在哪个 crate 都行:跟调用方放同一个 crate(
examples/policy.rs就是这么写的),或者单独建一个普通 lib crate,deps指到 hifmt 与它即可。
3.2 管住出口: 不实现格式化 trait
注解是「告诉下游这段是私密」,有两种情况它管不到。
一是只能识别最外层调用名。宏记的只有最外层调用名,id.to_private().trim()记的是trim,里层的to_private看不见;变量、字面量更是连名字都没有。表里写了缺省项的话,这些实参全都落进缺省项——to_private那一条管不到它们,所以缺省项要往严的那边写。这一层由类型兜住:让隐私数据本身不实现ToStr这一族 trait,想输出就只能调to_private/to_public,于是log!("{:s}", id)直接编译不过。标注方法交出来的是普通&str,出了这一步之后随你怎么用 —— 它保证的是「入口唯一」,不是「全程可追踪」。
二是下游不认注解。dprintf/snprintf这些 libc 接口不认识格式串里的%{private},实参给什么就照打什么,只有 hilog / os_log 那类认这套语法的 sink 才会按注解处理。不认注解的下游由标注方法自己挡住:
text
fn to_private(&self) -> &str {
if PRIVACY_ON { "<private>" } else { self.0 }
}
开关一开,交给调用的是占位符而不是真值。两层分工 —— 注解告诉认它的下游「这段是私密」,脱敏保证不认它的下游也拿不到真值。开关写成const时分支会被折掉,运行时零开销。
上面那份examples/policy.rs两层都写了:Uuid 一个格式化 trait 都不实现,to_private 打开开关就只交出占位符;cargo run --example policy 就能跑。
四、派生宏
自定义类型不必手写 impl:六个 trait 各有一个同名派生宏。use hifmt::ToNumber; 一次把 trait 和派生宏都导进来(一个在类型命名空间、一个在宏命名空间,同名不冲突);写全路径 #[derive(hifmt::ToNumber)] 则不必 use。ToPointer 没有派生宏。
4.1 标量族:ToNumber / ToDouble / ToStr / ToCStr / ToChar
形状就两种 —— 单字段结构体与枚举 —— 一个属性都不用写:
rust
// 宏展开出来的是 `crate::dprintf`, 声明在 **crate 根**上.
extern "C" {
fn dprintf(fd: i32, format: *const u8, ...) -> i32;
}
use hifmt::{ToNumber, ToStr};
#[derive(ToNumber)]
struct UserId(u32); // 单字段结构体: 转发给字段
#[derive(ToNumber)]
struct Wrap<T>(T); // 泛型参数按 std derive 的规矩加约束, 这里是 T: ToNumber
#[derive(ToNumber, ToStr)]
enum ErrCode {
NoEnt,
NoSpc,
} // 无字段枚举: 判别式直接来自语言, 变体名就是字面量; 两个一起派生, 各按各的转换符打
#[derive(ToStr)]
enum Level {
Debug,
Info,
Other(u8),
} // 枚举: 变体名就是字面量; 带数据的变体也只取名字, 载荷忽略
fn main() {
hifmt::println!(
"{:d} {:d} {:d} {:d} {:s} {:s} {:s}",
UserId(7),
Wrap(3_u8),
ErrCode::NoEnt,
ErrCode::NoSpc,
Level::Debug,
Level::Info,
Level::Other(9)
);
// 7 3 0 1 Debug Info Other
}
标量族里只有 ToStr(输出变体名)与 ToNumber(输出判别式)认枚举。ToStr 对带数据的变体也一样,只取变体名、载荷忽略;ToNumber 只认无字段变体,带数据的变体没有 as u64 判别式,报错。要连载荷一起打,走 ToBuffer,见 4.2。一个变体都没有的枚举也照常派生,只是这种类型造不出值:ToStr 打类型名,ToNumber 给 0。
字段是 Box<T> / Rc<T> 这类只有 Deref、自己没实现对应 trait 的类型也转发得动。
4.2 结构体与枚举族:ToBuffer
#[derive(ToBuffer)] 支持结构体与枚举,输出形状照 Rust 的 Debug:具名字段是 名字 { 字段: 值, 字段: 值 },元组字段是 名字(值, 值) —— 大括号内两侧补空格,圆括号内不补。属性都写在 #[hifmt(...)] 里:
| 位置 | 写法 | 作用 |
|---|---|---|
| 类型 | #[hifmt(unnamed)] |
具名字段的值前面不加 字段名: 标签(标签是缺省行为) |
| 类型 | #[hifmt(sep = "字面量")] |
字段之间的分隔串,缺省 ", " |
| 变体 | #[hifmt(unnamed)] / #[hifmt(sep = "字面量")] |
同上,但只覆盖这一条变体,缺省继承类型上的 |
| 字段 | #[hifmt("转换符")] |
这个字段怎么格式化,例如 #[hifmt("{:x}")];不写就按字段类型推断 |
| 字段 | #[hifmt(skip)] |
这个字段不参与输出,也不必实现任何 trait |
几个键可以写在同一个 #[hifmt(...)] 里,也可以拆成几条。标签与字段片段各管各的:前者只管加不加 字段名: ,后者只管值怎么格式化。跳过的字段不算在分隔串之间,也不占标签。元组字段永远不标标签 —— 名字已经印在括号前面了 —— 所以 unnamed 对它们不起作用。
一个字段都没渲染出来时括号整个消失,只剩名字:struct Unit; 打 Unit,字段全被 skip 掉的结构体打类型名。
枚举按变体走:无字段变体只打变体名,带数据的变体打「变体名 + 载荷」,载荷用上面那套字段规则。字段全被 skip 掉的变体与无字段变体一样,只出变体名;一个变体都没有的枚举直接输出类型名。
rust
// 派生出的 `to_buf` 靠 `snprintf` 拼接, 所以要声明在 crate 根上.
extern "C" {
fn dprintf(fd: i32, format: *const u8, ...) -> i32;
fn snprintf(buf: *mut u8, len: usize, format: *const u8, ...) -> i32;
}
#[derive(hifmt::ToBuffer)]
#[hifmt(sep = " | ")]
struct Record {
id: u32,
#[hifmt("{:x}")]
mask: u32,
// 不写片段就按字段类型推断; `Option<T>` / `Result<T, E>` 按内层推断, 这里是 `{:u}`.
retries: Option<u32>,
// 跳过的字段连 impl 都不必有: `Vec<u8>` 一个格式化 trait 都没实现.
#[allow(dead_code)]
#[hifmt(skip)]
payload: Vec<u8>,
}
/// 枚举: 变体名 + 载荷; 无字段变体只打变体名.
#[derive(hifmt::ToBuffer)]
enum Event {
Tick,
Moved { dx: i32, dy: i32 },
Resized(u32, u32),
}
fn main() {
// Record { id: 7 | mask: ff | retries: 3 }
hifmt::println!(
"{:b}",
Record {
id: 7,
mask: 255,
retries: Some(3),
payload: Vec::new()
}
);
// 输出: Tick / Moved { dx: 1, dy: 2 } / Resized(80, 24)
hifmt::println!("{:b}", Event::Tick);
hifmt::println!("{:b}", Event::Moved { dx: 1, dy: 2 });
hifmt::println!("{:b}", Event::Resized(80, 24));
}
不写片段的字段按类型推断转换符。推断只看类型名的最后一段,是语法层面的,所以类型别名不认 —— type Id = u32; 的最后一段是 Id,得自己写片段:
| 字段类型 | 转换符 |
|---|---|
i8 i16 i32 i64 i128 isize bool |
{:d} |
u8 u16 u32 u64 u128 usize |
{:u} |
f32 f64 |
{:f} |
char |
{:rc} |
&str &CStr String CString |
{:s} |
&[u8] &[i8] &[u8; N] &[i8; N] [u8; N] [i8; N] |
{:s} |
Option<T> 与 Result<T, E> 按内层类型推断(Option<u32> 是 {:u},Result<&str, E> 是 {:s}),嵌套几层也一样。
- 表外的类型(泛型参数、别名、自定义类型)必须写片段,不静默兜底。
- 片段里正好一个转换符,且不写实参引用(
{x}):字段的值就是实参,转换符写成{:x}这样;前后可以有字面文本({:d}ms)。片段写坏了在#[derive]处就报错,不拖到展开。 - 容器字段:集合字段(
Vec<T>、切片等)直接渲染。片段里必须含容器转换符({:u*, }/{:b*, }),它前后的字面文本(例如包一层[])写成什么完全由你定、原样输出:#[hifmt("{:u*, }")] ids: Vec<u64>→1, 2, 3#[hifmt("[{:u*, }]")] ids: Vec<u64>→[1, 2, 3]#[hifmt("{:b*, }")] items: Vec<T>→ 逐元素走元素自己的ToBuffer
- 裸指针字段推断不出转换符:它只走
cs,写#[hifmt("{:cs}")]表明它是 C 字符串;cs要求实参自带unsafe块,派生代码会替你给这个字段的实参包上unsafe。 - 片段里的转换符决定给类型参数加哪个约束:
#[hifmt("{:d}")] T加的是T: ToNumber。 - 标签只给具名字段加;元组字段不标标签 —— 名字(类型名 / 变体名)已经印在括号前面了,所以
unnamed对它们不起作用。一个字段都没渲染出来时括号整个消失,只剩名字。 - 枚举按变体走:无字段变体只打变体名,带数据的变体打「变体名 + 载荷」;字段全被
skip掉的变体也只剩变体名,一个变体都没有的枚举直接打类型名。变体上的unnamed/sep只覆盖这一条变体,缺省继承类型上的。 - 用派生宏的类型要由调用方声明
snprintf(同{:b}本身)。
覆盖全部场景的样例在 examples/derive.rs,cargo run --example derive 就能跑。
feature
| feature | 默认 | 说明 |
|---|---|---|
alloc |
关 | 提供 String / CString 的 ToStr 实现 |
std |
关 | 蕴含 alloc |
不开任何 feature 就是纯 core,#![no_std]。容器不依赖 alloc(缓冲在栈上),Vec<T> 能不能当容器实参取决于你那边有没有 alloc,与 hifmt 的 feature 无关。派生宏的容器字段同理:字段类型是 Vec<T>,alloc 由调用方提供。
