课程概览 · 第 15 章

上一篇:模块:crate、路径与可见性

下一篇:宏:从声明宏到过程宏的边界

属性把编译条件、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
2
3
4
5
6
7
8
9
10
11
12
#![warn(unused_must_use)]

#[derive(Debug)]
struct Job {
name: String,
}

fn main() {
let job = Job { name: "index".into() };
assert_eq!(job.name, "index");
println!("{job:?}");
}

先问属性在改变编译条件、诊断或表示,还是请求某个宏生成代码。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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct TaskId(u64);

#[derive(Debug)]
struct Session {
user_id: u64,
cached_html: String,
}

impl PartialEq for Session {
fn eq(&self, other: &Self) -> bool {
self.user_id == other.user_id
}
}
impl Eq for Session {}

fn main() {
let id = TaskId(1);
let copy = id;
assert_eq!(id, copy);
let a = Session { user_id: 7, cached_html: "old".into() };
let b = Session { user_id: 7, cached_html: "new".into() };
assert_eq!(a, b);
assert_ne!(a.cached_html, b.cached_html);
}

若让 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[cfg(target_os = "macos")]
fn platform_name() -> &'static str {
"macOS"
}
#[cfg(not(target_os = "macos"))]
fn platform_name() -> &'static str {
"other"
}

#[cfg(any())]
fn unavailable() {
missing_api();
}

fn main() {
assert!(!platform_name().is_empty());
if cfg!(debug_assertions) {
println!("debug build");
}
}

故意失败:编译错误(仅标准库;独立内容放 src/main.rs;预期未选分支中的 missing_api 名称仍需解析,通常为 E0425)。

1
2
3
4
5
fn main() {
if cfg!(any()) {
missing_api();
}
}

cfg_attr 条件成立时才附加另一个属性。Cargo feature 是构建配置,不是运行期开关;代码一旦被移除,不能靠环境变量让它重新出现。

代码片段(第三方语法;放应用 src/lib.rs;没有入口)。需 manifest 的 serde = { version = "1", optional = true, features = ["derive"] },并在 [features] 声明 serde = ["dep:serde"]。完整 Serde 项目见第 16 章,feature 管理见第 20 章。

1
2
3
4
5
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Debug)]
pub struct Report {
pub count: usize,
}

must_use 与 lint:诊断不是不可忽略的类型

must_use 可标记函数或类型。函数属性针对调用返回值,类型属性针对产生该类型的值;提示文本应解释忽略意味着什么。它是可配置 lint:默认通常 warning,deny 时成为错误,显式 let _ = ... 可表明有意忽略。它不改变运行时行为,也不保证业务策略正确。

Result 已有 must_use 诊断;不要把每个返回 Result 的函数重复标记。自定义“决定”“待处理 token”或 builder 结果更适合额外说明。

独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。第二个调用是有意丢弃,不是处理了结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#[must_use = "请使用校验决定"]
#[derive(Debug, PartialEq)]
enum TitleDecision {
Accept,
Reject,
}

fn validate_title(title: &str) -> TitleDecision {
if title.trim().is_empty() {
TitleDecision::Reject
} else {
TitleDecision::Accept
}
}

fn main() {
assert_eq!(validate_title(" "), TitleDecision::Reject);
let _ = validate_title("draft");
}

在上例 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#[derive(Debug, PartialEq)]
pub struct TaskId(u64);

#[derive(Debug, PartialEq)]
pub enum TaskError {
InvalidId,
}

impl TaskId {
pub fn new(value: u64) -> Result<Self, TaskError> {
if value == 0 {
Err(TaskError::InvalidId)
} else {
Ok(Self(value))
}
}
}

fn direct_index(values: &[i32], index: usize) -> i32 {
values[index]
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn invalid_id_is_an_error() {
assert_eq!(TaskId::new(0), Err(TaskError::InvalidId));
}

#[test]
#[should_panic(expected = "index out of bounds")]
fn direct_index_panics() {
let _ = direct_index(&[1], 2);
}
}

上述项目的第二个测试先正常编译,再在执行时触发越界,由 should_panic 判为通过。单独在普通 main 调用相同 direct_index 才是运行期 panic 反例,不要把测试通过误写为没有发生 panic。

ignore 不删除或免编译测试,只让默认测试运行器跳过它。真实外部服务测试可标 #[ignore = "requires a local PostgreSQL instance"],并安排专用环境;本章不提供假装迁移成功的空测试。

代码片段(测试运行命令;在已包含真实 ignore 测试的项目目录执行,依赖该项目的服务与 fixture;不是上述 attribute_tests 自带的数据库测试)。

1
2
cargo +stable test -- --ignored
cargo +stable test -- --include-ignored

自定义属性与消费者

稳定 Rust 不接受任意无消费者的 #[my_attr]。自定义属性来自属性过程宏、派生宏声明的辅助属性或工具。属性过程宏在单独的 proc-macro crate 中接收参数 token 和项目 token,返回 token 替换原项目;这不是运行期可读取的任意元数据。

代码片段(仅协议演示,不是独立审计工程;放 audit_macros/src/lib.rs,该 crate 的 manifest 需 [lib] proc-macro = true;使用标准库 proc_macro,没有业务入口)。透明 audited 只返回输入项目,不提供校验、日志或审计能力。

1
2
3
4
5
6
use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn audited(_attr: TokenStream, item: TokenStream) -> TokenStream {
item
}

代码片段(沿用上述 audit_macros 协议;放依赖它的应用 src/lib.rs,需 manifest 中指向该 proc-macro crate 的 path 依赖;不构成完整项目)。level 参数在这个透明实现中被忽略;生产宏应明确验证自己的参数协议。

1
2
3
4
#[audit_macros::audited(level = "strict")]
pub fn publish(title: &str) -> String {
format!("published: {title}")
}

宏展开后的代码仍需遵守类型、借用与可见性。自定义属性适合序列化字段映射、路由声明和生成绑定等编译期规则,不应隐藏业务控制流。需要真实审计时,应实现并验证审计动作,不以属性名称代替能力。

Serde 的辅助属性由相应 derive 宏声明并消费;Clippy 的工具属性由 Clippy 解释,不是通用扩展点。

代码片段(第三方 Serde 辅助属性语法;放应用 src/lib.rs;需 serde = { version = "1", features = ["derive"] },无入口;第 16 章提供完整运行例)。

1
2
3
4
5
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTask {
pub task_title: String,
}

深入阅读:文档、布局与 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
2
3
4
5
6
7
8
/// 只接受非零任务标识。
///
/// # Errors
///
/// 值为 0 时返回 [`TaskError::InvalidId`]。
pub fn new_task_id(value: u64) -> Result<TaskId, TaskError> {
TaskId::new(value)
}

doc(hidden) 只影响展示,不是访问控制。它不能消除公开项的维护责任,也不应隐藏用户实际需要的 API。

repr 是布局承诺

repr(Rust) 不承诺稳定字段顺序或跨版本 ABI。repr© 采用 C 互操作布局规则,但内部 String、引用或其他 Rust 类型不会自动变成 FFI-safe。repr(transparent) 对满足约束的单一非零大小字段包装提供同布局和 ABI,repr(u8) 可为字段为空的枚举指定整数表示;具体边界还须检查有效值。

代码片段(仅标准库;独立布局定义放 src/lib.rs,无运行入口,也不与上文同名 TaskId 合并)。

1
2
3
4
5
6
7
8
#[repr(transparent)]
pub struct TaskId(u64);

#[repr(u8)]
pub enum TaskState {
Queued = 1,
Done = 2,
}

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 都必须写通配分支。使用它也有下游表达能力的成本,应提供稳定构造方法。

自测

  1. cfg 删除项目为什么不等于可以隐藏任意非法 token?cfg! 又有什么区别?
  2. 同 ID、不同缓存的 Session 应相等时,为什么不能盲目派生 Hash?
  3. must_use 能否被忽略?deny 与显式 let _ 如何影响它?
  4. direct_index_panics 为什么把下标传给函数,而不是直接写 [1][2]?
  5. audited 名称是否说明存在审计能力?non_exhaustive 在 crate 内外相同吗?

参考答案

  1. 代码先要具有可解析语法;cfg 隔离后续语义检查,cfg! 两侧仍检查名字与类型。
  2. 派生 Hash 会覆盖缓存,与仅按 ID 相等不一致;应只哈希 ID 或直接以 ID 为键。
  3. 可以;默认诊断可升为错误,显式 let _ 表明有意忽略并避开该诊断。
  4. 常量越界可能被 unconditional_panic 提前拒绝;函数参数使示例展示运行期检查。
  5. 不说明;透明实现没有审计。non_exhaustive 的演进限制主要施加给外部调用者。