所有权:最独特的一堵墙
在别的语言里,你几乎不必操心"这个值归谁、何时释放":有垃圾回收的替你管,没有的用悬垂指针与双重释放换经验。Rust 修了一堵墙来解决它,这堵墙也是你读 Rust 代码时最先撞上的东西。读完本章,你应该能回答:
- 给你
gateway.rs的chat_completions,你能说出body、request、requested各自拥有什么,以及request在哪一行交出了所有权; - 看到签名里的
&HeaderMap、&str、&mut和不带&的ChatRequest,你能立刻归类:拿走、只读,还是可写; provider.rs里self.0.clone()为什么是故意复制;删掉它,故障转移会坏在哪。
进门:字节变成一个有属主的值
一个 agent 把一段 JSON POST 到 jackdaw 的 /v1/chat/completions。字节进了进程,就是一块堆上的内存:谁来释放?释放两次、或还有人引用着就释放,各会怎样?Rust 的答案只有一条规则:每个值有唯一属主;属主离开作用域,值被释放,恰好一次。这就是所有权。主线是这串字节的旅程,第一站:网关的处理函数(axum 框架,第 14 章展开):
async fn chat_completions(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
// ...(bearer token 检查省略)
let request = match decode_request(&body) {
Ok(request) => request,
Err(message) => return error_response(StatusCode::BAD_REQUEST, message),
};
let requested = request.model_field().to_string();
if request.wants_stream() {
stream_reply(&state, request, &requested).await
} else {
complete_reply(&state, request, &requested).await
}
}读法:先看参数表。body: Bytes 是"拿走"——axum 把整次请求的字节作为一个新值交给函数,由它负责到底;headers: HeaderMap 同理;State/Arc 是传入共享状态的固定写法,第 14 章展开。decode_request(&body) 带 &,是借用:看一眼,不夺走。解出的 ChatRequest 绑定到 request,从这行起,解析后 JSON 的属主就是 request 这个名字。
解码这一步:
fn decode_request(body: &[u8]) -> Result<ChatRequest, String> {
let value: Value = serde_json::from_slice(body)
.map_err(|error| format!("request body is not valid JSON: {error}"))?;
if !value.is_object() {
return Err("request body must be a JSON object".to_string());
}
Ok(ChatRequest(value))
}读法:&[u8] 是对字节的只读借用;from_slice 解析出一个全新的 Value(serde_json 的任意 JSON 值,serde 第 6 章展开),在堆上重新分配,不是原字节的视图;ChatRequest(value) 把它包好,随返回值交还调用方——所有权沿返回值流回 chat_completions。Result 第 4 章见过:要么 Ok(值) 要么 Err(人话);map_err 在这里只是把错误改写成一句话。
看一眼,还是抱回家:&str 与 String
路由之前,网关要先知道"客户端点名要哪个模型":
/// A chat request body, passed through untouched except for `model`.
#[derive(Debug, Clone)]
pub struct ChatRequest(pub Value);
impl ChatRequest {
// ...
/// The raw `model` string as received (for logs).
pub fn model_field(&self) -> &str {
self.0
.get("model")
.and_then(Value::as_str)
.unwrap_or("<missing>")
}
}读法:ChatRequest(pub Value) 是个结构体,.0 是它包着的 JSON(#[derive(...)] 让编译器自动实现打印、克隆等,第 6 章展开)。&self 是只读借用;返回的 &str 是字符串切片(&str):指向 request 内部那几个字节的只读视图,不复制内容。"引用能活多久"的约束叫生命周期,多数场景编译器自动推导,你现在认得这个词就够。
再看 chat_completions 里那行 let requested = request.model_field().to_string();。方向先给:.to_string() 就是在复制。下一节你会看到 request 要被整个交出去,而 &str 视图不能比它指向的数据活得久;.to_string() 把内容复制进 requested——一个自己拥有字节的堆字符串(String),之后原件去哪、何时释放,都与它无关。临时看一眼用 &str,要长期持有就复制成 String,这是两者的分界线。
TIP
stream_reply(&state, request, &requested) 传的是 &String,形参却是 &str——Rust 自动转换,不用写 .as_str()。审 AI 生成的 diff 时,别在这里多想。
move:交出去,名字就作废
上面那行调用里,request 不带 &:被拿走了。这叫所有权转移(move)——赋值或传参不带 &,所有权就交出去,原名字从此失效。这行之后再写 request.wants_stream(),编译器直接拒绝,报 use of moved value(E0382)。不是运行时保护,是编译期的墙。主线的出口在供应商:
#[async_trait::async_trait]
pub trait Provider: Send + Sync {
// ...
/// Send the request and wait for the full JSON response.
async fn complete(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError>;
// ...
}读法:trait 是行为契约、async fn/.await 是等一次网络往返,第 7 章展开;trait 上方那行 #[async_trait::async_trait] 同样先当作固定写法,为什么非它不可,第 7 章拆开;Send + Sync 第一次读直接无视;ProviderError 是枚举,第 6 章展开。关键在参数:request: ChatRequest 不带 &,provider 拿走整个请求,router 与网关从此无权再碰;返回的 ChatResponse 是上游新造的值,所有权再沿返回值流回来。整条链:字节进门 → 解析成 request → move 进 router → move 给 provider → 新的响应值流回网关。
可写借用,与一次故意的 Clone
路由那头,group/<id> 可能对应多个候选模型:第一个 429 了就换下一个(故障转移,第 13 章展开)。客户端发的 body 长这样(示意),with_model 要做的就是把组名换成组里某个真实模型 id:
{ "model": "group/backup", "stream": false, "messages": [ /* ... */ ] }/// A copy of the body with `model` set to the upstream model id.
///
/// One deliberate `clone` per candidate: the original request must stay
/// intact so the failover loop can rewrite it for the next model.
pub fn with_model(&self, model_id: &str) -> ChatRequest {
let mut body = self.0.clone();
// Insert through the object map (not `body["model"] = ...`, which
// panics on non-objects); callers guarantee an object body.
if let Value::Object(map) = &mut body {
map.insert("model".to_string(), Value::String(model_id.to_string()));
}
ChatRequest(body)
}读法:从 .clone() 那行看起。self.0 是原件;.clone() 深拷贝一份,副本的属主是局部变量 body,此刻副本里 model 还是 group/backup。&mut body 是可写借用,全篇唯一一处:在 map.insert 把 model 换成上游 id 期间,body 不允许任何其他读或写。末尾 ChatRequest(body) 把副本交还调用方;原件从头到尾只被读过。
为什么必须复制?函数的注释自己写了:故障转移循环还要给下一个候选再改一次 model,原件必须完好;若改成拿走 self,第一次转移就把原件消耗掉了。.clone() 从不自动发生——看到它,就是有人明确决定"这里复制一次"(克隆);值不值,看数据大小与次数:每个候选一次、每次请求一两回,可以接受。执法者是借用检查器:同一时刻,要么任意多个只读借用,要么一个可写借用,编译期强制。它拦下的不是你,是你本会在凌晨调试的悬垂引用与数据竞争。
签名即文档:拿走、只读、可写
把全章压成速查表。读任何 Rust 函数签名,先扫参数里的 &:
| 签名里看到 | 含义 | 本章例子 |
|---|---|---|
T(不带 &) | 拿走:所有权 move 进来,函数负责到底 | complete(&self, request: ChatRequest) |
&T | 只读借用:看,不改,不负责释放 | authorized(headers: &HeaderMap, token: &str) |
&mut T | 可写借用:独占修改权 | &mut body |
代码可以由 AI 写,但签名是数据的路线图。审 diff 时,& 的有无往往比函数名更早告诉你数据会怎么动。
小结
- 每个值有唯一属主;字节由 axum 造出交给 handler,解析后的 JSON 归
request,它在交给stream_reply/complete_reply的那一行 move 出去,事先用.to_string()给自己留下一份。 &只读、&mut可写、不带&拿走——这套语言写在每个签名里。.clone()是明确决定的复制;jackdaw 里那次,是给故障转移留原件。
下一章把"值"升级成"类型":结构体与枚举,以及 ChatRequest 为什么薄薄包一层就有用。
自检
- 你从 Python 来,习惯把对象传给函数后继续用。在
chat_completions里,complete_reply(&state, request, &requested)之后再写request.wants_stream(),会发生什么?想继续用这个值,有哪些改法? - 把
with_model改成拿走self(fn with_model(self, ...)),删掉.clone(),直接let mut body = self.0;。这个函数本身能编译吗?故障转移循环会在哪一步出问题? authorized(headers: &HeaderMap, token: &str)为什么两个参数都用&?如果改成拿走HeaderMap,chat_completions还能编译吗?
参考答案
- 编译失败:
use of moved value: request(E0382)。所有权已随调用转移,名字作废。改法:要么在 move 前把要用的值取出来(源码里requested正是这么做的),要么让函数收&ChatRequest只读借用——要不要保留所有权,决定选哪种。 - 函数本身能编译。坏在调用方:第一次调用就消耗了请求,循环到第二个候选时没有原件可改,调用处报
use of moved value。这正是注释 "One deliberatecloneper candidate" 防住的事故:每个候选改自己的副本,原件始终完好。 - 认证只需要读 header 和 token,拿走没有任何好处。改成拿走后,只要
chat_completions在authorized之后不再使用headers,依然能编译——move 合法,但白白放弃了整张 header 表;&传达的是"看一眼,不负责释放"。读签名时,&的有无就是第一眼要看的东西。