Skip to content

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}把 Rust char 编码为 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:}仍在写它那一段的作用域里解析,调用方传下来的那一段找调用方的变量:

    text
    macro_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:

    text
    macro_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) 先求值:

    rust
    extern "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 值,报「expected IntoIter<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 由调用方提供。