属性:让编译配置、诊断与文档可审查
课程概览 · 第 15 章
属性是写给编译器和工具链的结构化指令。它可以让一段代码只在某个目标或 feature 下存在,让类型获得 trait 实现,调整 lint 的诊断级别,或把 API 说明交给 rustdoc。属性不替代正常的设计判断;它的价值在于把构建、诊断与文档规则明确放进源码。
学习目标与默认选择
学完本篇,你应当能够:
- 区分外部属性
#[...]与内部属性#![...]的作用范围; - 理解
derive生成 trait 实现的前提与代价; - 区分删除代码的
#[cfg]和计算布尔值的cfg!; - 理解自定义属性由过程宏、派生辅助属性或工具消费,而非任意元数据;
- 用测试属性、
must_use和局部 lint 配置表达可审查的意图; - 知道何时应该使用文档、布局、代码生成和 API 演进属性,何时应当保持默认。
默认选择是:属性范围越小越好;先修复 lint,再局部豁免;只为确实成立的语义派生 trait。
属性:给编译器的结构化指令
属性以 #[...] 或 #![...] 书写。外部属性 #[attr] 作用于紧随其后的项目、字段、语句或表达式;内部属性 #![attr] 写在模块或 crate 的开头,作用于所在容器。#![warn(...)] 会影响整个 crate,#[allow(...)] 可以只包住一个函数,这就是二者作用域的本质区别。
1 | |
属性并不都是同一种机制:有些由编译器识别(cfg、derive、repr、lint),有些由过程宏 crate 定义(例如 #[tokio::main]、#[serde(rename_all = "camelCase")])。读属性时先问两个问题:它在改变编译条件、诊断或表示,还是在请求某个宏生成代码?
#[derive]:生成 trait 实现,而不是魔法能力
derive 为类型自动生成 trait 实现。它最适合字段语义与目标 trait 完全一致的数据类型:调试打印用 Debug,测试值比较用 PartialEq/Eq,键类型用 Hash,显式复制用 Clone,小而简单的按位复制类型才考虑 Copy。
1 | |
派生有明确的前提。TaskId 能 Copy,是因为 u64 可以 Copy,且复制 ID 与资源所有权无关;包含 String、Vec、文件句柄或互斥锁的类型不能随意 Copy。派生 Clone 会按字段克隆,可能分配或复制大量数据,不能把它当成“免费的修复借用错误”的工具。
Debug 的输出用于开发和诊断,不是稳定的用户显示格式;面向用户的文本应该手写 Display。PartialEq/Eq 表达的是“相等”这一领域语义,若类型含有缓存、时间戳或浮点数等不应参与相等性的字段,先定义清楚语义,再手写实现或拆分类型。为 HashMap 键派生 Hash 时也必须保证相等的值产生相同哈希,通常让 PartialEq 和 Hash 覆盖同一组字段。
1 | |
这个例子故意不派生 Hash:一旦你决定 user_id 才是相等语义,若要把 Session 放进哈希集合,就还要手写只哈希 user_id 的 Hash。更清晰的设计通常是让 Session 不充当键,把 TaskId、UserId 这样的值对象当键。
#[cfg] 与 cfg!:删除代码还是计算布尔值
#[cfg(condition)] 是条件编译:条件不成立时,带属性的项目根本不参与后续编译。它适合平台差异、可选 feature 和测试专用代码。
1 | |
cfg!(condition) 则是产生一个编译期布尔常量的普通表达式。两条分支仍必须能通过类型检查,因此它不能用来隔离某个平台不存在的 API。
1 | |
这是最常见的误区:当一侧代码在当前平台根本无法解析时,用 #[cfg],不要用 if cfg!(...)。#[cfg(test)] 同理,它让测试模块只在 cargo test 的测试构建中存在;发布库不会带着这些测试函数。
#[cfg_attr(condition, attr)] 是“条件成立时才附加另一个属性”。它可以避免复制整个项目,例如仅在某 feature 启用时派生额外 trait:
1 | |
Cargo feature 是构建配置的一部分,不是运行时开关。#[cfg(feature = "serde")] 中的 serde 名称必须在 Cargo.toml 的 [features] 中声明;编译后不能通过环境变量让被删掉的代码重新出现。
自定义属性:过程宏的声明式入口
这里的“自定义”是指由库或工具定义、供编译期消费的属性,不是给任意代码附加一段可随意读取的元数据。稳定 Rust 不接受没有消费者的任意 #[my_attr];常见消费者是属性过程宏、派生宏声明的辅助属性,以及 Clippy 等工具的属性。
属性过程宏必须定义在独立的 proc-macro crate 中,业务 crate 通过依赖使用它。属性名建议带 crate 路径,例如 #[audit_macros::audited],以避免与其他宏或内置属性混淆。
最小的属性过程宏接收两段 TokenStream:括号中的参数和被标记的项目;返回的 TokenStream 会替换原项目。下面的透明实现只演示协议,实际宏会解析参数、校验输入并生成附加代码。
1 | |
1 | |
调用端的参数只是 token,具体语义完全由宏定义;Rust 只检查属性的外层语法。宏应为未知键、不合法值或错误的标记目标报告编译错误,而不是悄悄忽略拼写错误。
1 | |
属性宏在类型检查之前改写代码,因此不能绕过可见性、借用或类型规则;它生成的最终代码仍须正常通过编译。用 cargo expand 检查展开结果,尤其要确认宏没有意外改变公共 API、错误处理或 async 函数的行为。
派生辅助属性与工具属性
并非所有自定义属性都会直接改写一个项目。派生宏可以通过 #[proc_macro_derive(Describe, attributes(describe))] 声明自己允许的辅助属性;serde 的配置就是典型例子。#[serde(...)] 只有在相应的 derive 宏参与并声明该辅助属性时才有意义,配置键和值也由该宏负责解释。
1 | |
Clippy 的 #[clippy::...] 等则属于工具属性,服务于特定工具的诊断配置,不是应用可以仿造的通用扩展点。使用这类属性时应遵循该工具的文档,并把范围限制在需要它的项目上。
何时设计自定义属性
自定义属性适合把重复的、附着于声明本身的编译期规则集中起来,例如路由注册、序列化字段映射或 FFI 绑定生成。它不适合保存运行时可变配置,也不应掩盖一个本可由类型、显式函数调用或普通 trait 实现表达的设计。
设计属性参数时保持小而可验证的模式:使用命名键,拒绝未知键,给出指向属性位置的诊断;对 feature 可选的宏可用 cfg_attr 条件附加。若宏只是为了少写几行样板代码,却使展开结果和控制流难以理解,普通函数或声明宏通常更清晰。
测试与诊断属性
#[test]、#[ignore] 与 #[should_panic]
测试属性告诉测试运行器如何发现并执行函数。带 #[test] 的无参数函数会成为一个独立测试;它可以返回 (),也可以返回 Result<(), E>,后者用 ? 编写失败路径通常更直接。单元测试一般放在 #[cfg(test)] mod tests 中,使其只在测试构建时编译;集成测试则是 tests/ 下独立的 crate。
#[ignore] 不会删除测试,只是让默认的 cargo test 跳过它。适合依赖外部服务、执行时间很长或等待环境修复的测试;最好写明原因,并在 CI 中为它安排明确的执行入口。cargo test -- --ignored 只执行被忽略的测试,cargo test -- --include-ignored 则连同普通测试一起执行。长期被忽略的测试不是稳定的测试策略。
#[should_panic] 要求测试因 panic 通过;expected = "..." 匹配 panic 信息中的子串,应尽可能具体。它只适合断言不可恢复的编程错误或不变量。可预期的用户输入、网络或磁盘失败应返回并断言 Result::Err,否则测试只知道“发生过 panic”,却无法检查错误值和恢复行为。
1 | |
#[must_use]:把容易忽略的结果变成诊断
当一个函数的返回值被丢弃通常意味着 bug,可在函数或类型上加 #[must_use]。标在函数上只约束这次调用的结果;标在类型上则约束所有产生该类型的表达式,适合“待提交的事务”“必须轮询的 future”或“需要显式处理的决定”等值。属性后的字符串会成为诊断提示,因此应说明调用者下一步该做什么。
must_use 只产生编译期警告,不会改变运行时行为,也不会强迫调用者选择某一种处理方式。确认忽略合理时,写出 let _ = operation();;若希望执行并传播失败,用 operation()?;。不要为了消除警告盲目绑定到以下划线开头的变量,那会让真正应该处理的值悄悄丢失。
1 | |
标准库的 Result 和 Option 本身已带有 must_use 语义,因此普通返回 Result 的函数通常无需重复标注。更值得标记的是 builder 的中间对象、必须提交的事务、代表待处理工作的 token 等自定义类型。不要把每个函数都标成 must_use,否则警告会失去信号价值。
lint:修正优先,豁免必须局部且说明原因
lint 是编译器和 Clippy 对可疑代码的诊断规则。allow、warn、deny、forbid 调整的是某组 lint 的级别:allow 不报告,warn 报告但允许构建继续,deny 把命中变成错误,forbid 则禁止任何更内层的属性再把该 lint 降级。它们既能写成 crate 或模块的内部属性,也能附在项目上;离例外越近,读者越容易判断它是否仍然必要。
lint 名称可以是 unused_variables 这样的 rustc lint,也可以是 clippy::match_single_binding 这样的 Clippy lint。优先修代码;若这是有意为之,再在最小范围内豁免,并紧邻属性说明原因、适用的兼容条件或删除时机。#[allow] 是“这里永远允许”的声明,不能替代待办事项或缺陷跟踪。
1 | |
不建议在 crate 根写 #![allow(dead_code)]。它会淹没真正无用的代码,也会让重构后遗留的分支长期存在。库 crate 常见的更稳妥配置是 #![warn(missing_docs)] 或 CI 中运行 cargo clippy -- -D warnings,但把所有警告升级为错误前,要先确保团队能修复或明确豁免现存警告。
#[expect(lint_name)] 适合你预计某条 lint 会出现的场景。例如,为兼容一个旧接口而临时保留未使用的参数时,可写 #[expect(unused_variables, reason = "等待旧接口下线")]。若将来代码变化导致 lint 消失,编译器会报告未满足的 expectation,提醒你删除过期标记;这比长期 allow 更适合临时兼容或有明确过期条件的例外。reason 让诊断和源码同时保留豁免的上下文。
文档、布局、代码生成与 API 演进属性
下面的文档示例展示了最常用的文档注释;随后分别说明文档、内存布局、代码生成和 API 演进相关的属性。
1 | |
#[doc]:让 rustdoc 收集 API 契约
/// 是 #[doc = "..."] 的语法糖,文档注释会被 cargo doc 收集。公开 API 应优先写文档注释,说明用途、参数、错误和 panic 条件;示例代码还会默认作为 doctest 编译执行。用 cargo test --doc 可以单独运行它们。一个文档块包含多行、条件拼接内容或复用 README 片段时,才需要直接写 #[doc = ...]。
1 | |
#[doc(hidden)] 会把项目从生成的文档索引中隐藏,适合为宏展开或兼容层保留的公开实现细节;它不是访问控制,也不应拿来藏住应当稳定维护的公共 API。文档中的链接、示例和标题也是 API 契约的一部分,改动公开项时应一并检查。
#[repr]:选择布局是 ABI 决策
默认的 repr(Rust) 不承诺字段顺序、填充和跨编译版本的稳定 ABI。#[repr(C)] 让结构体、联合体或枚举采用与 C 互操作所需的布局规则,适用于 FFI 或固定二进制格式;它不会把 String、引用或普通 Rust enum 自动变成 C 可用的数据。边界类型仍要逐字段确认大小、对齐、所有权和有效值。
#[repr(transparent)] 适合围绕一个非零大小字段的 newtype。它保证包装类型与该字段具有相同的布局和 ABI,常用于为 FFI 句柄或数值 ID 增加类型安全。#[repr(u8)]、#[repr(i32)] 等为枚举指定判别值的整数表示,适合协议或 FFI 约定;应显式写出需要稳定的判别值。
1 | |
#[repr(packed)] 会降低对齐要求,读取其中字段可能形成未对齐引用并触发未定义行为;除非在实现经审查的二进制解析或硬件接口,否则不要使用。#[repr(align(N))] 为类型提高对齐,主要用于 SIMD、缓存行隔离等已测量的需求,也会增加对象大小。布局属性解决的是互操作或表示约束,而不是通用性能开关。
#[inline] 与 #[cold]:代码生成提示,而非承诺
#[inline] 提示编译器在跨 crate 优化时考虑内联,#[inline(always)] 是更强的请求,#[inline(never)] 则抑制内联倾向;最终决定仍取决于优化器、编译模式和调用上下文。泛型函数本来就会在使用处单态化,短小函数也常能被自动内联,因此不应把 inline 当成默认装饰。
#[cold] 表示函数极少执行,优化器可据此调整分支预测和代码布局。它适合构造罕见错误、panic 路径或不可达分支,不适合仅仅“看起来不重要”的业务函数。任何这类属性都应在 profiler 已确认热点或冷路径后加入,并保留基准数据而不是凭直觉调参。
#[deprecated] 与 #[non_exhaustive]:维护演进边界
#[deprecated] 仍保留项目可用,但每次使用都会产生诊断。库作者应提供替代 API、since 版本和迁移说明,并在足够长的兼容周期后再考虑删除;把功能立刻变成错误会让下游无法平滑迁移。
1 | |
#[non_exhaustive] 用于公开 enum 或 struct,提前声明“未来可能增加变体或字段”。crate 外的调用者匹配 enum 时必须保留 _ 分支,且不能用结构体字面量完整构造该类型;这为兼容演进留下空间。它会限制下游表达能力,所以只在确实需要保留扩展权时使用,并在文档中说明稳定的构造和匹配方式。
小结:属性是编译期契约
属性把“这段代码何时参与编译”“这个类型具备哪些语义”“哪条诊断可以例外”写成工具能检查的契约。自定义属性还必须明确谁消费它、会生成什么代码。最好的属性通常很小、理由明确,而且没有把本应由类型、函数签名或测试表达的规则藏到编译开关或宏展开之后。
自测
#[cfg]与cfg!的关键差异是什么?
方向:前者移除不适用代码,后者只产生布尔值,两侧仍需通过编译。- 为什么不应把
Clone当作解决借用错误的默认手段?
方向:派生会按字段复制,可能分配或复制大量数据;应先检查所有权和 API 设计。 - 什么情况下应使用
#[must_use]?
方向:忽略返回值通常就是 bug 的自定义类型或操作;不要让它泛滥。 - 为什么
#![allow(dead_code)]通常不是好选择?
方向:它会掩盖真正的死代码;应在最小范围豁免并写明理由。 #[repr(C)]能否作为普通结构体的性能优化?
方向:不能;它主要服务 FFI 或明确布局需求,性能优化应先测量。- 为什么不能把
#[audit]当成任意可保存的业务元数据?
方向:自定义属性必须由过程宏、派生宏或工具消费;属性宏接收 token 并以生成代码替换原项目。






