std 是 Rust 默认提供的标准库 crate。它不只是若干容器和工具函数:从 Option、Result、迭代器、格式化,到文件、网络、线程、进程和平台路径,日常 Rust 程序的大多数基础能力都从这里开始。而这些能力背后大多是 trait 的约定——#[derive]、==、for、?、+、println!、文件读写与线程边界,背后都是标准库 trait。正确的使用方式不是背模块名和 trait 名,而是先识别问题属于数据建模、trait 语义、I/O、并发还是平台边界,再选择最小且语义匹配的 API。

本文以稳定版、Edition 2024 为基线,示例只依赖标准库。API 是否可用仍应以项目的 MSRV 和本机工具链文档为准。trait 的抽象边界、静态分发与 dyn Trait 见泛型与 trait;unsafe 与 Send / Sync 的安全契约见Unsafe。

先建立边界:语言、core、alloc 与 std

Rust 的“基础设施”分四层。它们不是互斥的四套 API,而是由下向上增加能力;std 会重新导出许多下层类型,所以普通应用通常只需写 std::...。

层可依赖的能力典型内容适用场景
语言与编译器语法、类型检查、借用检查、原生类型i32、bool、str、引用、闭包所有 Rust 代码
core不要求堆分配、操作系统或标准运行时Option、Result、Iterator、Future、fmt固件、内核、no_std 库
alloc在 core 上增加全局分配器Vec、String、Box、Rc可分配但没有完整 OS 的环境
std在 core / alloc 上增加平台与运行时抽象文件、网络、线程、进程、环境变量默认的命令行、服务端、桌面程序

默认情况下,编译器会为 crate 提供 std,并把 Rust prelude 放进每个模块的作用域。因此普通应用可以直接使用 Vec、String、Option 和 Result。这不代表它们“属于语言”;其中大多数由下层库定义并由 std 暴露。

#![no_std] 会移除这条默认路径。此时可直接依赖 core;若目标具备全局分配器,还可显式使用 alloc:

1
2
3
4
5
6
#![no_std]

extern crate alloc;

use alloc::vec::Vec;
use core::fmt;

no_std 二进制还需要目标相关的启动代码、panic 处理和(如使用 alloc)全局分配器。不要为了缩小依赖而机械移除 std:只要程序需要文件、网络、线程、进程或宿主环境,std 通常就是正确边界。

名称为什么有时不用 use

Rust 有三种常见的名称来源:

  1. 完整路径:std::collections::HashMap。最明确,适合偶发使用或文档说明。
  2. 显式导入:use std::collections::HashMap;。适合模块稳定依赖的名称。
  3. 自动导入的 prelude:只包含高频、基础的名称,不是整个标准库。

prelude 是编译器自动放入每个模块作用域的一组名称。它让常用 trait 的方法可直接解析,但不代表所有标准库 trait 都无需 use:Clone、Iterator、From 已在 prelude;Display、Read、Write、Add 和 Error 仍需从对应模块显式导入。Edition 2024 的 prelude 还包含 Option / Some / None、Result / Ok / Err、Vec、String,以及 Default、TryFrom、IntoIterator、Future 等常用 trait。标准宏也默认可用,例如 println!、format!、vec!、assert!、matches!。

下列名称仍需要自己导入:

需要显式导入原因与常见位置
HashMap、BTreeMap、VecDequestd::collections 中的具体容器不是 prelude 内容
Path、PathBuf、File路径和文件是平台 / I/O 边界,必须显式表达依赖
Read、Write、BufReadtrait 必须在作用域内,才能调用其提供的方法
Display、Error、Add并非所有模块都需要的 trait
Duration、Instant、Mutex、Arc时间和并发同步原语

按来源给常用 trait 建立一张地图,后续选型时按图索骥:

来源高频 trait主要出现的位置
prelude:数据与比较Clone、Copy、Debug、Default、PartialEq、Eq、PartialOrd、Ordderive、比较、排序、调试
prelude:转换与遍历From、Into、TryFrom、TryInto、AsRef、AsMut、ToOwned、ToString、Iterator、IntoIteratorAPI 参数、for、collect
prelude:编译器边界Sized、Send、Sync、Unpin、Drop、Fn、FnMut、FnOnce泛型 bound、闭包、并发、析构
std::fmt / std::opsDisplay、Write、Add、Index、Deref格式化、运算符、智能指针
std::borrow / std::convertBorrow、BorrowMut、From、TryFrom异构查找、边界转换
std::io / std::errorRead、BufRead、Write、Seek、Error文件、网络、错误链
std::futureFutureasync / .await 的异步计算协议

不要通过“能否省略 use”判断一个类型的重要性。prelude 的目标只是减少几乎所有模块都会出现的样板代码;它会随 Edition 增补少量名称,但刻意保持很小。

从一个只用 std 的程序认识模块边界

下面的命令行程序读取一个文件,统计空白分隔的单词,并把结果写到标准输出。它涵盖参数、非 Unicode 路径、文件 I/O、错误传播、标准输出锁和格式化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;

fn main() -> io::Result<()> {
let path = match env::args_os().nth(1) {
Some(arg) => PathBuf::from(arg),
None => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: count <file>",
));
}
};

let content = fs::read_to_string(&path)?;
let words = content.split_whitespace().count();

let stdout = io::stdout();
let mut out = stdout.lock();
writeln!(out, "{}: {words} words", path.display())?;
Ok(())
}

这里的边界值得逐项看清:

  • args_os() 返回平台原生的 OsString,不会因文件名不是 UTF-8 而失败;若命令参数本来必须是 Unicode 文本,才选 args()。
  • PathBuf 拥有路径;fs::read_to_string(&path) 只借用它,并在读取失败时返回 io::Error。
  • ? 把 io::Result 中的失败直接交给 main,没有用 unwrap() 把可预期的文件错误变成 panic。
  • stdout().lock() 让一段连续输出持有同一把输出锁;writeln! 通过 io::Write 返回可处理的 I/O 错误,而不是把输出错误隐藏掉。

这也是查标准库的实用顺序:先从问题的资源边界开始,再沿着类型和 trait 跳转,而不是从模块目录逐个浏览。

高频模块地图

标准库模块很多,但大部分业务代码集中在下面几组。先看这一张地图,再进入单个 API 的 rustdoc 页面,效率最高。

问题首选模块 / 类型首先确认的约束
缺失、失败、比较、转换option、result、cmp、convert、error缺失是正常分支,还是需要向上报告的失败?
连续数据和文本Vec<T>、[T]、String、str、slice是否需要拥有、修改或增长数据?
按键、去重、有序、队列collections是否需要稳定顺序、范围查询或优先级?
惰性数据变换iter、IntoIterator是借用、可变借用,还是消费元素?
格式化与文本解析fmt、str、string面向用户还是调试;转换会不会分配?
文件、字节流、终端path、fs、io是路径操作、文件系统操作,还是通用 reader / writer?
配置、子进程、时钟env、process、time输入是否为 Unicode;需要墙上时间还是耗时测量?
线程和共享状态thread、sync数据如何移动、共享、取消和回收?
TCP、UDP、套接字地址net是否接受阻塞 I/O;是否还需要 HTTP、TLS 或异步运行时?
OS / C 边界ffi、os文本是否可假设 UTF-8;资源句柄的所有权在哪里?
低层控制mem、ptr、pin、alloc、panic能否先用安全的高层 API;安全不变量是什么?

其中 option、result、iter、cmp 等很多能力实际来自 core;fs、net、process、thread 等则依赖宿主平台。这个区别决定了代码能否进入 no_std 环境,也提示了可移植性边界。

数据、集合与迭代:先决定所有权,再选容器

Option、Result 与 Error

Option<T> 表示“可能不存在,但这不是异常”;Result<T, E> 表示“操作可能失败,调用方必须面对失败原因”。不要用空字符串、-1 或 null 风格的哨兵值混淆两种语义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::collections::HashMap;

fn count_words(text: &str) -> HashMap<&str, usize> {
let mut counts = HashMap::new();

for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}

counts
}

fn main() {
let counts = count_words("rust std rust");
assert_eq!(counts.get("rust"), Some(&2));
assert_eq!(counts.get("missing"), None);
}

这个函数没有为单词分配新的 String:map 的键是借用 text 的 &str,因此返回值不能活得比输入更久。若索引要脱离原始文本保存,就把键改为 String 并在插入处显式 to_owned();分配发生的位置必须清楚。

错误处理的默认规则也很简单:

情形表达方式例子
正常缺失Option<T>map.get(key)、slice.first()
可恢复失败Result<T, E>读文件、解析整数、建立连接
调用方需要统一错误契约自定义错误类型实现 std::error::Error库的公开 API
代码不变量被破坏panic! / expect()“这个 worker 不应 panic”之类的内部断言

应用入口可以先返回 io::Result<()> 或具体错误类型;库不应因为普通输入、文件不存在或网络断开而 panic。

std::error::Error 负责把错误做成可组合的 trait 对象或错误链,不会替你定义业务语义;错误枚举的变体和上下文仍应由 API 设计者决定。它要求 Debug 与 Display,并可通过 source() 暴露底层原因。库的错误枚举通常实现 From<io::Error> 等转换,让 ? 能将底层错误映射到本领域错误:

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
use std::error::Error;
use std::fmt;

#[derive(Debug)]
enum AppError {
Io(std::io::Error),
}

impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(f, "I/O error: {error}"),
}
}
}

impl Error for AppError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Io(error) => Some(error),
}
}
}

impl From<std::io::Error> for AppError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}

应用可用 thiserror 减少这些样板;库接口仍应明确每个错误变体与 source 链的语义。详见 错误处理。

连续存储:数组、切片、Vec、str 与 String

类型所有权与大小默认用途
[T; N]内联拥有;长度编译期固定固定协议字段、小型缓冲区
[T] / &[T]动态长度切片;通常借用让函数接受任意连续存储的一段数据
Vec<T>堆上拥有、可增长需要在运行期追加、删除或收集元素
str / &strUTF-8 文本切片;通常借用只读文本参数和视图
String堆上拥有、可增长的 UTF-8 文本构建、修改或保存文本

优先让只读函数接受 &[T] 或 &str,而不是 &Vec<T> 或 &String。前者不绑定调用方的容器,也避免暗示函数需要拥有或修改它。

String 按 UTF-8 存储,不能按任意整数下标切出“第 N 个字符”。str::chars() 按 Unicode 标量值迭代,graphemes() 这类用户感知字符分割则不在标准库中,需要专门 crate。二进制数据应使用 &[u8] / Vec<u8>,不要伪装成字符串。

HashMap / HashSet 适合按键查找和去重,但遍历顺序不承诺稳定。日志、测试快照、协议输出或用户界面需要确定顺序时,应排序后输出,或直接选择 BTreeMap / BTreeSet。队列用 VecDeque,优先队列用 BinaryHeap;不要反复对 Vec 调用 remove(0)。

迭代器:惰性组合,终止时才做工作

Iterator 的适配器通常惰性:map、filter、take 只是构造处理管道,直到 collect、sum、find、for 等消费者出现才执行。这样能避免中间 Vec,也把“如何遍历”和“何时分配结果”分开。

1
2
3
4
5
6
7
8
9
10
fn even_ids(ids: &[u64]) -> impl Iterator<Item = u64> + '_ {
ids.iter().copied().filter(|id| *id % 2 == 0)
}

fn main() {
let ids = [1, 2, 3, 4, 5];
let result: Vec<_> = even_ids(&ids).collect();

assert_eq!(result, [2, 4]);
}

for item in value 本质上调用 IntoIterator::into_iter(value),所以循环右侧决定所有权:for x in &items 借用元素,for x in &mut items 可变借用元素,for x in items 通常消费容器。遇到“值在循环后为什么不能再用”的报错时,先检查这一步,而不是先加 clone()。

1
2
3
4
5
6
7
8
9
10
fn main() {
let ids = vec![1, 2, 3];

for id in &ids {
assert!(*id > 0); // 借用元素,ids 仍可用。
}

let doubled: Vec<_> = ids.into_iter().map(|id| id * 2).collect();
assert_eq!(doubled, vec![2, 4, 6]);
}

支撑 for 与 collect 的是一组 trait 协议。Iterator 只要求实现 next,大量适配器和消费方法由默认实现提供;IntoIterator 决定一个值、共享借用或可变借用如何进入 for 循环;FromIterator 则是 collect() 构造目标集合的基础。

trait作用
Iterator产出 Item;map、filter、sum 等建立在 next 上。
IntoIteratorfor value in collection 的入口;要区分 T、&T、&mut T 三种消费方式。
FromIteratorcollect::<Vec<_>>()、collect::<HashSet<_>>() 的目标构造协议。
Extend把一个迭代器的元素原地追加到既有集合。
DoubleEndedIterator / ExactSizeIterator提供反向迭代、精确长度等额外能力;只在承诺可实现时标注。

自定义容器通常应实现三份 IntoIterator:T 产出拥有值,&T 产出共享引用,&mut T 产出可变引用。具体实现与生命周期边界见迭代器与闭包。

Trait:把语义写进接口

标准库的大量能力以 trait 形式提供。阅读和设计时先问三件事:这个 trait 是在描述数据本身的语义、函数所需的能力,还是编译器必须相信的安全性质?三类约定的实现成本和错误后果完全不同。

领域类型:先从可派生 trait 开始

大多数领域类型不需要手写很多 trait。先根据真实用途派生最小集合;只有字段比较规则、文本格式或默认状态具有额外业务语义时,才手写 impl。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::collections::{BTreeSet, HashSet};

#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
struct JobId(u64);

fn main() {
let first = JobId(7);
let second = first; // Copy 后 first 仍可用。
assert_eq!(first, second);

let mut hash_ids = HashSet::new();
hash_ids.insert(first); // 需要 Eq + Hash。

let mut tree_ids = BTreeSet::new();
tree_ids.insert(first); // 需要 Ord。
}
trait承诺的语义默认建议
Debug面向开发者的结构化诊断输出几乎总是 derive。
Clone显式复制一个独立值复制有业务意义时实现;它可能分配并复制堆数据。
Copy赋值、传参时可隐式按位复制仅用于小型、无资源所有权的纯值;它要求同时是 Clone。
PartialEq / Eq相等比较;Eq 还承诺自反、对称、传递需要比较时派生;含 f32 / f64 的类型通常只能是 PartialEq。
PartialOrd / Ord排序;Ord 要求任意两个值都可比较作为 BTreeMap / BTreeSet 键时需要 Ord。
Hash写入哈希状态作为 HashMap / HashSet 键时与 Eq 一起派生。

Debug、Display 与 ToString

Debug 的输出服务日志、断言失败和临时诊断,格式不应被当作稳定协议。Display 则服务面向用户的文本、CLI 或错误信息,通常要手写。实现了 Display 后,标准库的 blanket impl(毯式实现:不针对某个具体类型,而是用一个泛型 impl 覆盖“满足某条件的所有类型”)会自动提供 ToString;不要再为同一类型手写 ToString。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::fmt;

#[derive(Debug)]
struct JobId(u64);

impl fmt::Display for JobId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "job-{}", self.0)
}
}

fn main() {
let id = JobId(7);
assert_eq!(format!("{id:?}"), "JobId(7)");
assert_eq!(id.to_string(), "job-7");
}

不要因为某个类型“可以打印”就实现 Display。例如包含密钥、二进制载荷或内部状态的类型,Debug 也许应脱敏,Display 则可能根本不应存在。

Default:默认值必须可直接使用

Default 不是“所有字段取零值”,而是类型的自然、安全初始状态。配置对象通常适合;已验证的 ID、金额、连接或文件句柄通常不适合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::time::Duration;

#[derive(Debug)]
struct RetryPolicy {
max_attempts: u8,
delay: Duration,
}

impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
delay: Duration::from_secs(1),
}
}
}

..Default::default() 只应覆盖确实可省略的配置字段。若默认值会掩盖业务遗漏,让构造函数要求调用方显式传入更安全。

相等、排序与哈希必须彼此一致

Eq 是 PartialEq 的更强承诺,Ord 是 PartialOrd 的更强承诺。浮点 NaN != NaN,且与任何值都不可排序,因此浮点类型不能直接实现 Eq / Ord。需要用浮点作集合键或确定排序时,应先定义清楚 NaN 的业务规则,再用专用包装类型或显式比较器。

Hash 与 Eq 有硬约束:若 a == b,则二者必须写入相同的哈希序列。所以自定义相等规则时,Hash 也必须按同一组字段实现;不确定时,两个 trait 一起 derive 最稳妥。

转换与借用:区分拿走值和借出视图

转换 trait 的选择直接影响所有权、分配与错误路径。不要只因调用写起来短,就把不同语义混在一起。

trait做什么实现建议
From<T> / Into<U>消耗或移动值,构造另一类型实现 From;对应 Into 会自动获得。
TryFrom<T> / TryInto<U>校验或窄化后转换用关联类型 Error 返回失败原因。
AsRef<T> / AsMut<T>借出廉价的共享 / 可变视图泛型参数边界常用,不转移所有权。
Borrow<T> / BorrowMut<T>借出且保持比较、排序、哈希等价主要用于 HashMap / BTreeMap 异构查找。
ToOwned从借用创建拥有值str::to_owned()、切片转 Vec 等标准模式。

From 与 Into:实现一个,得到一对

From<T> 与 Into<U> 是同一份契约的两端:消耗一个值,构造另一个类型的值。方向永远是“泛型参数转成 for 后面的类型”,但两个 trait 的 for 位置刚好相反,这是初读时最容易看晕的地方。以 &str 转 JobName 为例:

impl 写法读法方向
impl From<&str> for JobNameJobName 来自 &str&str → JobName
impl Into<JobName> for &str&str 变成 JobName&str → JobName

对照方法签名可以看到两个方法都是“原料进、产物出”,方向完全一致;区别只在 From 把产物写在 for 后面、方法里产物是 Self,Into 把原料写在 for 后面、原料是 self:

1
2
3
4
5
6
7
8
9
10
11
12
13
// From:原料是参数,产物是 Self。
impl From<&str> for JobName {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}

// Into:原料是 self,产物是返回值。仅示意签名,实际由下文 blanket impl 自动提供,不需要也不应该手写。
impl Into<JobName> for &str {
fn into(self) -> JobName {
JobName::from(self)
}
}

记法:impl SomeTrait for Y 中 Y 永远是主体。From<T> for U 的主体 U 是产物(“U 能被造出来”);Into<U> for T 的主体 T 是原料(“T 能交出去”)。二者的等价关系由标准库的一条 blanket impl 固定:

1
2
3
4
5
6
7
8
9
// 标准库既有实现,示意。
impl<T, U> Into<U> for T
where
U: From<T>,
{
fn into(self) -> U {
U::from(self)
}
}

因此工程上的分工是固定的:实现端写 From,调用端用 .into() 或 Into bound。实现 From 后 Into 自动获得;直接手写 Into 得不到任何额外能力,孤儿规则(不能为外部的 trait 和外部的类型组合写 impl,见下文 blanket impl 一节)对两种写法的判定也一致,不存在只有 Into 能表达的转换。标准库还提供恒等实现 impl<T> From<T> for T,所以任何值都可以 .into() 成自身类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[derive(Debug, PartialEq, Eq)]
struct JobName(String);

impl From<&str> for JobName {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}

fn main() {
let name: JobName = "deploy".into();
assert_eq!(name, JobName::from("deploy"));

let raw = String::from("build");
let owned: String = raw.into(); // 走恒等转换。
assert_eq!(owned, "build");
}

.into() 的目标类型必须能从上下文推断:变量标注、函数签名或 collect 的目标。推断不出时编译器报 type annotations needed,应补上标注,或改写为 JobName::from("deploy") 让类型直接出现在调用处。from 与 into 语义等价,选择只看哪一端更清楚:构造时目标类型明确用 from,赋值处类型已知时 into 免去重复。

Into 作为泛型边界

Into 的独特价值在 API 边界。函数接收 impl Into<JobName> 时,&str、String 与 JobName 都能直接传入,函数内部统一拿到拥有所有权的值:

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, PartialEq, Eq)]
struct JobName(String);

impl From<&str> for JobName {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}

impl From<String> for JobName {
fn from(value: String) -> Self {
Self(value)
}
}

fn enqueue(name: impl Into<JobName>) {
let name = name.into();
println!("queued: {name:?}");
}

fn main() {
enqueue("deploy");
enqueue(String::from("deploy"));
enqueue(JobName::from("deploy"));
}

注意 From 不会自动串联:实现了 From<&str> 并不会让 String 间接可用,enqueue(String::from("deploy")) 要求单独的 From<String>。想接受哪个入口,就为它实现哪个 From;这也是 Into 边界比想象中“宽容”得有限的原因。

impl Into<T> 与 impl AsRef<T> 的分界在所有权:前者取得值的所有权,必要时移动或分配;后者只借出视图。函数要保存这个值就用 Into,只在本次调用中读取就用 AsRef,见下一小节。

From 还承担错误链的转换:? 在错误类型不匹配时按 From::from 把底层错误包装为当前函数的错误类型,这正是上文错误一节中 impl From<io::Error> for AppError 的作用;thiserror 的 #[from] 属性生成的也是同样的实现。

TryFrom:把校验放进类型边界

From 的契约是无条件成功:实现它,就是向所有调用者承诺这条转换没有失败路径。需要校验、窄化或解析时使用 TryFrom,用关联类型 Error 说明失败原因;实现端同理写 TryFrom,TryInto 由 blanket impl 自动提供。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#[derive(Debug, PartialEq, Eq)]
struct Port(u16);

impl TryFrom<u16> for Port {
type Error = &'static str;

fn try_from(value: u16) -> Result<Self, Self::Error> {
if value == 0 {
Err("port must not be zero")
} else {
Ok(Self(value))
}
}
}

fn main() {
assert_eq!(Port::try_from(8080), Ok(Port(8080)));
assert!(Port::try_from(0).is_err());
}

不要在 From / TryFrom 中连接网络、读文件或 panic。它们应只描述值到值的转换;有副作用的初始化应命名为 connect、load 或 try_new。

AsRef 与 Borrow 不可互换

文件 API 常接受 impl AsRef<Path>,从而兼容 &str、String、&Path 与 PathBuf。调用方仍拥有原值,函数只临时借用路径。

1
2
3
4
5
6
7
8
9
use std::path::Path;

fn is_log_file(path: impl AsRef<Path>) -> bool {
path.as_ref().extension().is_some_and(|ext| ext == "log")
}

fn main() {
assert!(is_log_file("logs/app.log"));
}

Borrow 更严格:借出的视图必须和原类型拥有相同的 Eq、Ord、Hash 语义。正因如此,HashMap<String, V> 可以用 &str 查找而不用临时分配 String。

1
2
3
4
5
6
7
8
use std::collections::HashMap;

fn main() {
let mut ports = HashMap::new();
ports.insert(String::from("http"), 80);

assert_eq!(ports.get("http"), Some(&80));
}

Deref 不是通用转换 trait

Deref / DerefMut 支持解引用和借用强转,是 Box<T>、Rc<T>、Arc<T> 等智能指针的核心能力。它会让目标类型的方法自动可见,因此只在包装类型确实应该像指针一样透明访问目标时实现。

不要给 UserId(String)、Money(Decimal) 这类 newtype 实现 Deref 来少写 .0:这会泄漏内部类型的整套方法,破坏本应用 newtype 建立的语义边界。

运算、调用与析构:语言语法背后的 trait

std::ops 中的 trait 会被运算符语法调用。实现它们是在给类型增加一种新的语言级表达式,应让表达式读起来符合领域直觉,而非仅仅“能编译”。

表达式trait何时适合
a + b、a += bAdd、AddAssign两值相加有明确且无歧义的领域含义。
a[i]、a[i] = vIndex、IndexMut索引必然有效或越界应当是编程错误。
-a、!aNeg、Not语义与数学负号、逻辑非一致。
f()Fn、FnMut、FnOnce闭包和可调用对象的调用能力。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::ops::Add;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Bytes(u64);

impl Add for Bytes {
type Output = Self;

fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}

fn main() {
assert_eq!(Bytes(3) + Bytes(5), Bytes(8));
}

Index 返回引用且允许越界 panic;若“找不到”是正常输入,应提供 get 返回 Option,不要只为了下标语法而隐藏失败路径。

Fn、FnMut、FnOnce

闭包根据捕获方式自动实现一个或多个调用 trait。它们的能力层级是 Fn: FnMut: FnOnce:可多次不可变调用的闭包最强,消耗捕获值的闭包只能调用一次。设计泛型 API 时应选择函数体真正需要的最弱 bound。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn run_once<F>(job: F) -> String
where
F: FnOnce() -> String,
{
job()
}

fn apply_twice<F>(mut f: F, value: i32) -> i32
where
F: FnMut(i32) -> i32,
{
let next = f(value);
f(next)
}

若 API 只调用一次,就写 FnOnce,它也能接收 Fn 与 FnMut 闭包;不应无理由要求 Fn,否则会拒绝需要 move 捕获资源的合法调用者。

Drop:只做不会失败的释放

Drop::drop 在值离开作用域时自动执行,用于归还句柄、释放堆内存等。不能直接调用 .drop(),需要提前销毁时写 std::mem::drop(value)。

Drop 没有返回 Result 的位置,因此文件 flush、事务提交、网络关闭等可能失败的操作必须暴露为显式 flush、commit 或 close 方法;Drop 只做兜底释放,并避免 panic。

编译器与并发边界:不要轻易手写 marker trait

Sized、Send、Sync、Unpin 多数是 auto trait:编译器根据字段递归推导。它们并非普通的业务能力标签,而是类型布局、线程安全和地址稳定性的安全承诺。

trait含义日常写法
Sized编译期知道大小泛型默认包含 T: Sized;只在接收 DST 借用时写 T: ?Sized。
Send值可安全转移到另一线程由字段自动推导;线程任务常要求 Send + 'static。
Sync&T 可安全跨线程共享Mutex<T>、RwLock<T> 等用同步原语维护共享可变状态。
Unpin被 Pin 包装后仍可移动异步状态机和自引用类型的专门边界,通常无需手写。

unsafe impl Send 或 unsafe impl Sync 是在向所有调用者承诺不存在数据竞争、悬垂访问和错误别名。除非能写出并审查完整的不变量证明,否则应让编译器自动推导,或通过 Arc、Mutex、消息传递表达同步。

Future 不在 prelude。async fn、async 块会由编译器生成对应的状态机,日常业务代码通常返回 async fn 的结果并使用 .await,而不是手写 Future::poll;只有实现运行时、协议适配器或底层库时才需要直接处理它。异步取消、Pin 与轮询边界见并发与异步。

blanket impl、孤儿规则与实现清单

标准库会为“一整类满足条件的类型”提供 blanket impl。例如实现了 Display 的类型会自动获得 ToString,目标类型实现 From<T> 后,T 自动可 .into()。因此实现“源头 trait”比重复实现派生能力更好。

但不能为“外部 trait + 外部类型”写实现;这是孤儿规则,用来避免两个 crate 提供冲突实现。需要为 Vec<u8> 定义十六进制展示等本地语义时,定义 struct HexBytes(Vec<u8>); 这样的 newtype,再为它实现 Display。

为新领域类型选择 trait 时,按下面清单核对:

  1. 诊断需要:Debug。
  2. 复制需要:仅在复制语义明确时加 Clone;仅在隐式复制仍廉价时加 Copy。
  3. 集合键需要:哈希键派生 PartialEq + Eq + Hash;有序键再派生 Ord。
  4. 默认构造需要:默认值确实安全、可直接使用时才实现 Default。
  5. 文本协议需要:人类可读且稳定的文本才实现 Display。
  6. 边界转换需要:无条件用 From,有校验用 TryFrom,只借用用 AsRef。
  7. 并发需要:让字段决定 Send / Sync;不要以 unsafe impl 消除报错。

格式化宏与 FromStr

上一章覆盖了 trait 的语义选型;与它们配合的格式化宏,分工同样不同:

API结果与成本适用位置
format!创建拥有的 String,通常会分配确实要保存或传递格式化结果
write! / writeln!写进已有 writer 或 String避免中间字符串,处理输出错误
print! / println!直接写标准输出简单 CLI 输出;写失败会 panic
format_args!构造延迟格式化参数自定义日志 / 格式化 API 的底层接口

parse() 通过 FromStr 工作,返回的目标类型常需要显式标注:let port: u16 = raw.parse()?;。这种标注不是冗余,它让“文本将被解释为何种值”在边界处可审查。

路径、文件与通用 I/O:区分文件系统和字节流

std::path 只处理路径结构;std::fs 执行文件系统操作;std::io 用 trait 抽象任何字节 reader / writer。把这三层混在一起是 I/O 代码最常见的误解。

路径不是 UTF-8 字符串

Path 对应借用视图,类似 str;PathBuf 对应拥有、可修改的路径,类似 String。二者内部以 OsStr / OsString 表示,因此能保留当前平台允许的非 Unicode 文件名。

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::path::{Path, PathBuf};

fn report_path(root: &Path, name: &str) -> PathBuf {
let mut path = root.join("reports");
path.push(name);
path.set_extension("txt");
path
}

fn main() {
let path = report_path(Path::new("output"), "today");
assert_eq!(path, Path::new("output/reports/today.txt"));
}

用 join、push、extension、file_name 等 API 组合或检查路径,不要手写 / 或 \\。但不要把 Path 的词法操作当作安全隔离:components()、join() 不会解析符号链接,且不会替你验证不可信路径是否仍在某个根目录内。需要访问真实文件系统时再考虑 canonicalize(),并注意检查与实际打开文件之间仍可能存在竞态。

fs 适合一口气完成,io 适合流式处理

小型 UTF-8 文本可直接用 fs::read_to_string;二进制文件可用 fs::read。文件很大、需要按行处理、或处理对象不一定是文件时,使用 File、BufReader、BufWriter 与 I/O trait。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::Path;

fn read_non_empty_lines(path: &Path) -> io::Result<Vec<String>> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut lines = Vec::new();

for line in reader.lines() {
let line = line?;
if !line.trim().is_empty() {
lines.push(line);
}
}

Ok(lines)
}

用最小能力描述 I/O 边界

std::io 的核心是按字节读取的 Read 与写入的 Write。读取文本行时,使用带内部缓冲的 BufRead;需要移动流位置时增加 Seek bound。它们都不在 prelude,签名中或调用处需要显式 use。

trait最小能力常见误区
Read从字节源读取单次 read 不保证填满缓冲;需要填满时用 read_exact。
BufRead带缓冲的读取逐行读取应使用 BufReader,避免频繁系统调用。
Write写入字节并 flush单次 write 可能只写部分数据;完整写入用 write_all。
Seek移动字节流游标仅当业务确实依赖随机访问时要求它。
fmt::Write向文本缓冲格式化写入与 io::Write 不同;前者面向 UTF-8 文本,后者面向字节。

File、TcpStream、Vec<u8> 都实现 Read / Write,所以函数参数优先接受 impl Read、impl Write 或泛型 bound,只有真正需要文件元数据时才要求 File:

1
2
3
4
5
6
7
8
9
10
use std::io::{self, Read, Write};

fn copy_all<R, W>(mut input: R, mut output: W) -> io::Result<()>
where
R: Read,
W: Write,
{
io::copy(&mut input, &mut output)?;
output.flush()
}

I/O 的几个硬约束:

  • 高频、小块读取或写入应使用 BufReader / BufWriter,减少系统调用。需要确认数据真的交给底层 writer 时显式 flush();析构时发生的 flush 错误无法可靠地交给调用方处理。
  • BufRead::lines() 每次分配一个 String 并去掉行尾换行符。需要复用缓冲区或精确保留字节时,改用 read_line、read_until 或底层 Read API。

环境、进程与时间:把平台状态当成不可信输入

模块常用 API关键注意事项
std::envargs_os、var、var_os、current_dir环境变量和工作目录都可被外部改变;var 只接受 Unicode 值
std::processCommand、Child、ExitStatusCommand 默认不经过 shell;显式传参数并检查退出状态
std::timeDuration、Instant、SystemTime测量耗时用 Instant;显示或记录现实时间用 SystemTime

Command::new("git").args(["status", "--short"]) 会把两个字符串作为两个参数传给进程,不进行 shell 展开、通配符替换或管道解析。这既更可预测,也避免把不可信文本拼接进 shell 命令的风险。若确实需要 shell 语义,必须显式启动相应 shell,并承担其转义规则和注入边界。

Instant 只有“经过了多久”的意义,不能格式化成日期;SystemTime 表示墙上时间,时钟校正可能使 duration_since 返回错误。不要用 SystemTime 计算超时,也不要把 Instant 写进跨进程持久化数据。

线程与同步:先选消息传递,再讨论共享可变状态

std::thread 提供 OS 线程。thread::spawn 的闭包及其捕获值通常必须满足 Send + 'static,因为线程可能比创建它的栈帧活得更久。只需要在一个作用域内并行处理借用数据时,优先考虑 thread::scope,它能让编译器验证所有子线程会在离开作用域前结束。

需求标准库选择边界
独占堆分配Box<T>单一所有者,适合递归类型或大对象
单线程共享所有权Rc<T> + Weak<T>不可跨线程;Weak 用于打破引用环
单线程内部可变性Cell<T> / RefCell<T>RefCell 在运行时检查借用,违规会 panic
跨线程共享所有权Arc<T>只解决计数,不自动让内部数据可变或线程安全
互斥读写Mutex<T>等待时阻塞 OS 线程;锁 guard 离开作用域即释放
多读单写RwLock<T>仅在读多写少且测量证明收益时考虑
一次初始化OnceLock<T> / LazyLock<T>避免 static mut,用于线程安全全局初始化
工作结果回传sync::mpsc多生产者、单消费者的消息通道

消息通道能让 worker 保有自己的数据,把最终结果交回一个接收者,避免过早引入共享锁:

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
use std::sync::mpsc;
use std::thread;

fn sum_in_workers(numbers: &[u64]) -> u64 {
let (tx, rx) = mpsc::channel();
let mut workers = Vec::new();

for chunk in numbers.chunks(2) {
let tx = tx.clone();
let values = chunk.to_vec();

workers.push(thread::spawn(move || {
let subtotal: u64 = values.into_iter().sum();
tx.send(subtotal).expect("receiver remains alive");
}));
}

drop(tx);
let total: u64 = rx.into_iter().sum();

for worker in workers {
worker.join().expect("worker must not panic");
}

total
}

fn main() {
assert_eq!(sum_in_workers(&[1, 2, 3, 4, 5]), 15);
}

原始 tx 必须在接收迭代前 drop;否则 receiver 仍看到一个活着的发送端,会无限等待下一个值。示例里的两个 expect() 都是明确的内部不变量:接收端在 worker 发送前仍存活,worker 不应 panic。对于外部输入、I/O 或可预期的运行时故障,函数应改为返回 Result 并让调用方处理。

Mutex::lock() 和 RwLock 的加锁方法还会报告中毒(poisoning):若持锁线程 panic,后续获取者必须决定内部状态能否继续使用。不要把锁 guard 持有到不必要的工作中;临界区应只做读写共享状态的最小操作。std 不提供异步运行时,因而 std::sync::Mutex 也不适合跨 .await 持有;异步程序应按所选运行时的并发模型设计。

网络与异步边界

std::net 提供基础的阻塞式网络能力:TcpListener、TcpStream、UdpSocket、SocketAddr 和 ToSocketAddrs。TcpStream 同时实现 Read / Write,所以文件和 TCP 协议处理可以共享一部分字节流代码。

它刻意不提供 HTTP、TLS、WebSocket、JSON 编解码或异步任务调度。set_nonblocking(true) 只会改变套接字的阻塞行为,不会凭空提供 reactor、计时器、取消传播或 .await 执行器。需要这些语义时,引入专门 crate 和运行时是正常设计,不应在 std 之上手搓半套异步框架。

网络代码必须明确消息边界:TCP 是连续字节流,不保留“某一次 write 对应某一次 read”的边界。长度前缀、分隔符或成熟协议解析器都可以;“每次 read 恰好拿到一个请求”不可以。

宏、断言与 panic:便利不等于错误处理

标准宏是 std 体验的一部分,但语义各不相同:

宏用途不该承担的责任
assert!、assert_eq!测试和不可违反的不变量用户输入、网络或文件错误
debug_assert!仅调试构建的内部检查有副作用的业务逻辑
matches!简洁测试模式是否匹配替代需要绑定数据的完整 match
vec!构造 Vec不必要的临时集合
dbg!开发期快速诊断稳定日志或生产输出
todo!、unimplemented!明确 panic 的占位已交付的可达业务路径
include_str!、env!编译期嵌入文本 / 环境值运行时可变配置

panic! 表示当前任务无法安全继续,例如内存不变量被破坏。Result 表示调用方仍能做出有意义的恢复或报告。两者的选择是 API 合同,不是代码量的选择;“先 unwrap(),以后再补错误处理”会把边界不清的问题推迟到最难排查的时刻。

FFI、平台模块与 I/O 安全

跨语言或跨平台边界时,文本和资源句柄都不能再按普通 String / 整数处理:

  • OsStr / OsString 表示平台原生文本,可能不是 UTF-8;适合文件名、环境变量和命令参数。
  • CStr / CString 表示 NUL 结尾的 C 字符串;CString::new 会拒绝内部 NUL。
  • std::os 包含 Unix / Windows 专用扩展,应把它们限制在最薄的平台适配层,业务层继续使用 Path、File、TcpStream 等可移植类型。
  • 文件描述符或 Windows handle 受 I/O 安全模型约束。安全 API 应接收拥有或借用的句柄类型,而不是把随意的整数当作可关闭、可读写的资源。

这类代码常常需要 unsafe,但不应让 unsafe 向上扩散。把原始指针、原始 handle、布局约束和 C ABI 封装在很小的模块内,公开 API 则用 Result、所有权和借用表达调用方需要遵守的规则。

何时停止只用 std

“标准库优先”是依赖选择的起点,不是禁止生态 crate 的信条。std 适合基础能力和小型工具;当问题本身含有更高层语义时,应选择维护良好的专用库。

需求std 是否足够原因
文件、路径、命令行参数、线程、TCP / UDP通常足够这些是标准库的明确边界
JSON / TOML / YAML不够数据格式及其兼容性策略不属于 std
HTTP、TLS、数据库协议不够需要协议实现、安全更新和生态互操作
异步 I/O、任务取消、定时器不够需要运行时与 reactor 模型
Unicode 分词、用户感知字符簇不够str 只保证 UTF-8 和 Unicode 标量值操作
日志、CLI 参数定义、日期时区通常不够配置、格式与平台策略远超基础抽象

先写清需求,再引入依赖。反过来,若需求只是读取文件、解析一行文本、遍历集合或启动一个子进程,先查 std 往往能减少构建时间、依赖树和安全更新面。

查文档的正确顺序

在线标准库文档的 crate 首页会列出模块、原生类型、标准宏和 prelude。阅读具体 API 时,按下面顺序过滤信息:

  1. 路径和签名:参数是借用、可变借用还是所有权转移;返回 Option、Result 还是直接值。
  2. Trait Implementations:一个类型实现了哪些能力;例如 TcpStream 的 Read / Write,Vec<T> 的 Deref<Target = [T]>。
  3. Panics / Errors / Safety:panic 前提、错误种类和 unsafe 调用者义务比示例更重要。
  4. 稳定性标记:确认 API 是否稳定、何时稳定、是否受 feature gate 限制;稳定版文档也会展示实验性项目。
  5. Source 链接:需要理解性能、边界处理或实现约束时再读;先依赖公开合同,不要把实现细节当 API 保证。

本机工具链版本和线上 stable 文档可能不同。需要与项目 MSRV 严格一致时,优先使用:

1
2
3
4
rustup doc --std
rustup doc --core
rustup doc --alloc
cargo doc --open

前 3 条打开当前工具链自带的文档;cargo doc --open 则把项目依赖和本地类型一起生成,适合沿着实际调用关系跳转。

常见误区

  • 把 std 当成“所有 Rust 功能”:原生类型由编译器提供,很多基础 trait 和类型来自 core;std 在其上增加完整宿主环境。
  • 以为 prelude 导入了所有常用名称:HashMap、Path、Read、Display 等仍要显式导入。
  • 把路径当 String 拼接:跨平台代码应使用 Path / PathBuf,并保留 OsStr 语义。
  • 假设一次 read 填满缓冲区:字节流 API 允许短读;固定长度协议必须使用 read_exact 或自行累计。
  • 依赖 HashMap 的遍历顺序:该顺序不是稳定合同;需要确定输出时排序或改用 BTree 容器。
  • 给 newtype 实现 Deref 只为少写 .0:这会泄漏内部类型的整套方法,破坏 newtype 的语义边界。
  • 用 unsafe impl Send 消除跨线程报错:Send / Sync 是安全承诺;应通过 Arc、Mutex 或消息传递让编译器自动推导。
  • 用 Rc<RefCell<T>> 处理跨线程共享:它们是单线程工具;跨线程从 Arc 加消息传递或同步原语开始。
  • 认为 std::net 等于异步网络栈:它提供基础 socket,不提供 async executor、HTTP 或 TLS。
  • 用 unwrap() 处理外部世界:文件、网络、环境变量和解析输入都会失败,应返回或匹配 Result。
  • 把 Path::canonicalize 当作唯一安全校验:它访问文件系统,仍需在真实打开、权限和竞态模型中整体设计。

自测

  1. 为什么实现 Display 后不应再手写 ToString?
  2. HashMap 的键为什么通常同时需要 Eq 与 Hash?
  3. 哪些情况应使用 TryFrom 而不是 From?
  4. 为什么实现端应写 From 而不是 Into?泛型 API 边界又为什么常用 impl Into<...>?
  5. AsRef<Path> 与 Borrow<str> 的约束为什么不同?
  6. 一个只调用回调一次的函数为何应接收 FnOnce?
  7. 为什么 Index 不适合表示用户输入造成的“可能找不到”?
  8. Read::read 与 Write::write 分别为什么不能承诺一次完成?
  9. 为什么不能为了跨线程编译通过就写 unsafe impl Send?

参考答案

  1. 实现 Display 后不应再手写 ToString:标准库存在 blanket impl impl<T: Display> ToString for T,实现 Display 会自动获得 ToString。再手写会造成实现冲突无法编译;即使绕过,两份实现也可能输出不一致,破坏“同一文本协议”的约定。

  2. 键同时需要 Eq 与 Hash:开放寻址 / 链式哈希表先用 Hash 定位桶,再用 Eq 确认同桶内哪个键真正匹配,二者缺一不可。且两者必须一致:a == b 时哈希序列必须相同,否则相等的键落入不同桶,get 会返回 None。这是不确定时两个 trait 一起 derive 的原因。

  3. 应使用 TryFrom 的情况:转换存在失败路径时——需要校验(如端口非零)、窄化(u64 → u32 可能溢出)、解析(文本转枚举)等。From 的契约是无条件成功,把可能失败的转换塞进 From 是向调用者作出无法兑现的承诺。

  4. 实现端写 From 的原因:标准库的 blanket impl 固定了方向——U: From<T> 时 T 自动获得 Into<U>,反之没有。手写 Into 得不到任何额外能力,还可能与 blanket impl 冲突。泛型边界用 impl Into<T> 的原因是调用体验:&str、String、T 都能直接传入,函数内部统一调用 .into() 拿到拥有所有权的值,调用方不必手写 T::from(...)。

  5. AsRef 与 Borrow 约束不同的原因:AsRef 只承诺借出一个廉价视图,不要求视图与原类型在比较、排序、哈希上等价,一个类型可以对多个目标实现 AsRef。Borrow 额外要求借出视图与原类型共享同一套 Eq / Ord / Hash 语义,这是 HashMap<String, V> 能用 &str 免分配查找的前提;若语义不一致,查找结果将是错误的。

  6. 只调用一次应接收 FnOnce:能力层级是 Fn: FnMut: FnOnce,FnOnce 是最弱、覆盖面最广的 bound——只捕获引用的闭包、可变闭包和 move 走所有权的闭包都满足它。写成 Fn 会无理由拒绝需要 move 捕获资源(如把 String 移交进任务)的合法调用者。选择 bound 的原则是“函数体真正需要的最弱能力”。

  7. Index 不适合“可能找不到”:Index::index 的签名只能返回引用,没有 Option / Result 的位置,失败路径只有 panic。“找不到”若是用户输入导致的正常情况,应暴露 get(key) -> Option<&V>,把失败交给类型系统处理;Index 只应用于越界即编程错误的场景。

  8. read / write 不能承诺一次完成:底层系统调用本身允许部分读写——read 在到达 EOF、数据尚未就绪或缓冲区未填满时合法返回较少字节,write 可能只消费缓冲区的一部分。需要“恰好填满”用 read_exact,需要“全部写出”用 write_all,二者内部循环重试直到完成或出错。

  9. 不能为消除报错写 unsafe impl Send:Send / Sync 是安全承诺而非能力标签,手写 unsafe impl 等于向所有调用者保证不存在数据竞争、悬垂访问和错误别名。承诺若不成立,UB 会出现在远离该 impl 的并发代码中且极难定位。正确做法是调整字段——用 Arc 替代 Rc、Mutex / RwLock 保护共享可变状态、或改用消息传递——让编译器自动推导;只有能写出并审查完整不变量证明时才允许手写。

官方入口

标准库的价值不在于“没有依赖”,而在于它给出了跨 crate、跨平台都能依赖的最小公共语义。先用这些语义把所有权、失败路径和资源边界写清楚;只有问题本身超出这一层时,再用生态 crate 补上协议、运行时或领域能力。