Rust 编译器错误 E0067 全解析:复合赋值运算符左侧为什么"不可赋值"
导读
E0067 是 Rust 编译器在类型检查阶段报出的错误,含义是"在赋值操作(如 =、+=、-= 等)的左侧使用了非法表达式"。本文以本仓库中 错误码说明文档 为核心骨架,结合 rustc 的类型检查源码,深入讲解什么是"可赋值的左侧"(place expression)、E0067 与相邻错误码(如 E0070、E0368)的区别,以及遇到该错误时如何定位并修复代码。读完本文,你将能准确判断一条复合赋值语句的左侧是否合法,并掌握从源码层面排查此类错误的能力。
一、错误速览:一条会触发 E0067 的代码
原文档给出了最直接的触发示例——对整数字面量使用复合赋值运算符:
12 += 1; // error!
这一行代码会同时暴露两个问题:
- 字面量
12是一个值表达式(value expression),不是可赋值的存储位置,因此"把 12 自增 1 再写回 12"在语义上根本无从谈起; - rustc 在类型检查阶段检测到左侧不可赋值,于是发出
E0067。
同样的思路也适用于普通赋值 12 = 1;,不过那会触发它的"邻居"错误 E0070(invalid left-hand side of assignment)。E0067 专门用于 a <op>= b 这种二元复合赋值运算符,而普通 = 赋值则由 E0070 负责,这一点下文会从源码路径详细展开。
二、正确写法:先声明可变变量,再复合赋值
原文档给出的修复方案是:为左侧提供一个真正"可写的存储位置"——一个 mut 变量:
let mut x: i8 = 12;
x += 1; // ok!
修复要点有三条,缺一不可:
- 使用
let声明变量:变量路径是对内存中一个槽位的引用,属于可赋值位置; - 声明为
mut:Rust 的变量默认不可变,对非mut变量做x += 1会触发E0384(cannot assign twice to immutable variable),因此必须显式允许写入; - 类型标注
i8:让x += 1两侧类型清晰一致,避免溢出等后续问题。
执行完毕后 x 的值为 13,复合赋值等价于先读取、计算再写回。
三、核心概念:place expression 与 value expression
要彻底理解 E0067,必须先掌握 Rust 对表达式的一对基本分类。原文档中出现的 "place expression" 一词是理解本错误的钥匙,它对应的中文术语是位置表达式(place expression,过去也称左值 lvalue)。
- 位置表达式(place expression):表示内存中某个可寻址的存储位置,对它求值得到的是"这个位置在哪里",而不是"这个位置里存的值是多少"。因为代表一个真实的存储槽位,它才可能成为赋值的左侧;
- 值表达式(value expression):只产生一个值本身,不绑定任何可写存储位置。字面量、函数调用结果、二元运算结果、
Box::new(..)、LinkedList::new()等都属于这一类。
赋值语句的语义是"把右侧的值写入左侧所代表的位置",因此左侧必须是 place expression。E0067 的英文全称 "An invalid left-hand side expression was used on an assignment operation" 说的正是:复合赋值运算符左侧被放了一个 value expression。
四、rustc 源码视角:编译器如何判定"左侧可赋值"
4.1 判定入口:is_syntactic_place_expr
rustc 判断一个表达式是否为位置表达式,核心实现在 compiler/rustc_hir/src/hir.rs。is_syntactic_place_expr 是对通用判定函数 is_place_expr 的包装:
pub fn is_syntactic_place_expr(&self) -> bool {
self.is_place_expr(|_| true)
}
从源码可以看到,下述表达式会被认定是位置表达式(可作赋值左侧):
- 解析路径
ExprKind::Path(QPath::Resolved(..)),且目标解析为局部变量(Res::Local)、静态变量(Res::Def(DefKind::Static, ..)); - 字段/下标等投影运算(
a.b、v[i]),只要基础表达式本身是位置表达式,其投影结果仍是位置表达式; - 解引用
*p:可写的解引用结果对应的是被指向的那块内存; - 类型标注、
unsafe绑定转型等"透传"表达式的"位置性"继承自其操作数。
反过来,字面量、& 引用临时值、运算式、构造表达式、方法调用结果等都不满足条件,因此会触发本错误。
4.2 报错点一:复合赋值运算符检查 check_expr_assign_op
当编译器解析到形如 a += b 的表达式时,会调用 check_expr_assign_op。该函数位于 compiler/rustc_hir_typeck/src/op.rs(文件头注释 "Code related to processing overloaded binary and unary operators" 表明这是运算符处理模块):
pub(crate) fn check_expr_assign_op(
&self,
expr: &'tcx Expr<'tcx>,
op: hir::AssignOp,
lhs: &'tcx Expr<'tcx>,
rhs: &'tcx Expr<'tcx>,
expected: Expectation<'tcx>,
) -> Ty<'tcx> {
let (lhs_ty, rhs_ty, _return_ty) =
self.check_overloaded_binop(expr, lhs, rhs, Op::AssignOp(op), expected);
// ... 内置二元运算类型约束检查 ...
self.check_lhs_assignable(lhs, E0067, op.span, |err| { /* ... */ });
}
关键在最后一行:它把错误码 E0067 与左侧表达式 lhs 一起传给 check_lhs_assignable——凡是 +=、-=、*=、/=、%=、&=、|=、^=、<<=、>>= 这类复合赋值运算符,左侧不可赋值时报的都是 E0067。
4.3 报错点二:统一判定函数 check_lhs_assignable
check_lhs_assignable 实现在 compiler/rustc_hir_typeck/src/expr.rs,它先把要检查的代码号作为参数接收,再统一执行"是否可赋值"的判定:
pub(crate) fn check_lhs_assignable(
&self,
lhs: &'tcx hir::Expr<'tcx>,
code: ErrCode,
op_span: Span,
adjust_err: impl FnOnce(&mut Diag<'_>),
) {
if lhs.is_syntactic_place_expr() {
return; // 是位置表达式,直接放行
}
// ... 对 let 链等特殊情况提前返回 ...
let mut err = self.dcx().struct_span_err(op_span, "invalid left-hand side of assignment");
err.code(code); // 写入 E0067 / E0070 等错误码
err.span_label(lhs.span, "cannot assign to this expression");
// ...
err.emit();
}
这里可以看到两条重要的工程信息:
- 先判定后报错:
is_syntactic_place_expr返回true就立即返回(放行),只有判定失败才会构造诊断信息; - 错误码由调用方注入:
check_lhs_assignable不硬编码错误码。复合赋值路径传入E0067,而普通赋值(见 compiler/rustc_hir_typeck/src/demand.rs 中同为左侧判定服务的调用)则可能带E0070。因此我们能在源码上确认:E0067是"复合赋值运算符"专属的错误码,E0070则负责普通=赋值。
五、实战修复场景
除了原文档的"先 let mut 再赋值"这一标准解法,日常开发中 E0067 还有几个高频场景,分别对应不同的修复动作。
场景一:对方法调用结果复合赋值
fn main() {
let mut v = vec![1, 2, 3];
// v.first_mut().unwrap() += 1; // 这样写会触发 E0067
*v.first_mut().unwrap() += 1; // ok:先解引用,得到可写位置
}
first_mut() 返回 Option<&mut i32>,.unwrap() 得到的 &mut i32 本身只是引用值;要写成复合赋值左侧,必须用 * 解引用到被引用的 i32 存储槽。这也印证了 4.1 中"解引用结果仍是位置表达式"的规则。
场景二:对构造的临时对象复合赋值
use std::collections::LinkedList;
fn main() {
let mut list = LinkedList::new();
list.push_back(1);
// list += ... // LinkedList 本身不实现 AddAssign
}
直接用 LinkedList::new() += 1; 这类"临时对象"写在左侧,会同时遭遇 E0368(类型未实现 AddAssign)与 E0067(左侧不可赋值)。仓库中的回归测试 tests/ui/error-codes/E0067.rs 正覆盖了这一组合场景:
use std::collections::LinkedList;
fn main() {
LinkedList::new() += 1; //~ ERROR E0368
//~^ ERROR E0067
}
其期望输出文件 tests/ui/error-codes/E0067.stderr 显示,编译器会对同一行报两条错误,其中 E0067 的标注正是:
error[E0067]: invalid left-hand side of assignment
|
LL | LinkedList::new() += 1;
| ----------------- ^^
| |
| cannot assign to this expression
场景三:二元运算结果当左侧
fn main() {
let mut a = 1;
let mut b = 2;
// (a + b) += 1; // error[E0067]:加法结果是无名临时值
a += b; // ok:读 a、加 b、写回 a
// 若要同时改两个变量,应分开写:
// a += 1;
// b += 1;
}
a + b 的求值结果没有对应的存储槽位,不能作为复合赋值左侧。这是"把右侧语义误当作左侧"的典型笔误。
六、编译器给出的智能建议
值得一提的是,rustc 对 E0067 并非只会生硬报错。回看 compiler/rustc_hir_typeck/src/op.rs,在复合赋值场景中,报错逻辑会额外探测 *lhs 是否存在合法的运算符重载实现:
self.check_lhs_assignable(lhs, E0067, op.span, |err| {
if let Some(lhs_deref_ty) = self.deref_once_mutably_for_diagnostic(lhs_ty) {
if /* 检查 deref 一次后是否可行 */ {
// ...
err.span_suggestion_verbose(
lhs.span.shrink_to_lo(),
"consider dereferencing the left-hand side of this operation",
"*", // 建议在左侧插入一个 *
Applicability::MaybeIncorrect,
);
}
}
// ...
});
也就是说,当代码是 foo() += rhs 而 *foo() += rhs 恰好合法(例如左侧解引用后类型实现了 AddAssign)时,编译器会主动建议在左侧加一个 * 解引用运算符。理解这条建议背后的机制,能帮助你在收到提示时快速判断它是否适用。
七、修复清单与自查要点
将以上内容汇总成可直接套用的自查清单:
- 左侧是不是字面量(
12 += 1)?→ 换成mut变量; - 左侧是不是函数/方法调用、构造表达式或运算结果(
LinkedList::new() += 1)?→ 把它先存进mut变量,或对返回的&mut结果用*解引用后再复合赋值; - 左侧变量是否声明为
mut?→ 未声明为mut时虽然不会报 E0067,但会触发E0384不可变赋值错误,同样需要补mut; - 是不是普通
=赋值报的E0070?→ 它负责普通赋值路径,修复思路与 E0067 完全一致:把左侧换成位置表达式。
修复完成后,可以用 rustc 直接验证。在装有该版本工具链的终端里执行错误码查询:
rustc --explain E0067
该命令会打印与 compiler/rustc_error_codes/src/error_codes/E0067.md 同源的详细解释。error_codes 目录下每个 .md 文件与 rustc --explain <code> 一一对应,是查看全部错误码长解释的首选路径;相应的 UI 回归测试则集中在 tests/ui/error-codes/ 目录,若想阅读更多错误码的实际触发样例,可按文件名(如 E0067.rs + 同名 .stderr)检索。
结语
E0067 是 Rust 所有"赋值左侧不合法"类错误中最常见的一支。它的判断标准非常清晰——左侧必须是能代表存储位置的 place expression;触发它时,把右手边那个"算出来的值"先存进一个 mut 变量,或者对 &mut 结果做一次 * 解引用,问题通常就能迎刃而解。通过 错误码文档、expr.rs 的 check_lhs_assignable 与 op.rs 的 check_expr_assign_op 三处对照阅读,你已经能从"报错信息—判定逻辑—修复策略"三个层面完整理解这一类编译错误。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00