属性:让编译配置、诊断与文档可审查
课程概览 · 第 15 章
属性把编译条件、trait 派生、诊断与文档意图写给编译器和工具。它不是替代正常设计的装饰:每个属性都应说明作用范围、消费者与可观察效果。本章先把内置机制讲清,再区分过程宏协议与真正的业务能力。
前置知识与学习目标
需要理解 trait、模块与 Cargo 的基本目标;创建项目命令可查第 20 章。学完本章应能:
- 区分内外属性作用域与 derive 的语义前提;
- 预测 cfg、cfg!、cfg_attr 对编译的影响;
- 正确使用 must_use、lint 与测试属性;
- 找出自定义属性的消费者,识别布局与 API 演进承诺。
内外属性的作用域
#[attr] 是外部属性,作用于紧随其后的允许该属性的语法位置;#![attr] 是内部属性,作用于所在 crate 或模块等容器。不是每一种属性都可用于所有项目、表达式或语句位置,应依赖各属性的语法约束。crate 根内部 lint 配置影响整个 crate,局部属性只影响对应范围。
独立示例(仅标准库;完整内容放 src/main.rs,Edition 2024;执行 cargo +stable run)。
1 | |
先问属性在改变编译条件、诊断或表示,还是请求某个宏生成代码。cfg、repr 与 lint 是内置机制;Serde 派生、Tokio 入口等依赖过程宏,不能因它们看起来像注释就忽略依赖。
derive 生成实现,不定义领域语义
derive 按字段生成 trait 实现。TaskId 可以 Copy,是因为 u64 可 Copy 且 ID 复制符合语义,而不是因为“小结构体自动可以 Copy”。Clone 按字段调用 clone,String/Vec 可能复制与分配;不能把克隆当成消除借用报错的默认手段。
Debug 用于诊断,不承诺稳定用户格式;用户展示应实现 Display。相等、哈希与顺序必须覆盖一致的语义字段。缓存不参与领域相等时,不应直接 derive PartialEq。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。Session 只按 user_id 相等,缓存可不同;没有派生 Hash,避免错误地把缓存纳入哈希。
1 | |
若让 Session 成为 HashMap 键,需手写只哈希 user_id 的 Hash;通常直接用 ID 当键更明确。浮点 NaN、时间戳与缓存也都要求先决定相等语义,再选择派生。
cfg、cfg! 与 cfg_attr
#[cfg(condition)] 条件不成立时移除适用代码,使其中的名字无需参与后续名称与类型检查;但外围仍须是可解析的 Rust 语法,不能用它包住任意非法 token。cfg! 只是布尔表达式,未选分支仍须解析并通过名称和类型检查。平台不存在的 API 用 cfg 隔离,而不是 if cfg!。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。平台函数成对条件编译;any() 为 false,未定义名字位于被移除的项目中。
1 | |
故意失败:编译错误(仅标准库;独立内容放 src/main.rs;预期未选分支中的 missing_api 名称仍需解析,通常为 E0425)。
1 | |
cfg_attr 条件成立时才附加另一个属性。Cargo feature 是构建配置,不是运行期开关;代码一旦被移除,不能靠环境变量让它重新出现。
代码片段(第三方语法;放应用 src/lib.rs;没有入口)。需 manifest 的 serde = { version = "1", optional = true, features = ["derive"] },并在 [features] 声明 serde = ["dep:serde"]。完整 Serde 项目见第 16 章,feature 管理见第 20 章。
1 | |
must_use 与 lint:诊断不是不可忽略的类型
must_use 可标记函数或类型。函数属性针对调用返回值,类型属性针对产生该类型的值;提示文本应解释忽略意味着什么。它是可配置 lint:默认通常 warning,deny 时成为错误,显式 let _ = ... 可表明有意忽略。它不改变运行时行为,也不保证业务策略正确。
Result 已有 must_use 诊断;不要把每个返回 Result 的函数重复标记。自定义“决定”“待处理 token”或 builder 结果更适合额外说明。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。第二个调用是有意丢弃,不是处理了结果。
1 | |
在上例 main 增加 validate_title("draft"); 会触发 unused_must_use 诊断;在文件开头加 #![deny(unused_must_use)] 则会拒绝这行。let _ = validate_title("draft"); 仍显式允许忽略。不要为消警告盲目绑定 _result,它可能只是把忘记处理变得更隐蔽。
| lint 等级 | 含义 |
|---|---|
| allow | 不报告该诊断 |
| warn | 报告但通常允许构建 |
| deny | 命中成为错误 |
| forbid | 成为错误,并禁止内层降低等级 |
优先修代码,有意例外应限制范围并写理由。Clippy lint 可用 clippy::... 路径;不要在 crate 根允许所有 dead_code 来隐藏未用模型。#[expect(lint, reason = "...")](Rust 1.81 起)表示预计诊断出现,若以后它消失,会报告 unfulfilled_lint_expectations,提醒删除过期标记。
测试属性
test 注册测试函数;常用无参数函数返回 (),也可返回实现终止契约的 Result。cfg(test) 决定测试模块只进入测试构建。should_panic 要求测试 panic,expected 匹配消息子串;业务校验失败应断言 Err,不应为了满足 panic 测试而 unwrap。
多文件示例(仅标准库;独立 library 项目 attribute_tests/src/lib.rs 完整内容,Edition 2024;先在父目录执行 cargo +stable new --lib --edition 2024 attribute_tests,再在项目目录执行 cargo +stable test)。索引放入函数参数,避免编译器先以 unconditional_panic 拒绝固定越界表达式。
1 | |
上述项目的第二个测试先正常编译,再在执行时触发越界,由 should_panic 判为通过。单独在普通 main 调用相同 direct_index 才是运行期 panic 反例,不要把测试通过误写为没有发生 panic。
ignore 不删除或免编译测试,只让默认测试运行器跳过它。真实外部服务测试可标 #[ignore = "requires a local PostgreSQL instance"],并安排专用环境;本章不提供假装迁移成功的空测试。
代码片段(测试运行命令;在已包含真实 ignore 测试的项目目录执行,依赖该项目的服务与 fixture;不是上述 attribute_tests 自带的数据库测试)。
1 | |
自定义属性与消费者
稳定 Rust 不接受任意无消费者的 #[my_attr]。自定义属性来自属性过程宏、派生宏声明的辅助属性或工具。属性过程宏在单独的 proc-macro crate 中接收参数 token 和项目 token,返回 token 替换原项目;这不是运行期可读取的任意元数据。
代码片段(仅协议演示,不是独立审计工程;放 audit_macros/src/lib.rs,该 crate 的 manifest 需 [lib] proc-macro = true;使用标准库 proc_macro,没有业务入口)。透明 audited 只返回输入项目,不提供校验、日志或审计能力。
1 | |
代码片段(沿用上述 audit_macros 协议;放依赖它的应用 src/lib.rs,需 manifest 中指向该 proc-macro crate 的 path 依赖;不构成完整项目)。level 参数在这个透明实现中被忽略;生产宏应明确验证自己的参数协议。
1 | |
宏展开后的代码仍需遵守类型、借用与可见性。自定义属性适合序列化字段映射、路由声明和生成绑定等编译期规则,不应隐藏业务控制流。需要真实审计时,应实现并验证审计动作,不以属性名称代替能力。
Serde 的辅助属性由相应 derive 宏声明并消费;Clippy 的工具属性由 Clippy 解释,不是通用扩展点。
代码片段(第三方 Serde 辅助属性语法;放应用 src/lib.rs;需 serde = { version = "1", features = ["derive"] },无入口;第 16 章提供完整运行例)。
1 | |
深入阅读:文档、布局与 API 演进
doc 与 rustdoc
/// 是 doc 属性的语法糖,公开 API 应记录用途、错误、panic 与安全前提。库文档中的 Rust 示例通常作为 doctest 编译执行,cargo +stable test --doc 单独运行库 doctest;Cargo 默认不为 bin 目标运行 doctest。#[doc = include_str!("../README.md")] 需真实存在的文本文件与合法的目标项目,不是自动把任意 README 变成正确契约。
代码片段(仅标准库;追加到上文 attribute_tests/src/lib.rs;沿用该库 TaskId 与 TaskError;执行目录 attribute_tests/,cargo +stable doc 构建文档)。
1 | |
doc(hidden) 只影响展示,不是访问控制。它不能消除公开项的维护责任,也不应隐藏用户实际需要的 API。
repr 是布局承诺
repr(Rust) 不承诺稳定字段顺序或跨版本 ABI。repr© 采用 C 互操作布局规则,但内部 String、引用或其他 Rust 类型不会自动变成 FFI-safe。repr(transparent) 对满足约束的单一非零大小字段包装提供同布局和 ABI,repr(u8) 可为字段为空的枚举指定整数表示;具体边界还须检查有效值。
代码片段(仅标准库;独立布局定义放 src/lib.rs,无运行入口,也不与上文同名 TaskId 合并)。
1 | |
repr(packed) 降低对齐,形成未对齐字段引用可能导致 UB;repr(align(N)) 提高对齐,也可能增加大小。这些用于明确的互操作或表示约束,不是普通结构体的通用性能开关。
inline、cold、deprecated 与 non_exhaustive
inline、inline(always)、inline(never) 是代码生成意图;不要把强请求当作固定性能保证。cold 告诉优化器路径很少执行,应基于实际工作负载,而不是“这函数看起来不重要”。先用第 21 章的同 profile 测量,再决定是否添加这些属性。
deprecated 保留 API 可用并产生诊断;属性可含 since 与 note,说明替代方式与兼容周期。non_exhaustive 给公开类型预留扩展空间:标在 enum 时,crate 外匹配需兜底;标在 struct 或变体时,还会限制外部字面量构造。定义 crate 内仍可以穷尽匹配,所以它不是让所有 match 都必须写通配分支。使用它也有下游表达能力的成本,应提供稳定构造方法。
自测
- cfg 删除项目为什么不等于可以隐藏任意非法 token?cfg! 又有什么区别?
- 同 ID、不同缓存的 Session 应相等时,为什么不能盲目派生 Hash?
- must_use 能否被忽略?deny 与显式 let _ 如何影响它?
- direct_index_panics 为什么把下标传给函数,而不是直接写
[1][2]? - audited 名称是否说明存在审计能力?non_exhaustive 在 crate 内外相同吗?
参考答案
- 代码先要具有可解析语法;cfg 隔离后续语义检查,cfg! 两侧仍检查名字与类型。
- 派生 Hash 会覆盖缓存,与仅按 ID 相等不一致;应只哈希 ID 或直接以 ID 为键。
- 可以;默认诊断可升为错误,显式 let _ 表明有意忽略并避开该诊断。
- 常量越界可能被 unconditional_panic 提前拒绝;函数参数使示例展示运行期检查。
- 不说明;透明实现没有审计。non_exhaustive 的演进限制主要施加给外部调用者。






